Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
// Qa Matrix plugin module implements cli paths behavior.
import path from "node:path";
import { assertNoSymlinkParents, pathScope } from "openclaw/plugin-sdk/security-runtime";
export function resolveRepoRelativeOutputDir(repoRoot: string, outputDir?: string) {
if (!outputDir) {
return undefined;
}
if (path.isAbsolute(outputDir)) {
throw new Error("--output-dir must be a relative path inside the repo root.");
}
const resolved = path.resolve(repoRoot, outputDir);
const relative = path.relative(repoRoot, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error("--output-dir must stay within the repo root.");
}
return resolved;
}
function assertRepoRelativePath(repoRoot: string, targetPath: string, label: string) {
const relative = path.relative(repoRoot, targetPath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`${label} must stay within the repo root.`);
}
}
export async function ensureRepoBoundDirectory(repoRoot: string, targetDir: string, label: string) {
const repoRootResolved = path.resolve(repoRoot);
const targetResolved = path.resolve(targetDir);
assertRepoRelativePath(repoRootResolved, targetResolved, label);
try {
await assertNoSymlinkParents({
rootDir: repoRootResolved,
targetPath: targetResolved,
messagePrefix: label,
});
} catch (error) {
if (error instanceof Error && error.message.includes("symlink")) {
throw new Error(`${label} must not traverse symlinks.`, { cause: error });
}
throw error;
}
const result = await pathScope(repoRootResolved, { label }).ensureDir(targetResolved);
if (!result.ok) {
throw new Error(`${label} must stay within the repo root.`);
}
return result.path;
}

View File

@@ -0,0 +1,181 @@
// Qa Matrix tests cover cli plugin behavior.
import { mkdir, mkdtemp, readFile, rm, symlink } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const runMatrixQaLive = vi.hoisted(() => vi.fn());
const closeGlobalDispatcher = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("./runners/contract/runtime.js", () => ({
runMatrixQaLive,
}));
vi.mock("undici", async () => {
const actual = await vi.importActual<typeof import("undici")>("undici");
return {
...actual,
getGlobalDispatcher: () => ({
close: closeGlobalDispatcher,
}),
};
});
import { runQaMatrixCommand } from "./cli.runtime.js";
const tmpDirs: string[] = [];
async function expectPathMissing(targetPath: string): Promise<void> {
let error: unknown;
try {
await readFile(targetPath, "utf8");
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
}
describe("matrix qa cli runtime", () => {
const originalRunNodeOutputLog = process.env.OPENCLAW_RUN_NODE_OUTPUT_LOG;
afterEach(async () => {
vi.clearAllMocks();
if (originalRunNodeOutputLog === undefined) {
delete process.env.OPENCLAW_RUN_NODE_OUTPUT_LOG;
} else {
process.env.OPENCLAW_RUN_NODE_OUTPUT_LOG = originalRunNodeOutputLog;
}
await Promise.all(tmpDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
it("rejects non-env credential sources for the disposable Matrix lane", async () => {
await expect(
runQaMatrixCommand({
credentialSource: "convex",
}),
).rejects.toThrow("Matrix QA currently supports only --credential-source env");
});
it("passes through default env credential source options", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-cli-"));
tmpDirs.push(repoRoot);
runMatrixQaLive.mockResolvedValue({
reportPath: "/tmp/matrix-report.md",
summaryPath: "/tmp/matrix-summary.json",
observedEventsPath: "/tmp/matrix-events.json",
routeStateManifestPath: "/tmp/matrix-route-state.json",
});
const originalStdoutWrite = process.stdout["write"];
process.stdout.write = (() => true) as typeof process.stdout.write;
try {
await runQaMatrixCommand({
repoRoot,
outputDir: ".artifacts/qa-e2e/matrix",
providerMode: "mock-openai",
credentialSource: "env",
});
} finally {
process.stdout.write = originalStdoutWrite;
}
expect(runMatrixQaLive).toHaveBeenCalledWith({
repoRoot,
outputDir: path.join(repoRoot, ".artifacts/qa-e2e/matrix"),
providerMode: "mock-openai",
primaryModel: undefined,
alternateModel: undefined,
fastMode: undefined,
failFast: undefined,
profile: undefined,
scenarioIds: undefined,
sutAccountId: undefined,
credentialSource: "env",
credentialRole: undefined,
});
expect(closeGlobalDispatcher).toHaveBeenCalledTimes(1);
});
it("reuses a run-node output log instead of installing a nested tee", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-cli-"));
tmpDirs.push(repoRoot);
const outputPath = path.join(repoRoot, "run-node-output.log");
process.env.OPENCLAW_RUN_NODE_OUTPUT_LOG = outputPath;
runMatrixQaLive.mockResolvedValue({
reportPath: "/tmp/matrix-report.md",
summaryPath: "/tmp/matrix-summary.json",
observedEventsPath: "/tmp/matrix-events.json",
routeStateManifestPath: "/tmp/matrix-route-state.json",
});
const originalStdoutWrite = process.stdout["write"];
process.stdout.write = vi.fn(() => true) as unknown as typeof process.stdout.write;
try {
await runQaMatrixCommand({
repoRoot,
outputDir: ".artifacts/qa-e2e/matrix",
providerMode: "mock-openai",
credentialSource: "env",
});
} finally {
process.stdout.write = originalStdoutWrite;
}
expect(runMatrixQaLive).toHaveBeenCalledOnce();
await expectPathMissing(outputPath);
});
it.runIf(process.platform !== "win32")(
"rejects output dirs that traverse repo-local symlinks",
async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-cli-"));
const externalOutputRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-external-"));
tmpDirs.push(repoRoot, externalOutputRoot);
await mkdir(path.join(repoRoot, ".artifacts"), { recursive: true });
await symlink(externalOutputRoot, path.join(repoRoot, ".artifacts", "qa-e2e"));
await expect(
runQaMatrixCommand({
repoRoot,
outputDir: ".artifacts/qa-e2e/matrix",
providerMode: "mock-openai",
credentialSource: "env",
}),
).rejects.toThrow("Matrix QA output dir must not traverse symlinks.");
expect(runMatrixQaLive).not.toHaveBeenCalled();
},
);
it("preserves the Matrix QA failure when output log cleanup also fails", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-cli-"));
tmpDirs.push(repoRoot);
const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "matrix");
await mkdir(path.join(outputDir, "matrix-qa-output.log"), { recursive: true });
runMatrixQaLive.mockRejectedValue(new Error("scenario failed"));
const stderrChunks: string[] = [];
const originalStdoutWrite = process.stdout["write"];
const originalStderrWrite = process.stderr["write"];
process.stdout.write = (() => true) as typeof process.stdout.write;
process.stderr.write = ((chunk: string | Buffer) => {
stderrChunks.push(String(chunk));
return true;
}) as typeof process.stderr.write;
try {
await expect(
runQaMatrixCommand({
repoRoot,
outputDir: ".artifacts/qa-e2e/matrix",
providerMode: "mock-openai",
credentialSource: "env",
}),
).rejects.toThrow("scenario failed");
} finally {
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
}
expect(stderrChunks.join("")).toContain("Matrix QA output log error");
});
});

View File

@@ -0,0 +1,114 @@
// Qa Matrix plugin module implements cli behavior.
import {
printLiveTransportQaArtifacts,
startLiveTransportQaOutputTee,
} from "openclaw/plugin-sdk/qa-runtime";
import { ensureRepoBoundDirectory } from "./cli-paths.js";
import { runMatrixQaLive } from "./runners/contract/runtime.js";
import type { LiveTransportQaCommandOptions } from "./shared/live-transport-cli.js";
import { resolveLiveTransportQaRunOptions } from "./shared/live-transport-cli.runtime.js";
const RUN_NODE_OUTPUT_LOG_ENV = "OPENCLAW_RUN_NODE_OUTPUT_LOG";
async function closeMatrixQaCommandFetchHandles() {
try {
const { getGlobalDispatcher } = await import("undici");
const dispatcher = getGlobalDispatcher() as {
close?: () => Promise<void> | void;
};
await dispatcher.close?.();
} catch {
// Best-effort cleanup for short-lived QA commands. The command result and
// artifacts are already written; stale fetch keep-alive handles should not
// turn a green run into a failure.
}
}
function formatMatrixQaOutputTeeError(error: unknown) {
if (error instanceof Error) {
return error.message;
}
if (typeof error === "string") {
return error;
}
return "unknown error";
}
async function createMatrixQaCommandOutputTee(outputDir: string) {
const inheritedOutputPath = process.env[RUN_NODE_OUTPUT_LOG_ENV]?.trim();
if (inheritedOutputPath) {
return {
outputPath: inheritedOutputPath,
async stop() {},
};
}
return await startLiveTransportQaOutputTee({
fileName: "matrix-qa-output.log",
outputDir,
});
}
export async function runQaMatrixCommand(opts: LiveTransportQaCommandOptions) {
const runOptions = resolveLiveTransportQaRunOptions(opts);
const credentialSource = runOptions.credentialSource?.toLowerCase();
if (credentialSource && credentialSource !== "env") {
throw new Error(
"Matrix QA currently supports only --credential-source env (disposable local harness).",
);
}
const outputDir = await ensureRepoBoundDirectory(
runOptions.repoRoot,
runOptions.outputDir,
"Matrix QA output dir",
);
const checkedRunOptions = { ...runOptions, outputDir };
const outputTee = await createMatrixQaCommandOutputTee(checkedRunOptions.outputDir);
let primaryError: unknown;
let outputTeeError: unknown;
try {
process.stdout.write(`Matrix QA output: ${outputTee.outputPath}\n`);
const result = await runMatrixQaLive(checkedRunOptions);
printLiveTransportQaArtifacts("Matrix QA", {
report: result.reportPath,
"route/state manifest": result.routeStateManifestPath,
summary: result.summaryPath,
"observed events": result.observedEventsPath,
});
} catch (error) {
primaryError = error;
} finally {
try {
await outputTee.stop();
} catch (error) {
outputTeeError = error;
}
await closeMatrixQaCommandFetchHandles();
}
if (primaryError) {
if (outputTeeError) {
process.stderr.write(
`Matrix QA output log error: ${formatMatrixQaOutputTeeError(outputTeeError)}\n`,
);
}
throw toLintErrorObject(primaryError, "Non-Error thrown");
}
if (outputTeeError) {
throw toLintErrorObject(outputTeeError, "Non-Error thrown");
}
}
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
if (value instanceof Error) {
return value;
}
if (typeof value === "string") {
return new Error(value);
}
const error = new Error(fallbackMessage, { cause: value });
if ((typeof value === "object" && value !== null) || typeof value === "function") {
Object.assign(error, value);
}
return error;
}

View File

@@ -0,0 +1,102 @@
// Qa Matrix tests cover cli plugin behavior.
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { runQaMatrixCommand } = vi.hoisted(() => ({
runQaMatrixCommand: vi.fn(),
}));
vi.mock("./cli.runtime.js", () => ({
runQaMatrixCommand,
}));
import { matrixQaCliRegistration } from "./cli.js";
function mockProcessWrite(
_chunk: string | Uint8Array,
encodingOrCallback?: BufferEncoding | ((err?: Error | null) => void),
callback?: (err?: Error | null) => void,
) {
if (typeof encodingOrCallback === "function") {
encodingOrCallback();
} else {
callback?.();
}
return true;
}
describe("matrix qa cli registration", () => {
const originalDisableForceExit = process.env.OPENCLAW_QA_MATRIX_DISABLE_FORCE_EXIT;
let exitSpy: ReturnType<typeof vi.spyOn>;
let stderrSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
runQaMatrixCommand.mockReset();
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit(${String(code)})`);
});
stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(mockProcessWrite);
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(mockProcessWrite);
});
afterEach(() => {
if (originalDisableForceExit === undefined) {
delete process.env.OPENCLAW_QA_MATRIX_DISABLE_FORCE_EXIT;
} else {
process.env.OPENCLAW_QA_MATRIX_DISABLE_FORCE_EXIT = originalDisableForceExit;
}
exitSpy.mockRestore();
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
});
it("keeps disposable Matrix lane flags focused", () => {
const qa = new Command();
matrixQaCliRegistration.register(qa);
const matrix = qa.commands.find((command) => command.name() === "matrix");
const optionNames = matrix?.options.map((option) => option.long) ?? [];
for (const optionName of [
"--repo-root",
"--output-dir",
"--provider-mode",
"--model",
"--alt-model",
"--scenario",
"--fast",
"--profile",
"--fail-fast",
"--sut-account",
]) {
expect(optionNames).toContain(optionName);
}
expect(optionNames).not.toContain("--credential-source");
expect(optionNames).not.toContain("--credential-role");
});
it("exits with failure after Matrix artifacts are written for a failed run", async () => {
const qa = new Command();
matrixQaCliRegistration.register(qa);
runQaMatrixCommand.mockRejectedValue(new Error("Matrix QA failed.\nreport: /tmp/report.md"));
await expect(qa.parseAsync(["node", "openclaw", "matrix"])).rejects.toThrow("process.exit(1)");
expect(runQaMatrixCommand).toHaveBeenCalledOnce();
expect(stderrSpy).toHaveBeenCalledWith("Matrix QA failed.\nreport: /tmp/report.md\n");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("can disable the forced exit for direct test harnesses", async () => {
process.env.OPENCLAW_QA_MATRIX_DISABLE_FORCE_EXIT = "1";
const qa = new Command();
matrixQaCliRegistration.register(qa);
runQaMatrixCommand.mockRejectedValue(new Error("scenario failed"));
await expect(qa.parseAsync(["node", "openclaw", "matrix"])).rejects.toThrow("scenario failed");
expect(exitSpy).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,72 @@
// Qa Matrix plugin module implements cli behavior.
import type { Command } from "commander";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
createLazyCliRuntimeLoader,
createLiveTransportQaCliRegistration,
type LiveTransportQaCliRegistration,
type LiveTransportQaCommandOptions,
} from "./shared/live-transport-cli.js";
type MatrixQaCliRuntime = typeof import("./cli.runtime.js");
const DISABLE_MATRIX_QA_FORCE_EXIT_ENV = "OPENCLAW_QA_MATRIX_DISABLE_FORCE_EXIT";
const loadMatrixQaCliRuntime = createLazyCliRuntimeLoader<MatrixQaCliRuntime>(
() => import("./cli.runtime.js"),
);
async function flushProcessStream(stream: NodeJS.WriteStream) {
if (stream.destroyed || !stream.writable) {
return;
}
await new Promise<void>((resolve) => {
try {
stream.write("", () => resolve());
} catch {
resolve();
}
});
}
async function exitMatrixQaCommand(code: number): Promise<never> {
// Matrix crypto native handles can outlive the QA run even after every
// client/gateway/harness has been stopped. This command is single-shot, so
// artifact completion should terminate deterministically on both pass and fail.
await Promise.all([flushProcessStream(process.stdout), flushProcessStream(process.stderr)]);
process.exit(code);
}
async function runQaMatrix(opts: LiveTransportQaCommandOptions) {
const runtime = await loadMatrixQaCliRuntime();
if (process.env[DISABLE_MATRIX_QA_FORCE_EXIT_ENV] === "1") {
await runtime.runQaMatrixCommand(opts);
return;
}
try {
await runtime.runQaMatrixCommand(opts);
await exitMatrixQaCommand(0);
} catch (error) {
process.stderr.write(`${formatErrorMessage(error)}\n`);
await exitMatrixQaCommand(1);
}
}
export const matrixQaCliRegistration: LiveTransportQaCliRegistration =
createLiveTransportQaCliRegistration({
commandName: "matrix",
description: "Run the Docker-backed Matrix live QA lane against a disposable homeserver",
outputDirHelp: "Matrix QA artifact directory",
profileHelp:
"Matrix QA profile: all, fast, transport, media, e2ee-smoke, e2ee-deep, or e2ee-cli (default: all)",
failFastHelp: "Stop after the first failed Matrix check or scenario",
scenarioHelp: "Run only the named Matrix QA scenario (repeatable)",
sutAccountHelp: "Temporary Matrix account id inside the QA gateway config",
run: runQaMatrix,
});
export const qaRunnerCliRegistrations = [matrixQaCliRegistration] as const;
export function registerMatrixQaCli(qa: Command) {
matrixQaCliRegistration.register(qa);
}

View File

@@ -0,0 +1,21 @@
// Qa Matrix plugin module implements docker runtime behavior.
import {
createQaDockerRuntime,
type QaDockerFetchLike as FetchLike,
type QaDockerRunCommand as RunCommand,
} from "openclaw/plugin-sdk/qa-runtime";
export type { FetchLike, RunCommand };
const dockerRuntime = createQaDockerRuntime({
auditContext: "qa-matrix-docker-health-check",
});
export const {
execCommand,
fetchHealthUrl,
resolveComposeServiceUrl,
resolveHostPort,
waitForDockerServiceHealth,
waitForHealth,
} = dockerRuntime;

View File

@@ -0,0 +1,20 @@
// Qa Matrix tests cover run config plugin behavior.
import { describe, expect, it } from "vitest";
import { normalizeQaProviderMode } from "./run-config.js";
describe("matrix qa run config", () => {
it("defaults to live-frontier when provider mode is omitted", () => {
expect(normalizeQaProviderMode(undefined)).toBe("live-frontier");
expect(normalizeQaProviderMode("")).toBe("live-frontier");
});
it("keeps legacy live-openai as an alias for live-frontier", () => {
expect(normalizeQaProviderMode("live-openai")).toBe("live-frontier");
});
it("rejects unknown provider modes", () => {
expect(() => normalizeQaProviderMode("mystery-mode")).toThrow(
"unknown QA provider mode: mystery-mode",
);
});
});

View File

@@ -0,0 +1,17 @@
// Qa Matrix helper module supports run config behavior.
export type QaProviderMode = "mock-openai" | "live-frontier";
export type QaProviderModeInput = QaProviderMode | "live-openai";
export function normalizeQaProviderMode(input: unknown): QaProviderMode {
if (input === undefined || input === null || input === "") {
return "live-frontier";
}
if (input === "mock-openai") {
return "mock-openai";
}
if (input === "live-frontier" || input === "live-openai") {
return "live-frontier";
}
const details = typeof input === "string" ? `: ${input}` : "";
throw new Error(`unknown QA provider mode${details}`);
}

View File

@@ -0,0 +1,54 @@
// Qa Matrix tests cover model selection plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
const loadQaRuntimeModule = vi.hoisted(() => vi.fn());
const defaultQaRuntimeModelForMode = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/qa-runner-runtime", () => ({
loadQaRuntimeModule,
}));
describe("matrix qa model selection", () => {
beforeEach(() => {
defaultQaRuntimeModelForMode
.mockReset()
.mockImplementation((mode, options) =>
options?.alternate ? `${mode}:alt` : `${mode}:primary`,
);
loadQaRuntimeModule.mockReset().mockReturnValue({
defaultQaRuntimeModelForMode,
});
});
it("delegates default model selection through qa-lab runtime defaults", async () => {
const { resolveMatrixQaModels } = await import("./model-selection.js");
expect(resolveMatrixQaModels({ providerMode: "live-openai" })).toEqual({
providerMode: "live-frontier",
primaryModel: "live-frontier:primary",
alternateModel: "live-frontier:alt",
});
expect(defaultQaRuntimeModelForMode).toHaveBeenNthCalledWith(1, "live-frontier");
expect(defaultQaRuntimeModelForMode).toHaveBeenNthCalledWith(2, "live-frontier", {
alternate: true,
});
});
it("preserves explicit model overrides", async () => {
const { resolveMatrixQaModels } = await import("./model-selection.js");
expect(
resolveMatrixQaModels({
providerMode: "mock-openai",
primaryModel: "custom-primary",
alternateModel: "custom-alt",
}),
).toEqual({
providerMode: "mock-openai",
primaryModel: "custom-primary",
alternateModel: "custom-alt",
});
expect(loadQaRuntimeModule).not.toHaveBeenCalled();
expect(defaultQaRuntimeModelForMode).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,34 @@
// Qa Matrix plugin module implements model selection behavior.
import { loadQaRuntimeModule } from "openclaw/plugin-sdk/qa-runner-runtime";
import { normalizeQaProviderMode, type QaProviderModeInput } from "../../run-config.js";
export type ResolvedMatrixQaModels = {
providerMode: ReturnType<typeof normalizeQaProviderMode>;
primaryModel: string;
alternateModel: string;
};
export function resolveMatrixQaModels(params: {
providerMode?: QaProviderModeInput;
primaryModel?: string;
alternateModel?: string;
}): ResolvedMatrixQaModels {
const providerMode = normalizeQaProviderMode(params.providerMode ?? "live-frontier");
const primaryModel = params.primaryModel?.trim();
const alternateModel = params.alternateModel?.trim();
if (primaryModel && alternateModel) {
return {
providerMode,
primaryModel,
alternateModel,
};
}
const qaRuntime = loadQaRuntimeModule();
return {
providerMode,
primaryModel: primaryModel || qaRuntime.defaultQaRuntimeModelForMode(providerMode),
alternateModel:
alternateModel || qaRuntime.defaultQaRuntimeModelForMode(providerMode, { alternate: true }),
};
}

View File

@@ -0,0 +1,806 @@
// Qa Matrix tests cover runtime plugin behavior.
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { renderQaMarkdownReport } from "openclaw/plugin-sdk/qa-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { testing as liveTesting } from "./runtime.js";
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
type MatrixQaSummaryInput = Parameters<typeof liveTesting.buildMatrixQaSummary>[0];
type MatrixQaSummaryInputOverrides = Partial<Omit<MatrixQaSummaryInput, "timings">> & {
timings?: Partial<MatrixQaSummaryInput["timings"]>;
};
function buildMatrixQaSummaryInput(
overrides: MatrixQaSummaryInputOverrides = {},
): MatrixQaSummaryInput {
const timings: MatrixQaSummaryInput["timings"] = {
artifactWriteMs: 5,
canaryMs: 40,
harnessBootMs: 100,
initialGatewayBootMs: 200,
provisioningMs: 300,
scenarioGatewayBootMs: 50,
scenarioRestartGatewayMs: 60,
scenarioTransportInterruptMs: 70,
scenarios: [],
totalMs: 825,
...overrides.timings,
};
return {
artifactPaths: {
observedEvents: "/tmp/observed.json",
report: "/tmp/report.md",
routeStateManifest: "/tmp/route-state.json",
summary: "/tmp/summary.json",
},
checks: [{ name: "Matrix harness ready", status: "pass" }],
config: {
default: liveTesting.buildMatrixQaConfigSnapshot({
driverUserId: "@driver:matrix-qa.test",
observerUserId: "@observer:matrix-qa.test",
sutUserId: "@sut:matrix-qa.test",
topology: {
defaultRoomId: "!room:matrix-qa.test",
defaultRoomKey: "main",
rooms: [],
},
}),
scenarios: [],
},
differentialProbe: {
profile: "matrix-qa-v1",
steps: [],
sync: { continuity: true, incrementalStatus: 200, initialStatus: 200 },
},
finishedAt: "2026-04-10T10:05:00.000Z",
harness: {
baseUrl: "http://127.0.0.1:28008/",
composeFile: "/tmp/docker-compose.yml",
dmRoomIds: [],
image: "ghcr.io/matrix-construct/tuwunel:v1.5.1",
roomId: "!room:matrix-qa.test",
roomIds: ["!room:matrix-qa.test"],
serverName: "matrix-qa.test",
},
observedEventCount: 4,
scenarios: [],
startedAt: "2026-04-10T10:00:00.000Z",
sutAccountId: "sut",
userIds: {
driver: "@driver:matrix-qa.test",
observer: "@observer:matrix-qa.test",
sut: "@sut:matrix-qa.test",
},
...overrides,
timings,
};
}
describe("matrix live qa runtime", () => {
it("preserves a failed differential probe check without a probe payload", () => {
const summary = liveTesting.buildMatrixQaSummary(
buildMatrixQaSummaryInput({
checks: [
{ name: "Matrix harness ready", status: "pass" },
{
details: "missing-state response did not return M_NOT_FOUND",
name: "Matrix differential probe",
status: "fail",
},
],
differentialProbe: undefined,
}),
);
expect(summary.differentialProbe).toBeUndefined();
expect(summary.counts).toEqual({ failed: 1, passed: 1, total: 2 });
});
it("uses unique default artifact directories", () => {
const repoRoot = "/repo";
const firstOutputDir = liveTesting.resolveMatrixQaOutputDir({ repoRoot });
const secondOutputDir = liveTesting.resolveMatrixQaOutputDir({ repoRoot });
expect(path.dirname(firstOutputDir)).toBe(path.join(repoRoot, ".artifacts", "qa-e2e"));
expect(path.basename(firstOutputDir)).toMatch(/^matrix-[a-z0-9]+-[a-f0-9]{8}$/u);
expect(secondOutputDir).not.toBe(firstOutputDir);
expect(liveTesting.resolveMatrixQaOutputDir({ outputDir: ".artifacts/custom", repoRoot })).toBe(
".artifacts/custom",
);
});
it("prints Matrix QA progress by default for non-interactive runs", () => {
const previous = process.env.OPENCLAW_QA_MATRIX_PROGRESS;
delete process.env.OPENCLAW_QA_MATRIX_PROGRESS;
try {
expect(liveTesting.shouldWriteMatrixQaProgress()).toBe(true);
process.env.OPENCLAW_QA_MATRIX_PROGRESS = "0";
expect(liveTesting.shouldWriteMatrixQaProgress()).toBe(false);
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_QA_MATRIX_PROGRESS;
} else {
process.env.OPENCLAW_QA_MATRIX_PROGRESS = previous;
}
}
});
it("summarizes relevant gateway stderr lines for Matrix QA failures", () => {
const summary = liveTesting.summarizeMatrixQaGatewayStderrLog(
[
"normal gateway progress",
"Authorization: Bearer abcdefghijklmnopqrstuvwxyz",
"[agent/embedded] embedded run failover decision: stage=prompt decision=surface_error reason=auth",
"unexpected status 401 Unauthorized: Missing bearer or basic authentication in header",
].join("\n"),
);
expect(summary).toContain("gateway stderr tail:");
expect(summary).toContain("Authorization: Bearer");
expect(summary).toContain("reason=auth");
expect(summary).toContain("unexpected status 401 Unauthorized");
expect(summary).not.toContain("normal gateway progress");
expect(summary).not.toContain("abcdefghijklmnopqrstuvwxyz");
});
it("skips empty gateway stderr summaries", () => {
expect(liveTesting.summarizeMatrixQaGatewayStderrLog("\n\n")).toBeUndefined();
});
it("normalizes the Matrix QA hard timeout env", () => {
const previous = process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS;
try {
process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS = "12345";
expect(liveTesting.createMatrixQaRunDeadline().timeoutMs).toBe(12345);
process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS = "+012345";
expect(liveTesting.createMatrixQaRunDeadline().timeoutMs).toBe(12345);
process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS = "nope";
expect(liveTesting.createMatrixQaRunDeadline().timeoutMs).toBe(30 * 60_000);
process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS = "1e3";
expect(liveTesting.createMatrixQaRunDeadline().timeoutMs).toBe(30 * 60_000);
process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS = "1.5";
expect(liveTesting.createMatrixQaRunDeadline().timeoutMs).toBe(30 * 60_000);
process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS = String(Number.MAX_SAFE_INTEGER);
expect(liveTesting.createMatrixQaRunDeadline().timeoutMs).toBe(MAX_TIMER_TIMEOUT_MS);
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS;
} else {
process.env.OPENCLAW_QA_MATRIX_TIMEOUT_MS = previous;
}
}
});
it("does not start Matrix QA work after the hard run deadline expires", async () => {
const task = vi.fn(async () => "started");
vi.spyOn(Date, "now").mockReturnValue(1_001);
await expect(
liveTesting.withMatrixQaRunDeadline(
{
deadlineMs: 1_000,
timeoutMs: 30_000,
},
"Matrix scenario late",
task,
),
).rejects.toThrow(/Matrix scenario late not started because Matrix QA run timed out/u);
expect(task).not.toHaveBeenCalled();
});
it("passes the remaining Matrix QA run budget to the phase timeout", async () => {
vi.spyOn(Date, "now").mockReturnValue(1_000);
expect(
liveTesting.remainingMatrixQaRunMs(
{
deadlineMs: 1_250,
timeoutMs: 30_000,
},
"Matrix canary",
),
).toBe(250);
});
it("normalizes the Matrix QA canary timeout env", () => {
const previous = process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS;
try {
delete process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS;
expect(liveTesting.resolveMatrixQaCanaryTimeoutMs()).toBe(45_000);
process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = "90000";
expect(liveTesting.resolveMatrixQaCanaryTimeoutMs()).toBe(90_000);
process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = "+090000";
expect(liveTesting.resolveMatrixQaCanaryTimeoutMs()).toBe(90_000);
process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = "nope";
expect(liveTesting.resolveMatrixQaCanaryTimeoutMs()).toBe(45_000);
process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = "0x1000";
expect(liveTesting.resolveMatrixQaCanaryTimeoutMs()).toBe(45_000);
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS;
} else {
process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = previous;
}
}
});
it("uses a scenario provider override for the canary only when the whole run is pinned", () => {
const blockStreamingScenario = liveTesting.MATRIX_QA_SCENARIOS.find(
(scenario) => scenario.id === "matrix-room-block-streaming",
);
const threadScenario = liveTesting.MATRIX_QA_SCENARIOS.find(
(scenario) => scenario.id === "matrix-thread-follow-up",
);
expect(blockStreamingScenario).toBeDefined();
expect(threadScenario).toBeDefined();
const pinnedSchedule = liveTesting.scheduleMatrixQaScenariosInCatalogOrder([
blockStreamingScenario!,
]);
expect(liveTesting.selectMatrixQaCanaryProviderMode(pinnedSchedule)).toBe("mock-openai");
const mixedSchedule = liveTesting.scheduleMatrixQaScenariosInCatalogOrder([
threadScenario!,
blockStreamingScenario!,
]);
expect(liveTesting.selectMatrixQaCanaryProviderMode(mixedSchedule)).toBeUndefined();
});
it("preserves explicit model pins when a scenario keeps the suite provider", () => {
const defaultModels = {
alternateModel: "mock-openai/custom-alt",
primaryModel: "mock-openai/custom",
providerMode: "mock-openai" as const,
};
expect(
liveTesting.resolveMatrixQaGatewayModels({
defaultModels,
providerMode: "mock-openai",
}),
).toEqual(defaultModels);
});
it("injects a temporary Matrix account into the QA gateway config", () => {
const baseCfg: OpenClawConfig = {
plugins: {
allow: ["memory-core", "qa-channel"],
entries: {
"memory-core": { enabled: true },
"qa-channel": { enabled: true },
},
},
};
const next = liveTesting.buildMatrixQaConfig(baseCfg, {
driverUserId: "@driver:matrix-qa.test",
homeserver: "http://127.0.0.1:28008/",
observerUserId: "@observer:matrix-qa.test",
sutAccessToken: "syt_sut",
sutAccountId: "sut",
sutDeviceId: "DEVICE123",
sutUserId: "@sut:matrix-qa.test",
topology: {
defaultRoomId: "!room:matrix-qa.test",
defaultRoomKey: "main",
rooms: [
{
key: "main",
kind: "group",
memberRoles: ["driver", "observer", "sut"],
memberUserIds: [
"@driver:matrix-qa.test",
"@observer:matrix-qa.test",
"@sut:matrix-qa.test",
],
name: "Matrix QA",
requireMention: true,
roomId: "!room:matrix-qa.test",
},
],
},
});
expect(next.plugins?.allow).toContain("matrix");
expect(next.plugins?.entries?.matrix).toEqual({ enabled: true });
expect(next.messages?.groupChat?.visibleReplies).toBe("automatic");
expect(next.channels?.matrix).toEqual({
enabled: true,
defaultAccount: "sut",
accounts: {
sut: {
accessToken: "syt_sut",
deviceId: "DEVICE123",
dm: { enabled: false },
enabled: true,
encryption: false,
groupAllowFrom: ["@driver:matrix-qa.test"],
groupPolicy: "allowlist",
groups: {
"!room:matrix-qa.test": {
enabled: true,
requireMention: true,
},
},
homeserver: "http://127.0.0.1:28008/",
network: {
dangerouslyAllowPrivateNetwork: true,
},
replyToMode: "off",
threadReplies: "inbound",
userId: "@sut:matrix-qa.test",
},
},
});
});
it("derives Matrix DM + multi-room config from provisioned topology", () => {
const next = liveTesting.buildMatrixQaConfig(
{},
{
driverUserId: "@driver:matrix-qa.test",
homeserver: "http://127.0.0.1:28008/",
observerUserId: "@observer:matrix-qa.test",
sutAccessToken: "syt_sut",
sutAccountId: "sut",
sutUserId: "@sut:matrix-qa.test",
topology: {
defaultRoomId: "!room-a:matrix-qa.test",
defaultRoomKey: "main",
rooms: [
{
key: "main",
kind: "group",
memberRoles: ["driver", "observer", "sut"],
memberUserIds: [
"@driver:matrix-qa.test",
"@observer:matrix-qa.test",
"@sut:matrix-qa.test",
],
name: "Matrix QA A",
requireMention: true,
roomId: "!room-a:matrix-qa.test",
},
{
key: "secondary",
kind: "group",
memberRoles: ["driver", "sut"],
memberUserIds: ["@driver:matrix-qa.test", "@sut:matrix-qa.test"],
name: "Matrix QA B",
requireMention: false,
roomId: "!room-b:matrix-qa.test",
},
{
key: "sut-dm",
kind: "dm",
memberRoles: ["driver", "sut"],
memberUserIds: ["@driver:matrix-qa.test", "@sut:matrix-qa.test"],
name: "Matrix QA DM",
requireMention: false,
roomId: "!dm:matrix-qa.test",
},
],
},
},
);
expect(next.channels?.matrix?.accounts?.sut?.dm).toEqual({
allowFrom: ["@driver:matrix-qa.test"],
enabled: true,
policy: "allowlist",
});
expect(next.channels?.matrix?.accounts?.sut?.groups).toEqual({
"!room-a:matrix-qa.test": {
enabled: true,
requireMention: true,
},
"!room-b:matrix-qa.test": {
enabled: true,
requireMention: false,
},
});
});
it("records default and per-scenario Matrix config snapshots in the summary", () => {
const summary = liveTesting.buildMatrixQaSummary({
artifactPaths: {
observedEvents: "/tmp/observed.json",
report: "/tmp/report.md",
routeStateManifest: "/tmp/route-state.json",
summary: "/tmp/summary.json",
},
checks: [{ name: "Matrix harness ready", status: "pass" }],
config: {
default: liveTesting.buildMatrixQaConfigSnapshot({
driverUserId: "@driver:matrix-qa.test",
observerUserId: "@observer:matrix-qa.test",
sutUserId: "@sut:matrix-qa.test",
topology: {
defaultRoomId: "!room:matrix-qa.test",
defaultRoomKey: "main",
rooms: [
{
key: "main",
kind: "group",
memberRoles: ["driver", "observer", "sut"],
memberUserIds: [
"@driver:matrix-qa.test",
"@observer:matrix-qa.test",
"@sut:matrix-qa.test",
],
name: "Matrix QA",
requireMention: true,
roomId: "!room:matrix-qa.test",
},
],
},
}),
scenarios: [
{
id: "matrix-room-thread-reply-override",
title: "Matrix threadReplies always keeps room replies threaded",
config: liveTesting.buildMatrixQaConfigSnapshot({
driverUserId: "@driver:matrix-qa.test",
observerUserId: "@observer:matrix-qa.test",
overrides: {
threadReplies: "always",
},
sutUserId: "@sut:matrix-qa.test",
topology: {
defaultRoomId: "!room:matrix-qa.test",
defaultRoomKey: "main",
rooms: [
{
key: "main",
kind: "group",
memberRoles: ["driver", "observer", "sut"],
memberUserIds: [
"@driver:matrix-qa.test",
"@observer:matrix-qa.test",
"@sut:matrix-qa.test",
],
name: "Matrix QA",
requireMention: true,
roomId: "!room:matrix-qa.test",
},
],
},
}),
},
],
},
differentialProbe: {
profile: "matrix-qa-v1",
steps: [],
sync: { continuity: true, incrementalStatus: 200, initialStatus: 200 },
},
finishedAt: "2026-04-10T10:05:00.000Z",
harness: {
baseUrl: "http://127.0.0.1:28008/",
composeFile: "/tmp/docker-compose.yml",
dmRoomIds: [],
image: "ghcr.io/matrix-construct/tuwunel:v1.5.1",
roomId: "!room:matrix-qa.test",
roomIds: ["!room:matrix-qa.test"],
serverName: "matrix-qa.test",
},
observedEventCount: 0,
scenarios: [],
startedAt: "2026-04-10T10:00:00.000Z",
sutAccountId: "sut",
timings: {
artifactWriteMs: 5,
canaryMs: 40,
harnessBootMs: 100,
initialGatewayBootMs: 200,
provisioningMs: 300,
scenarioGatewayBootMs: 50,
scenarioRestartGatewayMs: 60,
scenarioTransportInterruptMs: 70,
scenarios: [],
totalMs: 825,
},
userIds: {
driver: "@driver:matrix-qa.test",
observer: "@observer:matrix-qa.test",
sut: "@sut:matrix-qa.test",
},
});
const config = summary.config;
expect(config.default.replyToMode).toBe("off");
expect(config.default.threadReplies).toBe("inbound");
expect(config.scenarios).toHaveLength(1);
expect(config.scenarios[0]?.id).toBe("matrix-room-thread-reply-override");
expect(config.scenarios[0]?.config.threadReplies).toBe("always");
});
it("preserves negative-scenario artifacts in the Matrix summary", () => {
const summary = liveTesting.buildMatrixQaSummary(
buildMatrixQaSummaryInput({
scenarios: [
{
id: "matrix-mention-gating",
title: "Matrix room message without mention does not trigger",
status: "pass",
details: "no reply",
artifacts: {
actorUserId: "@driver:matrix-qa.test",
driverEventId: "$driver",
expectedNoReplyWindowMs: 8_000,
token: "MATRIX_QA_NOMENTION_TOKEN",
triggerBody: "reply with only this exact marker: MATRIX_QA_NOMENTION_TOKEN",
},
},
],
timings: {
scenarios: [
{
durationMs: 80,
gatewayBootMs: 0,
gatewayRestartMs: 0,
id: "matrix-mention-gating",
title: "Matrix room message without mention does not trigger",
transportInterruptMs: 0,
},
],
totalMs: 905,
},
}),
);
expect(summary.counts.total).toBe(2);
expect(summary.counts.passed).toBe(2);
expect(summary.counts.failed).toBe(0);
expect(summary.scenarios[0]?.id).toBe("matrix-mention-gating");
expect(summary.scenarios[0]?.artifacts?.actorUserId).toBe("@driver:matrix-qa.test");
expect(summary.scenarios[0]?.artifacts?.expectedNoReplyWindowMs).toBe(8_000);
expect(summary.scenarios[0]?.artifacts?.triggerBody).toBe(
"reply with only this exact marker: MATRIX_QA_NOMENTION_TOKEN",
);
expect(summary.timings.totalMs).toBe(905);
});
it("keeps failing Matrix scenario details and timings complete in summary + report output", () => {
const summary = liveTesting.buildMatrixQaSummary(
buildMatrixQaSummaryInput({
observedEventCount: 6,
scenarios: [
{
id: "matrix-reaction-not-a-reply",
title: "Matrix reactions do not trigger a fresh bot reply",
status: "fail",
details: [
"unexpected SUT reply after reaction from @driver:matrix-qa.test",
"reaction event: $reaction",
"unexpected reply event: $reply",
].join("\n"),
},
],
timings: {
scenarios: [
{
durationMs: 8_000,
gatewayBootMs: 0,
gatewayRestartMs: 0,
id: "matrix-reaction-not-a-reply",
title: "Matrix reactions do not trigger a fresh bot reply",
transportInterruptMs: 0,
},
],
totalMs: 825,
},
}),
);
expect(summary.counts.total).toBe(2);
expect(summary.counts.passed).toBe(1);
expect(summary.counts.failed).toBe(1);
expect(summary.scenarios[0]?.id).toBe("matrix-reaction-not-a-reply");
expect(summary.scenarios[0]?.status).toBe("fail");
expect(summary.scenarios[0]?.details).toContain("reaction event: $reaction");
expect(summary.timings.scenarios[0]?.id).toBe("matrix-reaction-not-a-reply");
expect(summary.timings.scenarios[0]?.durationMs).toBe(8_000);
const report = renderQaMarkdownReport({
title: "Matrix QA Report",
startedAt: new Date(summary.startedAt),
finishedAt: new Date(summary.finishedAt),
checks: summary.checks,
scenarios: summary.scenarios.map((scenario) => ({
details: scenario.details,
name: scenario.title,
status: scenario.status,
})),
notes: [`observed events: ${summary.observedEventsPath}`],
});
expect(report).toContain("### Matrix reactions do not trigger a fresh bot reply");
expect(report).toContain("unexpected SUT reply after reaction from @driver:matrix-qa.test");
expect(report).toContain("reaction event: $reaction");
expect(report).toContain("observed events: /tmp/observed.json");
});
it("groups Matrix scenario execution by gateway config while preserving tail scenarios", () => {
const scenarios = liveTesting.findMatrixQaScenarios([
"matrix-thread-follow-up",
"matrix-e2ee-cli-encryption-setup-multi-account",
"matrix-thread-isolation",
"matrix-e2ee-cli-setup-then-gateway-reply",
"matrix-e2ee-cli-self-verification",
"matrix-e2ee-wrong-account-recovery-key",
]);
expect(
liveTesting
.scheduleMatrixQaScenariosInCatalogOrder(scenarios)
.map(({ scenario }) => scenario.id),
).toEqual([
"matrix-thread-follow-up",
"matrix-thread-isolation",
"matrix-e2ee-cli-self-verification",
"matrix-e2ee-cli-encryption-setup-multi-account",
"matrix-e2ee-cli-setup-then-gateway-reply",
"matrix-e2ee-wrong-account-recovery-key",
]);
});
it("uses the scenario timeout for post-restart Matrix readiness", () => {
expect(
liveTesting.getMatrixQaScenarioRestartReadyTimeoutMs({
timeoutMs: 180_000,
}),
).toBe(180_000);
});
it("retries Matrix gateway config patches after a stale config hash", async () => {
const patch = {
channels: {
matrix: {
enabled: true,
},
},
};
const gateway = {
call: vi
.fn()
.mockResolvedValueOnce({ hash: "hash-old" })
.mockRejectedValueOnce(
new Error("config changed since last load; re-run config.get and retry"),
)
.mockResolvedValueOnce({ hash: "hash-fresh" })
.mockResolvedValueOnce(undefined),
};
await liveTesting.patchMatrixQaGatewayConfig({
gateway: gateway as never,
patch,
replacePaths: ["channels.matrix.accounts.sut.groupAllowFrom"],
restartDelayMs: 250,
});
expect(gateway.call).toHaveBeenNthCalledWith(1, "config.get", {}, { timeoutMs: 60_000 });
expect(gateway.call).toHaveBeenNthCalledWith(
2,
"config.patch",
{
baseHash: "hash-old",
raw: JSON.stringify(patch, null, 2),
replacePaths: ["channels.matrix.accounts.sut.groupAllowFrom"],
restartDelayMs: 250,
},
{ timeoutMs: 60_000 },
);
expect(gateway.call).toHaveBeenNthCalledWith(3, "config.get", {}, { timeoutMs: 60_000 });
expect(gateway.call).toHaveBeenNthCalledWith(
4,
"config.patch",
{
baseHash: "hash-fresh",
raw: JSON.stringify(patch, null, 2),
replacePaths: ["channels.matrix.accounts.sut.groupAllowFrom"],
restartDelayMs: 250,
},
{ timeoutMs: 60_000 },
);
});
it("treats only connected, healthy Matrix accounts as ready", () => {
expect(liveTesting.isMatrixAccountReady({ running: true, connected: true })).toBe(true);
expect(liveTesting.isMatrixAccountReady({ running: true, connected: false })).toBe(false);
expect(
liveTesting.isMatrixAccountReady({
running: true,
connected: true,
restartPending: true,
}),
).toBe(false);
expect(
liveTesting.isMatrixAccountReady({
running: true,
connected: true,
healthState: "degraded",
}),
).toBe(false);
});
it("waits past not-ready Matrix status snapshots until the account is really ready", async () => {
vi.useFakeTimers();
const gateway = {
call: vi
.fn()
.mockResolvedValueOnce({
channelAccounts: {
matrix: [{ accountId: "sut", running: true, connected: false }],
},
})
.mockResolvedValueOnce({
channelAccounts: {
matrix: [{ accountId: "sut", running: true, connected: true }],
},
}),
};
const waitPromise = liveTesting.waitForMatrixChannelReady(gateway as never, "sut", {
timeoutMs: 1_000,
pollMs: 100,
});
await vi.advanceTimersByTimeAsync(100);
await expect(waitPromise).resolves.toBeUndefined();
expect(gateway.call).toHaveBeenCalledTimes(2);
});
it("fails readiness when the Matrix account never reaches a healthy connected state", async () => {
vi.useFakeTimers();
const gateway = {
call: vi.fn().mockResolvedValue({
channelAccounts: {
matrix: [{ accountId: "sut", running: true, connected: true, healthState: "degraded" }],
},
}),
};
const waitPromise = liveTesting.waitForMatrixChannelReady(gateway as never, "sut", {
timeoutMs: 250,
pollMs: 100,
});
const expectation = expect(waitPromise).rejects.toThrow(
'matrix account "sut" did not become ready',
);
await vi.advanceTimersByTimeAsync(300);
await expectation;
});
it("caps Matrix readiness status RPCs and sleeps to the remaining timeout budget", async () => {
vi.useFakeTimers();
const gateway = {
call: vi.fn().mockResolvedValue({
channelAccounts: {
matrix: [{ accountId: "sut", running: true, connected: true, healthState: "degraded" }],
},
}),
};
const waitPromise = liveTesting.waitForMatrixChannelReady(gateway as never, "sut", {
timeoutMs: 250,
pollMs: 1_000,
});
const expectation = expect(waitPromise).rejects.toThrow(
'matrix account "sut" did not become ready',
);
await vi.advanceTimersByTimeAsync(250);
await expectation;
expect(gateway.call).toHaveBeenCalledTimes(1);
expect(gateway.call).toHaveBeenCalledWith(
"channels.status",
{ probe: false, timeoutMs: 250 },
{ timeoutMs: 250 },
);
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,144 @@
// Qa Matrix plugin module implements scenario runtime allowbots behavior.
import { MATRIX_QA_BOT_DM_ROOM_KEY, resolveMatrixQaScenarioRoomId } from "./scenario-catalog.js";
import {
buildExactMarkerPrompt,
buildMatrixQaToken,
buildMentionPrompt,
createMatrixQaScenarioClient,
resolveMatrixQaNoReplyWindowMs,
runNoReplyExpectedScenario,
runTopologyScopedTopLevelScenario,
type MatrixQaScenarioContext,
} from "./scenario-runtime-shared.js";
import type { MatrixQaScenarioExecution } from "./scenario-types.js";
async function runObserverBotReplyScenario(params: {
context: MatrixQaScenarioContext;
roomKey?: string;
tokenPrefix: string;
withMention?: boolean;
}) {
return await runTopologyScopedTopLevelScenario({
accessToken: params.context.observerAccessToken,
actorId: "observer",
actorUserId: params.context.observerUserId,
context: params.context,
roomKey: params.roomKey ?? params.context.topology.defaultRoomKey,
tokenPrefix: params.tokenPrefix,
...(params.withMention === undefined ? {} : { withMention: params.withMention }),
});
}
async function runObserverBotNoReplyScenario(params: {
context: MatrixQaScenarioContext;
roomKey?: string;
tokenPrefix: string;
withMention?: boolean;
}) {
const token = buildMatrixQaToken(params.tokenPrefix);
const withMention = params.withMention !== false;
return await runNoReplyExpectedScenario({
accessToken: params.context.observerAccessToken,
actorId: "observer",
actorUserId: params.context.observerUserId,
baseUrl: params.context.baseUrl,
body: withMention
? buildMentionPrompt(params.context.sutUserId, token)
: buildExactMarkerPrompt(token),
...(withMention ? { mentionUserIds: [params.context.sutUserId] } : {}),
observedEvents: params.context.observedEvents,
roomId: resolveMatrixQaScenarioRoomId(params.context, params.roomKey),
syncState: params.context.syncState,
syncStreams: params.context.syncStreams,
sutUserId: params.context.sutUserId,
timeoutMs: resolveMatrixQaNoReplyWindowMs(params.context.timeoutMs),
token,
});
}
export async function runAllowBotsDefaultBlockScenario(context: MatrixQaScenarioContext) {
return await runObserverBotNoReplyScenario({
context,
tokenPrefix: "MATRIX_QA_ALLOWBOTS_DEFAULT_BLOCK",
});
}
export async function runAllowBotsTrueUnmentionedOpenRoomScenario(
context: MatrixQaScenarioContext,
) {
return await runObserverBotReplyScenario({
context,
tokenPrefix: "MATRIX_QA_ALLOWBOTS_TRUE_OPEN",
withMention: false,
});
}
export async function runAllowBotsMentionsMentionedRoomScenario(context: MatrixQaScenarioContext) {
return await runObserverBotReplyScenario({
context,
tokenPrefix: "MATRIX_QA_ALLOWBOTS_MENTIONS_MENTIONED",
});
}
export async function runAllowBotsMentionsUnmentionedOpenRoomBlockScenario(
context: MatrixQaScenarioContext,
) {
return await runObserverBotNoReplyScenario({
context,
tokenPrefix: "MATRIX_QA_ALLOWBOTS_MENTIONS_OPEN_BLOCK",
withMention: false,
});
}
export async function runAllowBotsMentionsDmUnmentionedScenario(context: MatrixQaScenarioContext) {
return await runObserverBotReplyScenario({
context,
roomKey: MATRIX_QA_BOT_DM_ROOM_KEY,
tokenPrefix: "MATRIX_QA_ALLOWBOTS_MENTIONS_DM",
withMention: false,
});
}
export async function runAllowBotsRoomOverrideBlocksAccountTrueScenario(
context: MatrixQaScenarioContext,
) {
return await runObserverBotNoReplyScenario({
context,
tokenPrefix: "MATRIX_QA_ALLOWBOTS_ROOM_BLOCK",
withMention: false,
});
}
export async function runAllowBotsRoomOverrideEnablesAccountOffScenario(
context: MatrixQaScenarioContext,
) {
return await runObserverBotReplyScenario({
context,
tokenPrefix: "MATRIX_QA_ALLOWBOTS_ROOM_ENABLE",
});
}
export async function runAllowBotsSelfSenderIgnoredScenario(
context: MatrixQaScenarioContext,
): Promise<MatrixQaScenarioExecution> {
const sutSender = createMatrixQaScenarioClient({
accessToken: context.sutAccessToken,
baseUrl: context.baseUrl,
});
const token = buildMatrixQaToken("MATRIX_QA_ALLOWBOTS_SELF_IGNORED");
return await runNoReplyExpectedScenario({
accessToken: context.observerAccessToken,
actorId: "observer",
actorUserId: context.sutUserId,
baseUrl: context.baseUrl,
body: buildExactMarkerPrompt(token),
observedEvents: context.observedEvents,
roomId: context.roomId,
sendClient: sutSender,
syncState: context.syncState,
syncStreams: context.syncStreams,
sutUserId: context.sutUserId,
timeoutMs: resolveMatrixQaNoReplyWindowMs(context.timeoutMs),
token,
});
}

View File

@@ -0,0 +1,731 @@
// Qa Matrix plugin module implements scenario runtime approval behavior.
import { randomUUID } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";
import { normalizeUniqueStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { MatrixQaObservedEvent } from "../../substrate/events.js";
import { MATRIX_QA_DRIVER_DM_ROOM_KEY, resolveMatrixQaScenarioRoomId } from "./scenario-catalog.js";
import {
advanceMatrixQaActorCursor,
buildMatrixQaToken,
createMatrixQaDriverScenarioClient,
createMatrixQaScenarioClient,
primeMatrixQaDriverScenarioClient,
type MatrixQaScenarioContext,
} from "./scenario-runtime-shared.js";
import type { MatrixQaScenarioExecution } from "./scenario-types.js";
const MATRIX_QA_APPROVAL_ALLOW_ONCE_REACTION = "✅";
const MATRIX_QA_APPROVAL_DENY_REACTION = "❌";
const MATRIX_QA_APPROVAL_DECISION_TIMEOUT_MS = 30_000;
const MATRIX_QA_APPROVAL_SHORT_WINDOW_MS = 4_000;
const MATRIX_QA_APPROVAL_LONG_COMMAND_TEXT = "matrix approval chunk fallback ".repeat(40);
type MatrixQaApprovalDecision = "allow-once" | "deny";
type MatrixQaApprovalKind = "exec" | "plugin";
type MatrixQaApprovalOptionReactionParams = {
context: MatrixQaScenarioContext;
emoji: string;
roomId: string;
targetEventId: string;
};
function requireMatrixQaGatewayCall(context: MatrixQaScenarioContext) {
if (!context.gatewayCall) {
throw new Error("Matrix approval QA scenario requires a live gateway RPC client");
}
return context.gatewayCall;
}
function buildMatrixApprovalArtifact(event: MatrixQaObservedEvent) {
if (!event.approval) {
throw new Error(`Matrix event ${event.eventId} did not include approval metadata`);
}
return {
...event.approval,
eventId: event.eventId,
roomId: event.roomId,
};
}
function isApprovalOptionReaction(
event: MatrixQaObservedEvent,
params: MatrixQaApprovalOptionReactionParams,
) {
return (
event.roomId === params.roomId &&
event.sender === params.context.sutUserId &&
event.type === "m.reaction" &&
event.reaction?.eventId === params.targetEventId &&
event.reaction.key === params.emoji
);
}
function hasObservedApprovalOptionReaction(params: MatrixQaApprovalOptionReactionParams) {
return params.context.observedEvents.some((event) => isApprovalOptionReaction(event, params));
}
function assertApprovalMetadata(params: {
event: { approval?: unknown; eventId: string };
expectedKind: MatrixQaApprovalKind;
}) {
const approval =
typeof params.event.approval === "object" && params.event.approval !== null
? (params.event.approval as {
allowedDecisions?: string[];
hasCommandText?: boolean;
id?: string;
kind?: string;
state?: string;
type?: string;
version?: number;
})
: null;
if (!approval) {
throw new Error(`approval event ${params.event.eventId} did not expose metadata`);
}
if (approval.kind !== params.expectedKind) {
throw new Error(
`approval event ${params.event.eventId} kind was ${approval.kind ?? "<missing>"} instead of ${params.expectedKind}`,
);
}
if (!approval.id) {
throw new Error(`approval event ${params.event.eventId} did not expose an approval id`);
}
if (approval.version !== 1) {
throw new Error(`approval event ${params.event.eventId} did not expose version=1`);
}
if (approval.type !== "approval.request") {
throw new Error(`approval event ${params.event.eventId} did not expose type=approval.request`);
}
if (approval.state !== "pending") {
throw new Error(`approval event ${params.event.eventId} did not expose state=pending`);
}
if (!approval.allowedDecisions?.includes("deny")) {
throw new Error(`approval event ${params.event.eventId} did not include deny`);
}
if (
params.expectedKind === "exec" &&
(!approval.allowedDecisions.includes("allow-once") || approval.hasCommandText !== true)
) {
throw new Error(`approval event ${params.event.eventId} did not expose exec approval fields`);
}
}
function isExpectedApprovalEvent(
event: MatrixQaObservedEvent,
params: {
context: MatrixQaScenarioContext;
expectedApprovalId: string;
expectedKind: MatrixQaApprovalKind;
roomId: string;
threadRootEventId?: string;
},
) {
return (
event.roomId === params.roomId &&
event.sender === params.context.sutUserId &&
event.type === "m.room.message" &&
event.approval?.kind === params.expectedKind &&
event.approval.id === params.expectedApprovalId &&
(!params.threadRootEventId || event.relatesTo?.eventId === params.threadRootEventId)
);
}
async function waitForApprovalEvent(params: {
context: MatrixQaScenarioContext;
expectedApprovalId: string;
expectedKind: MatrixQaApprovalKind;
roomId: string;
since?: string;
threadRootEventId?: string;
}) {
const observedMatch = params.context.observedEvents.find((event) =>
isExpectedApprovalEvent(event, params),
);
if (observedMatch) {
assertApprovalMetadata({
event: observedMatch,
expectedKind: params.expectedKind,
});
return {
event: observedMatch,
since: params.since,
};
}
const client = createMatrixQaScenarioClient({
accessToken: params.context.driverAccessToken,
baseUrl: params.context.baseUrl,
});
const matched = await client.waitForRoomEvent({
observedEvents: params.context.observedEvents,
predicate: (event) => isExpectedApprovalEvent(event, params),
roomId: params.roomId,
since: params.since,
timeoutMs: params.context.timeoutMs,
});
assertApprovalMetadata({
event: matched.event,
expectedKind: params.expectedKind,
});
return matched;
}
async function waitForObservedApprovalEvent(params: {
context: MatrixQaScenarioContext;
expectedApprovalId: string;
expectedKind: MatrixQaApprovalKind;
excludedRoomIds?: string[];
roomIds: string[];
timeoutMs: number;
}) {
const client = createMatrixQaDriverScenarioClient(params.context);
const roomIds = normalizeUniqueStringEntries(params.roomIds);
const primaryRoomId = roomIds[0];
if (!primaryRoomId) {
throw new Error("Matrix approval wait requires at least one candidate room");
}
const excludedRoomIds = new Set(params.excludedRoomIds ?? []);
const isExpectedObservedApproval = (event: MatrixQaObservedEvent) => {
if (excludedRoomIds.has(event.roomId)) {
return false;
}
if (
roomIds.some((roomId) =>
isExpectedApprovalEvent(event, {
...params,
roomId,
}),
)
) {
return true;
}
return (
event.sender === params.context.sutUserId &&
event.type === "m.room.message" &&
event.approval?.kind === params.expectedKind &&
event.approval.id === params.expectedApprovalId
);
};
const startedAt = Date.now();
while (Date.now() - startedAt < params.timeoutMs) {
const observedMatch = params.context.observedEvents.find(isExpectedObservedApproval);
if (observedMatch) {
assertApprovalMetadata({
event: observedMatch,
expectedKind: params.expectedKind,
});
return {
event: observedMatch,
since: undefined,
};
}
const remainingMs = params.timeoutMs - (Date.now() - startedAt);
if (remainingMs <= 0) {
break;
}
await client.waitForOptionalRoomEvent({
observedEvents: params.context.observedEvents,
predicate: isExpectedObservedApproval,
roomId: primaryRoomId,
timeoutMs: Math.min(1_000, remainingMs),
});
await sleep(Math.min(100, Math.max(25, params.timeoutMs - (Date.now() - startedAt))));
}
throw new Error(
`timed out waiting for observed Matrix approval ${params.expectedApprovalId} in ${roomIds.join(", ")}`,
);
}
function listDriverDmApprovalCandidateRoomIds(context: MatrixQaScenarioContext) {
const preferredRoomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_DRIVER_DM_ROOM_KEY);
return [
preferredRoomId,
...context.topology.rooms
.filter(
(room) =>
room.kind === "dm" &&
room.memberRoles.includes("driver") &&
room.memberRoles.includes("sut"),
)
.map((room) => room.roomId),
];
}
async function reactToApproval(params: {
context: MatrixQaScenarioContext;
decision: MatrixQaApprovalDecision;
roomId: string;
targetEventId: string;
}) {
const client = createMatrixQaDriverScenarioClient(params.context);
const emoji =
params.decision === "allow-once"
? MATRIX_QA_APPROVAL_ALLOW_ONCE_REACTION
: MATRIX_QA_APPROVAL_DENY_REACTION;
if (
!hasObservedApprovalOptionReaction({
context: params.context,
emoji,
roomId: params.roomId,
targetEventId: params.targetEventId,
})
) {
await client.waitForRoomEvent({
observedEvents: params.context.observedEvents,
predicate: (event) =>
isApprovalOptionReaction(event, {
context: params.context,
emoji,
roomId: params.roomId,
targetEventId: params.targetEventId,
}),
roomId: params.roomId,
timeoutMs: params.context.timeoutMs,
});
}
const eventId = await client.sendReaction({
emoji,
messageId: params.targetEventId,
roomId: params.roomId,
});
await client
.waitForRoomEvent({
observedEvents: params.context.observedEvents,
predicate: (event) =>
event.roomId === params.roomId &&
event.sender === params.context.driverUserId &&
event.type === "m.reaction" &&
event.reaction?.eventId === params.targetEventId &&
event.reaction.key === emoji,
roomId: params.roomId,
timeoutMs: params.context.timeoutMs,
})
.catch((err: unknown) => {
throw new Error(
`Matrix approval reaction ${eventId} was not observed before waiting for the gateway decision: ${String(err)}`,
);
});
return {
eventId,
reaction: {
eventId: params.targetEventId,
key: emoji,
},
};
}
function assertApprovalDecisionResult(params: {
approvalId: string;
decision: MatrixQaApprovalDecision;
result: unknown;
}) {
const result =
typeof params.result === "object" && params.result !== null
? (params.result as { decision?: unknown; id?: unknown })
: null;
if (result?.id !== params.approvalId) {
throw new Error(
`approval decision result id was ${formatApprovalResultValue(result?.id)} instead of ${params.approvalId}`,
);
}
if (result?.decision !== params.decision) {
throw new Error(
`approval decision was ${formatApprovalResultValue(result?.decision)} instead of ${params.decision}`,
);
}
}
function formatApprovalResultValue(value: unknown) {
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (value == null) {
return "<missing>";
}
return JSON.stringify(value) ?? "<unserializable>";
}
async function requestExecApproval(params: {
context: MatrixQaScenarioContext;
command: string;
id?: string;
threadRootEventId?: string;
}) {
const gatewayCall = requireMatrixQaGatewayCall(params.context);
return await gatewayCall(
"exec.approval.request",
{
...(params.id ? { id: params.id } : {}),
ask: "always",
command: params.command,
host: "gateway",
security: "full",
timeoutMs: MATRIX_QA_APPROVAL_DECISION_TIMEOUT_MS,
twoPhase: true,
turnSourceAccountId: params.context.sutAccountId,
turnSourceChannel: "matrix",
turnSourceTo: `room:${params.context.roomId}`,
...(params.threadRootEventId ? { turnSourceThreadId: params.threadRootEventId } : {}),
},
{
expectFinal: false,
timeoutMs: MATRIX_QA_APPROVAL_DECISION_TIMEOUT_MS + 5_000,
},
);
}
async function requestPluginApproval(params: { context: MatrixQaScenarioContext; token: string }) {
const gatewayCall = requireMatrixQaGatewayCall(params.context);
return await gatewayCall(
"plugin.approval.request",
{
agentId: "qa",
description: `Matrix plugin approval QA request ${params.token}`,
pluginId: "qa-matrix-plugin",
severity: "warning",
timeoutMs: MATRIX_QA_APPROVAL_DECISION_TIMEOUT_MS,
title: "Matrix plugin approval QA",
toolName: "matrix_qa_tool",
twoPhase: true,
turnSourceAccountId: params.context.sutAccountId,
turnSourceChannel: "matrix",
turnSourceTo: `room:${params.context.roomId}`,
},
{
expectFinal: false,
timeoutMs: MATRIX_QA_APPROVAL_DECISION_TIMEOUT_MS + 5_000,
},
);
}
async function waitForApprovalDecision(params: {
approvalId: string;
context: MatrixQaScenarioContext;
kind: MatrixQaApprovalKind;
}) {
const gatewayCall = requireMatrixQaGatewayCall(params.context);
const method =
params.kind === "exec" ? "exec.approval.waitDecision" : "plugin.approval.waitDecision";
return await gatewayCall(
method,
{ id: params.approvalId },
{
expectFinal: true,
timeoutMs: MATRIX_QA_APPROVAL_DECISION_TIMEOUT_MS + 5_000,
},
);
}
async function resolveApprovalDecision(params: {
approvalId: string;
context: MatrixQaScenarioContext;
decision: MatrixQaApprovalDecision;
kind: MatrixQaApprovalKind;
}) {
const gatewayCall = requireMatrixQaGatewayCall(params.context);
const method = params.kind === "exec" ? "exec.approval.resolve" : "plugin.approval.resolve";
return await gatewayCall(
method,
{ decision: params.decision, id: params.approvalId },
{
expectFinal: false,
timeoutMs: 5_000,
},
);
}
function readAcceptedApprovalRequest(result: unknown) {
const accepted =
typeof result === "object" && result !== null
? (result as { id?: unknown; status?: unknown })
: null;
if (accepted?.status !== "accepted") {
throw new Error(
`approval request status was ${formatApprovalResultValue(accepted?.status)} instead of accepted`,
);
}
return accepted;
}
function assertAcceptedApprovalRequest(params: { approvalId: string; result: unknown }) {
const id = readAcceptedApprovalRequest(params.result).id;
if (id !== params.approvalId) {
throw new Error(
`accepted approval id was ${formatApprovalResultValue(id)} instead of ${params.approvalId}`,
);
}
}
function readAcceptedApprovalRequestId(result: unknown) {
const id = readAcceptedApprovalRequest(result).id;
if (typeof id !== "string" || !id.trim()) {
throw new Error("approval request did not return an accepted approval id");
}
return id;
}
function buildExecApprovalCommand(params: { expectChunk?: boolean; token: string }) {
if (!params.expectChunk) {
return `printf ${params.token}`;
}
return `printf '${params.token} ${MATRIX_QA_APPROVAL_LONG_COMMAND_TEXT}'`;
}
async function runExecApprovalScenario(params: {
context: MatrixQaScenarioContext;
decision: MatrixQaApprovalDecision;
expectChunk?: boolean;
tokenPrefix: string;
threadRootEventId?: string;
}) {
const { client, startSince } = await primeMatrixQaDriverScenarioClient(params.context);
const token = buildMatrixQaToken(params.tokenPrefix);
const command = buildExecApprovalCommand({ expectChunk: params.expectChunk, token });
const approvalId = `qa-${token.toLowerCase()}-${randomUUID().slice(0, 8)}`;
const accepted = await requestExecApproval({
context: params.context,
command,
id: approvalId,
threadRootEventId: params.threadRootEventId,
});
assertAcceptedApprovalRequest({ approvalId, result: accepted });
const approval = await waitForApprovalEvent({
context: params.context,
expectedApprovalId: approvalId,
expectedKind: "exec",
roomId: params.context.roomId,
since: startSince,
threadRootEventId: params.threadRootEventId,
});
if (params.expectChunk) {
const chunk = await client.waitForRoomEvent({
observedEvents: params.context.observedEvents,
predicate: (event) =>
event.roomId === params.context.roomId &&
event.sender === params.context.sutUserId &&
event.type === "m.room.message" &&
event.body?.includes(token) === true &&
event.eventId !== approval.event.eventId &&
event.approval === undefined,
roomId: params.context.roomId,
timeoutMs: params.context.timeoutMs,
});
if (chunk.event.approval) {
throw new Error(`chunk event ${chunk.event.eventId} unexpectedly duplicated metadata`);
}
}
const reaction = await reactToApproval({
context: params.context,
decision: params.decision,
roomId: params.context.roomId,
targetEventId: approval.event.eventId,
});
const result = await waitForApprovalDecision({
approvalId,
context: params.context,
kind: "exec",
});
assertApprovalDecisionResult({
approvalId,
decision: params.decision,
result,
});
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: params.context.syncState,
nextSince: approval.since,
startSince,
});
return {
artifacts: {
approval: buildMatrixApprovalArtifact(approval.event),
reactionEmoji: reaction.reaction?.key,
reactionEventId: reaction.eventId,
reactionTargetEventId: reaction.reaction?.eventId,
token,
},
details: [
`approval event: ${approval.event.eventId}`,
`approval id: ${approvalId}`,
`approval kind: ${approval.event.approval?.kind ?? "<missing>"}`,
`decision: ${params.decision}`,
`reaction event: ${reaction.eventId}`,
`reaction target: ${reaction.reaction?.eventId ?? "<missing>"}`,
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runApprovalExecMetadataSingleEventScenario(context: MatrixQaScenarioContext) {
return await runExecApprovalScenario({
context,
decision: "allow-once",
tokenPrefix: "MATRIX_QA_APPROVAL_EXEC",
});
}
export async function runApprovalExecMetadataChunkedScenario(context: MatrixQaScenarioContext) {
return await runExecApprovalScenario({
context,
decision: "allow-once",
expectChunk: true,
tokenPrefix: "MATRIX_QA_APPROVAL_CHUNKED",
});
}
export async function runApprovalDenyReactionScenario(context: MatrixQaScenarioContext) {
return await runExecApprovalScenario({
context,
decision: "deny",
tokenPrefix: "MATRIX_QA_APPROVAL_DENY",
});
}
export async function runApprovalThreadTargetScenario(context: MatrixQaScenarioContext) {
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
const token = buildMatrixQaToken("MATRIX_QA_APPROVAL_THREAD_ROOT");
const rootEventId = await client.sendTextMessage({
body: `Matrix approval thread root ${token}`,
roomId: context.roomId,
});
const result = await runExecApprovalScenario({
context,
decision: "allow-once",
threadRootEventId: rootEventId,
tokenPrefix: "MATRIX_QA_APPROVAL_THREAD",
});
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
startSince,
});
return {
artifacts: {
...result.artifacts,
rootEventId,
},
details: [result.details, `thread root event: ${rootEventId}`].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runApprovalPluginMetadataSingleEventScenario(
context: MatrixQaScenarioContext,
) {
const { startSince } = await primeMatrixQaDriverScenarioClient(context);
const token = buildMatrixQaToken("MATRIX_QA_PLUGIN_APPROVAL");
const accepted = await requestPluginApproval({ context, token });
const approvalId = readAcceptedApprovalRequestId(accepted);
const approval = await waitForApprovalEvent({
context,
expectedApprovalId: approvalId,
expectedKind: "plugin",
roomId: context.roomId,
since: startSince,
});
const approvalMetadata = approval.event.approval;
if (
approvalMetadata?.pluginId !== "qa-matrix-plugin" ||
approvalMetadata.toolName !== "matrix_qa_tool" ||
approvalMetadata.severity !== "warning" ||
approvalMetadata.agentId !== "qa"
) {
throw new Error(`plugin approval event ${approval.event.eventId} did not expose plugin fields`);
}
const reaction = await reactToApproval({
context,
decision: "allow-once",
roomId: context.roomId,
targetEventId: approval.event.eventId,
});
const result = await waitForApprovalDecision({
approvalId,
context,
kind: "plugin",
});
assertApprovalDecisionResult({
approvalId,
decision: "allow-once",
result,
});
return {
artifacts: {
approval: buildMatrixApprovalArtifact(approval.event),
reactionEmoji: reaction.reaction?.key,
reactionEventId: reaction.eventId,
reactionTargetEventId: reaction.reaction?.eventId,
token,
},
details: [
`approval event: ${approval.event.eventId}`,
`approval id: ${approvalMetadata.id}`,
`plugin id: ${approvalMetadata.pluginId ?? "<missing>"}`,
`tool name: ${approvalMetadata.toolName ?? "<missing>"}`,
`decision: allow-once`,
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runApprovalChannelTargetBothScenario(context: MatrixQaScenarioContext) {
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
const dmRoomIds = listDriverDmApprovalCandidateRoomIds(context);
const token = buildMatrixQaToken("MATRIX_QA_APPROVAL_BOTH");
const approvalId = `qa-${token.toLowerCase()}-${randomUUID().slice(0, 8)}`;
const accepted = await requestExecApproval({
context,
command: `printf ${token}`,
id: approvalId,
});
assertAcceptedApprovalRequest({ approvalId, result: accepted });
const channelApproval = await waitForApprovalEvent({
context,
expectedApprovalId: approvalId,
expectedKind: "exec",
roomId: context.roomId,
since: startSince,
});
const dmApproval = await waitForObservedApprovalEvent({
context,
excludedRoomIds: [context.roomId],
expectedApprovalId: approvalId,
expectedKind: "exec",
roomIds: dmRoomIds,
timeoutMs: context.timeoutMs,
});
if (channelApproval.event.approval?.id !== dmApproval.event.approval?.id) {
throw new Error("target=both delivered different approval ids to channel and DM");
}
await resolveApprovalDecision({
approvalId,
context,
decision: "allow-once",
kind: "exec",
});
const lateDuplicate = await client.waitForOptionalRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.sender === context.sutUserId &&
event.type === "m.room.message" &&
event.approval?.id === approvalId &&
event.eventId !== channelApproval.event.eventId &&
event.eventId !== dmApproval.event.eventId,
roomId: context.roomId,
timeoutMs: MATRIX_QA_APPROVAL_SHORT_WINDOW_MS,
});
if (lateDuplicate.matched) {
throw new Error(`approval ${approvalId} was re-delivered after resolution`);
}
return {
artifacts: {
approvals: [
buildMatrixApprovalArtifact(channelApproval.event),
buildMatrixApprovalArtifact(dmApproval.event),
],
token,
},
details: [
`channel approval event: ${channelApproval.event.eventId}`,
`dm approval event: ${dmApproval.event.eventId}`,
`approval id: ${approvalId}`,
`cleanup decision: allow-once`,
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}

View File

@@ -0,0 +1,482 @@
// Qa Matrix tests cover scenario runtime cli plugin behavior.
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { describe, expect, it, vi } from "vitest";
import {
formatMatrixQaCliCommand,
redactMatrixQaCliOutput,
resolveMatrixQaOpenClawCliEntryPath,
runMatrixQaOpenClawCli,
startMatrixQaOpenClawCli,
testing,
} from "./scenario-runtime-cli.js";
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitForFile(pathToCheck: string, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await readFile(pathToCheck, "utf8");
return;
} catch {
await sleep(25);
}
}
throw new Error(`Timed out waiting for ${pathToCheck}`);
}
describe("Matrix QA CLI runtime", () => {
it("redacts secret CLI arguments in diagnostic command text", () => {
expect(
formatMatrixQaCliCommand([
"matrix",
"verify",
"backup",
"restore",
"--recovery-key",
"abcdef1234567890ghij",
]),
).toBe("openclaw matrix verify backup restore --recovery-key [REDACTED]");
expect(formatMatrixQaCliCommand(["matrix", "account", "add", "--access-token=token-123"])).toBe(
"openclaw matrix account add --access-token=[REDACTED]",
);
expect(
formatMatrixQaCliCommand(["matrix", "verify", "device", "abcdef1234567890ghij", "--json"]),
).toBe("openclaw matrix verify device [REDACTED] --json");
});
it("redacts Matrix token output before diagnostics and artifacts", () => {
expect(
redactMatrixQaCliOutput("GET /_matrix/client/v3/sync?access_token=abcdef1234567890ghij"),
).toBe("GET /_matrix/client/v3/sync?access_token=abcdef…ghij");
});
it("force-kills Windows CLI process trees when graceful taskkill fails", () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const originalSystemRoot = process.env.SystemRoot;
const originalWindir = process.env.WINDIR;
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
process.env.SystemRoot = "C:\\Windows";
delete process.env.WINDIR;
try {
const killMock = vi.fn();
const child = {
pid: 12345,
kill: killMock,
} as unknown as Parameters<typeof testing.killMatrixQaCliChild>[0];
const runTaskkill = vi
.fn()
.mockReturnValueOnce({ status: 1 })
.mockReturnValueOnce({ status: 0 });
testing.killMatrixQaCliChild(child, "SIGTERM", runTaskkill);
const taskkillPath = path.win32.join("C:\\Windows", "System32", "taskkill.exe");
expect(runTaskkill).toHaveBeenNthCalledWith(1, taskkillPath, ["/PID", "12345", "/T"], {
stdio: "ignore",
windowsHide: true,
});
expect(runTaskkill).toHaveBeenNthCalledWith(2, taskkillPath, ["/PID", "12345", "/T", "/F"], {
stdio: "ignore",
windowsHide: true,
});
expect(killMock).not.toHaveBeenCalled();
} finally {
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
}
if (originalSystemRoot === undefined) {
delete process.env.SystemRoot;
} else {
process.env.SystemRoot = originalSystemRoot;
}
if (originalWindir === undefined) {
delete process.env.WINDIR;
} else {
process.env.WINDIR = originalWindir;
}
}
});
it("prefers the ESM OpenClaw CLI entrypoint when present", async () => {
const root = await mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-entry-"));
try {
await mkdir(path.join(root, "dist"));
await writeFile(path.join(root, "dist", "index.mjs"), "");
expect(resolveMatrixQaOpenClawCliEntryPath(root)).toBe(path.join(root, "dist", "index.mjs"));
} finally {
await rm(root, { force: true, recursive: true });
}
});
it("can preserve expected non-zero CLI output for negative scenarios", async () => {
const root = await mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-nonzero-"),
);
try {
await mkdir(path.join(root, "dist"));
await writeFile(
path.join(root, "dist", "index.mjs"),
[
"process.stdout.write(JSON.stringify({ success: false, error: 'expected failure' }));",
"process.exit(7);",
].join("\n"),
);
const result = await runMatrixQaOpenClawCli({
allowNonZero: true,
args: ["matrix", "verify", "backup", "restore", "--json"],
cwd: root,
env: process.env,
timeoutMs: 5_000,
});
expect(result.exitCode).toBe(7);
expect(result.stdout).toContain('"success":false');
} finally {
await rm(root, { force: true, recursive: true });
}
});
it("can pass stdin to CLI commands", async () => {
const root = await mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-stdin-"));
try {
await mkdir(path.join(root, "dist"));
await writeFile(
path.join(root, "dist", "index.mjs"),
[
"let input = '';",
"process.stdin.setEncoding('utf8');",
"process.stdin.on('data', (chunk) => { input += chunk; });",
"process.stdin.on('end', () => {",
" process.stdout.write(JSON.stringify({ input: input.trim() }));",
"});",
].join("\n"),
);
const result = await runMatrixQaOpenClawCli({
args: ["matrix", "verify", "backup", "restore", "--recovery-key-stdin", "--json"],
cwd: root,
env: process.env,
stdin: "stdin-recovery-key\n",
timeoutMs: 5_000,
});
expect(result.stdout).toContain('"input":"stdin-recovery-key"');
} finally {
await rm(root, { force: true, recursive: true });
}
});
it("can close stdin after interactive CLI prompts", async () => {
const root = await mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-interactive-"),
);
try {
await mkdir(path.join(root, "dist"));
await writeFile(
path.join(root, "dist", "index.mjs"),
[
"let input = '';",
"process.stdin.setEncoding('utf8');",
"process.stdin.on('data', (chunk) => { input += chunk; process.stdout.write('prompt answered\\n'); });",
"process.stdin.on('end', () => {",
" process.stdout.write(JSON.stringify({ input: input.trim(), ended: true }));",
"});",
].join("\n"),
);
const session = startMatrixQaOpenClawCli({
args: ["matrix", "verify", "self"],
cwd: root,
env: process.env,
timeoutMs: 5_000,
});
await session.writeStdin("yes\n");
await session.waitForOutput(
(output) => output.text.includes("prompt answered"),
"interactive prompt acknowledgement",
5_000,
);
session.endStdin();
const result = await session.wait();
expect(result.stdout).toContain('"input":"yes"');
expect(result.stdout).toContain('"ended":true');
} finally {
await rm(root, { force: true, recursive: true });
}
});
it("includes timed-out CLI output in diagnostics", async () => {
const root = await mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-timeout-"),
);
try {
await mkdir(path.join(root, "dist"));
await writeFile(
path.join(root, "dist", "index.mjs"),
[
"process.stdout.write('waiting for verification\\n');",
"process.stderr.write('matrix sdk still syncing\\n');",
"setInterval(() => {}, 1000);",
].join("\n"),
);
await expect(
runMatrixQaOpenClawCli({
args: ["matrix", "verify", "self"],
cwd: root,
env: process.env,
timeoutMs: 250,
}),
).rejects.toThrow(/stdout:\nwaiting for verification/);
await expect(
runMatrixQaOpenClawCli({
args: ["matrix", "verify", "self"],
cwd: root,
env: process.env,
timeoutMs: 250,
}),
).rejects.toThrow(/stderr:\nmatrix sdk still syncing/);
} finally {
await rm(root, { force: true, recursive: true });
}
});
it("kills CLI commands that ignore graceful timeout termination", async () => {
const root = await mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-timeout-kill-"),
);
const pidPath = path.join(root, "cli.pid");
let childPid: number | undefined;
try {
await mkdir(path.join(root, "dist"));
await writeFile(
path.join(root, "dist", "index.mjs"),
[
"import { writeFileSync } from 'node:fs';",
`writeFileSync(${JSON.stringify(pidPath)}, String(process.pid));`,
"process.stdout.write('waiting despite graceful shutdown\\n');",
"process.on('SIGTERM', () => { process.stdout.write('ignored sigterm\\n'); });",
"setInterval(() => {}, 1000);",
].join("\n"),
);
await expect(
runMatrixQaOpenClawCli({
args: ["matrix", "verify", "self"],
cwd: root,
env: process.env,
timeoutMs: 500,
}),
).rejects.toThrow(/timed out after 500ms/u);
childPid = Number(await readFile(pidPath, "utf8"));
expect(isProcessRunning(childPid)).toBe(false);
} finally {
if (childPid && isProcessRunning(childPid)) {
process.kill(childPid, "SIGKILL");
}
await rm(root, { force: true, recursive: true });
}
});
it("preserves timeout diagnostics when wait attaches after timeout", async () => {
const root = await mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-late-wait-timeout-"),
);
const pidPath = path.join(root, "cli.pid");
let childPid: number | undefined;
try {
await mkdir(path.join(root, "dist"));
await writeFile(
path.join(root, "dist", "index.mjs"),
[
"import { writeFileSync } from 'node:fs';",
`writeFileSync(${JSON.stringify(pidPath)}, String(process.pid));`,
"process.stdout.write('late wait timeout marker\\n');",
"process.on('SIGTERM', () => {});",
"setInterval(() => {}, 1000);",
].join("\n"),
);
const session = startMatrixQaOpenClawCli({
args: ["matrix", "verify", "self"],
cwd: root,
env: process.env,
timeoutMs: 500,
});
await sleep(850);
await expect(session.wait()).rejects.toThrow(/timed out after 500ms/u);
await expect(session.wait()).rejects.toThrow(/late wait timeout marker/u);
childPid = Number(await readFile(pidPath, "utf8"));
expect(isProcessRunning(childPid)).toBe(false);
} finally {
if (childPid && isProcessRunning(childPid)) {
process.kill(childPid, "SIGKILL");
}
await rm(root, { force: true, recursive: true });
}
});
it("settles and kills descendants that keep timed-out CLI stdio open", async () => {
const root = await mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-timeout-tree-"),
);
const childPidPath = path.join(root, "child.pid");
const grandchildPidPath = path.join(root, "grandchild.pid");
let childPid: number | undefined;
let grandchildPid: number | undefined;
try {
await mkdir(path.join(root, "dist"));
await writeFile(
path.join(root, "dist", "index.mjs"),
[
"import { spawn } from 'node:child_process';",
"import { writeFileSync } from 'node:fs';",
`writeFileSync(${JSON.stringify(childPidPath)}, String(process.pid));`,
"const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000);'], { stdio: ['ignore', 'inherit', 'inherit'] });",
`writeFileSync(${JSON.stringify(grandchildPidPath)}, String(grandchild.pid));`,
"process.stdout.write('spawned persistent descendant\\n');",
"process.on('SIGTERM', () => {});",
"setInterval(() => {}, 1000);",
].join("\n"),
);
await expect(
runMatrixQaOpenClawCli({
args: ["matrix", "verify", "self"],
cwd: root,
env: process.env,
timeoutMs: 500,
}),
).rejects.toThrow(/timed out after 500ms/u);
childPid = Number(await readFile(childPidPath, "utf8"));
grandchildPid = Number(await readFile(grandchildPidPath, "utf8"));
expect(isProcessRunning(childPid)).toBe(false);
if (process.platform !== "win32") {
expect(isProcessRunning(grandchildPid)).toBe(false);
}
} finally {
for (const pid of [grandchildPid, childPid]) {
if (pid && isProcessRunning(pid)) {
process.kill(pid, "SIGKILL");
}
}
await rm(root, { force: true, recursive: true });
}
});
it("kills ignored-stdio descendants after a timed-out CLI exits gracefully", async () => {
if (process.platform === "win32") {
return;
}
const root = await mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-timeout-ignored-stdio-"),
);
const childPidPath = path.join(root, "child.pid");
const grandchildPidPath = path.join(root, "grandchild.pid");
let childPid: number | undefined;
let grandchildPid: number | undefined;
try {
await mkdir(path.join(root, "dist"));
await writeFile(
path.join(root, "dist", "index.mjs"),
[
"import { spawn } from 'node:child_process';",
"import { writeFileSync } from 'node:fs';",
`writeFileSync(${JSON.stringify(childPidPath)}, String(process.pid));`,
"const grandchild = spawn(process.execPath, ['-e', 'process.on(\\'SIGTERM\\', () => {}); setInterval(() => {}, 1000);'], { stdio: 'ignore' });",
"grandchild.unref();",
`writeFileSync(${JSON.stringify(grandchildPidPath)}, String(grandchild.pid));`,
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
].join("\n"),
);
const run = runMatrixQaOpenClawCli({
args: ["matrix", "verify", "self"],
cwd: root,
env: process.env,
timeoutMs: 500,
});
await waitForFile(grandchildPidPath, 2_000);
await expect(run).rejects.toThrow(/timed out after 500ms/u);
childPid = Number(await readFile(childPidPath, "utf8"));
grandchildPid = Number(await readFile(grandchildPidPath, "utf8"));
expect(isProcessRunning(childPid)).toBe(false);
expect(isProcessRunning(grandchildPid)).toBe(false);
} finally {
for (const pid of [grandchildPid, childPid]) {
if (pid && isProcessRunning(pid)) {
process.kill(pid, "SIGKILL");
}
}
await rm(root, { force: true, recursive: true });
}
});
it("kills ignored-stdio descendants after manual CLI session kill", async () => {
if (process.platform === "win32") {
return;
}
const root = await mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-session-kill-ignored-stdio-"),
);
const childPidPath = path.join(root, "child.pid");
const grandchildPidPath = path.join(root, "grandchild.pid");
let childPid: number | undefined;
let grandchildPid: number | undefined;
try {
await mkdir(path.join(root, "dist"));
await writeFile(
path.join(root, "dist", "index.mjs"),
[
"import { spawn } from 'node:child_process';",
"import { writeFileSync } from 'node:fs';",
`writeFileSync(${JSON.stringify(childPidPath)}, String(process.pid));`,
"const grandchild = spawn(process.execPath, ['-e', 'process.on(\\'SIGTERM\\', () => {}); setInterval(() => {}, 1000);'], { stdio: 'ignore' });",
"grandchild.unref();",
`writeFileSync(${JSON.stringify(grandchildPidPath)}, String(grandchild.pid));`,
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
].join("\n"),
);
const session = startMatrixQaOpenClawCli({
args: ["matrix", "verify", "self"],
cwd: root,
env: process.env,
timeoutMs: 10_000,
});
await waitForFile(grandchildPidPath, 2_000);
await sleep(300);
session.kill();
await sleep(500);
childPid = Number(await readFile(childPidPath, "utf8"));
grandchildPid = Number(await readFile(grandchildPidPath, "utf8"));
expect(isProcessRunning(childPid)).toBe(false);
expect(isProcessRunning(grandchildPid)).toBe(false);
} finally {
for (const pid of [grandchildPid, childPid]) {
if (pid && isProcessRunning(pid)) {
process.kill(pid, "SIGKILL");
}
}
await rm(root, { force: true, recursive: true });
}
});
});

View File

@@ -0,0 +1,491 @@
// Qa Matrix plugin module implements scenario runtime cli behavior.
import { spawn as startOpenClawCliProcess, spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { resolveMatrixQaWindowsSystem32ExePath } from "../../windows-system-tools.js";
export type MatrixQaCliRunResult = {
args: string[];
exitCode: number;
stderr: string;
stdout: string;
};
export type MatrixQaCliSession = {
args: string[];
endStdin: () => void;
output: () => { stderr: string; stdout: string };
wait: () => Promise<MatrixQaCliRunResult>;
waitForOutput: (
predicate: (output: { stderr: string; stdout: string; text: string }) => boolean,
label: string,
timeoutMs: number,
) => Promise<{ stderr: string; stdout: string; text: string }>;
writeStdin: (text: string) => Promise<void>;
kill: () => void;
};
const MATRIX_QA_CLI_SECRET_ARG_FLAGS = new Set(["--access-token", "--password", "--recovery-key"]);
const MATRIX_QA_CLI_TIMEOUT_KILL_GRACE_MS = 250;
const MATRIX_QA_CLI_TIMEOUT_FORCE_SETTLE_MS = 100;
function isMatrixQaCliSecretPositionalArg(args: string[], index: number): boolean {
return args[0] === "matrix" && args[1] === "verify" && args[2] === "device" && index === 3;
}
function redactMatrixQaCliArgs(args: string[]): string[] {
return args.map((arg, index) => {
const [flag] = arg.split("=", 1);
if (MATRIX_QA_CLI_SECRET_ARG_FLAGS.has(flag) && arg.includes("=")) {
return `${flag}=[REDACTED]`;
}
const previous = args[index - 1];
if (previous && MATRIX_QA_CLI_SECRET_ARG_FLAGS.has(previous)) {
return "[REDACTED]";
}
if (isMatrixQaCliSecretPositionalArg(args, index)) {
return "[REDACTED]";
}
return arg;
});
}
export function redactMatrixQaCliOutput(text: string): string {
return redactSensitiveText(text);
}
export function formatMatrixQaCliCommand(args: string[]) {
return `openclaw ${redactMatrixQaCliArgs(args).join(" ")}`;
}
export function resolveMatrixQaOpenClawCliEntryPath(cwd: string): string {
const mjsEntryPath = path.join(cwd, "dist", "index.mjs");
if (existsSync(mjsEntryPath)) {
return mjsEntryPath;
}
return path.join(cwd, "dist", "index.js");
}
function buildMatrixQaCliResult(params: {
args: string[];
exitCode: number;
output: { stderr: string; stdout: string };
}): MatrixQaCliRunResult {
return {
args: params.args,
exitCode: params.exitCode,
stderr: params.output.stderr,
stdout: params.output.stdout,
};
}
function formatMatrixQaCliExitError(result: MatrixQaCliRunResult) {
return [
`${formatMatrixQaCliCommand(result.args)} exited ${result.exitCode}`,
result.stderr.trim() ? `stderr:\n${redactMatrixQaCliOutput(result.stderr.trim())}` : null,
result.stdout.trim() ? `stdout:\n${redactMatrixQaCliOutput(result.stdout.trim())}` : null,
]
.filter(Boolean)
.join("\n");
}
function formatMatrixQaCliTimeoutError(result: MatrixQaCliRunResult, timeoutMs: number) {
return [
`${formatMatrixQaCliCommand(result.args)} timed out after ${timeoutMs}ms`,
result.stderr.trim() ? `stderr:\n${redactMatrixQaCliOutput(result.stderr.trim())}` : null,
result.stdout.trim() ? `stdout:\n${redactMatrixQaCliOutput(result.stdout.trim())}` : null,
]
.filter(Boolean)
.join("\n");
}
function killMatrixQaCliChild(
child: ReturnType<typeof startOpenClawCliProcess>,
signal: NodeJS.Signals,
runTaskkill: typeof spawnSync = spawnSync,
): void {
if (process.platform === "win32") {
if (child.pid) {
const taskkillPath = resolveMatrixQaWindowsSystem32ExePath("taskkill.exe");
const args = ["/PID", String(child.pid), "/T"];
if (signal === "SIGKILL") {
args.push("/F");
}
const result = runTaskkill(taskkillPath, args, { stdio: "ignore", windowsHide: true });
if (!result.error && result.status === 0) {
return;
}
if (signal !== "SIGKILL") {
const forceResult = runTaskkill(taskkillPath, [...args, "/F"], {
stdio: "ignore",
windowsHide: true,
});
if (!forceResult.error && forceResult.status === 0) {
return;
}
}
}
child.kill(signal);
return;
}
if (child.pid) {
try {
process.kill(-child.pid, signal);
return;
} catch {
// Fall back to the direct child if process-group signaling is unavailable.
}
}
child.kill(signal);
}
function isMatrixQaCliChildProcessGroupRunning(
child: ReturnType<typeof startOpenClawCliProcess>,
): boolean {
if (process.platform === "win32" || !child.pid) {
return false;
}
try {
process.kill(-child.pid, 0);
return true;
} catch {
return false;
}
}
export function startMatrixQaOpenClawCli(params: {
allowNonZero?: boolean;
args: string[];
cwd?: string;
env: NodeJS.ProcessEnv;
stdin?: string;
timeoutMs: number;
}): MatrixQaCliSession {
const cwd = params.cwd ?? process.cwd();
const distEntryPath = resolveMatrixQaOpenClawCliEntryPath(cwd);
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let closed = false;
let closeError: Error | undefined;
let closeResult: MatrixQaCliRunResult | undefined;
let killRequested = false;
let timedOut = false;
let forceKillTimeout: NodeJS.Timeout | undefined;
let forceSettleTimeout: NodeJS.Timeout | undefined;
let settleWait:
| {
reject: (error: Error) => void;
resolve: (result: MatrixQaCliRunResult) => void;
}
| undefined;
const child = startOpenClawCliProcess(process.execPath, [distEntryPath, ...params.args], {
cwd,
detached: process.platform !== "win32",
env: params.env,
stdio: ["pipe", "pipe", "pipe"],
});
const readOutput = () => ({
stderr: Buffer.concat(stderr).toString("utf8"),
stdout: Buffer.concat(stdout).toString("utf8"),
});
const finish = (result: MatrixQaCliRunResult, error?: Error) => {
if (closed) {
return;
}
closed = true;
closeError = error;
closeResult = result;
if (!settleWait) {
return;
}
if (error) {
settleWait.reject(error);
} else {
settleWait.resolve(result);
}
};
const finishTimeout = (result: MatrixQaCliRunResult) => {
finish(result, new Error(formatMatrixQaCliTimeoutError(result, params.timeoutMs)));
};
const finishResult = (result: MatrixQaCliRunResult) => {
if (result.exitCode !== 0 && params.allowNonZero !== true) {
finish(result, new Error(formatMatrixQaCliExitError(result)));
return;
}
finish(result);
};
const clearForcedTimeouts = () => {
if (forceKillTimeout) {
clearTimeout(forceKillTimeout);
forceKillTimeout = undefined;
}
if (forceSettleTimeout) {
clearTimeout(forceSettleTimeout);
forceSettleTimeout = undefined;
}
};
const finishForcedCleanup = (result: MatrixQaCliRunResult) => {
if (timedOut) {
finishTimeout(result);
return;
}
finishResult(result);
};
const scheduleForcedCleanup = () => {
if (forceKillTimeout || forceSettleTimeout) {
return;
}
forceKillTimeout = setTimeout(() => {
forceKillTimeout = undefined;
killMatrixQaCliChild(child, "SIGKILL");
forceSettleTimeout = setTimeout(() => {
forceSettleTimeout = undefined;
finishForcedCleanup(
buildMatrixQaCliResult({
args: params.args,
exitCode: 1,
output: readOutput(),
}),
);
}, MATRIX_QA_CLI_TIMEOUT_FORCE_SETTLE_MS);
}, MATRIX_QA_CLI_TIMEOUT_KILL_GRACE_MS);
};
const timeout = setTimeout(() => {
timedOut = true;
killMatrixQaCliChild(child, "SIGTERM");
scheduleForcedCleanup();
}, params.timeoutMs);
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
if (params.stdin !== undefined) {
child.stdin.end(params.stdin);
}
child.on("error", (error) => {
clearTimeout(timeout);
clearForcedTimeouts();
finish(
buildMatrixQaCliResult({
args: params.args,
exitCode: 1,
output: readOutput(),
}),
error,
);
});
child.on("close", (exitCode) => {
clearTimeout(timeout);
const result = buildMatrixQaCliResult({
args: params.args,
exitCode: exitCode ?? 1,
output: readOutput(),
});
if (timedOut || killRequested) {
// A closed parent is not proof that detached, ignored-stdio descendants are gone.
if (isMatrixQaCliChildProcessGroupRunning(child)) {
return;
}
clearForcedTimeouts();
finishForcedCleanup(result);
return;
}
clearForcedTimeouts();
finishResult(result);
});
return {
args: params.args,
endStdin: () => {
if (!child.stdin.destroyed) {
child.stdin.end();
}
},
output: readOutput,
wait: async () =>
await new Promise<MatrixQaCliRunResult>((resolve, reject) => {
if (closed && closeResult) {
if (closeError) {
reject(closeError);
} else if (closeResult.exitCode === 0 || params.allowNonZero === true) {
resolve(closeResult);
} else {
reject(new Error(formatMatrixQaCliExitError(closeResult)));
}
return;
}
settleWait = { reject, resolve };
}).catch((error: unknown) => {
throw new Error(
`Matrix QA CLI command failed (${formatMatrixQaCliCommand(params.args)}): ${redactMatrixQaCliOutput(formatErrorMessage(error))}`,
);
}),
waitForOutput: async (predicate, label, timeoutMs) => {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const output = readOutput();
const text = `${output.stdout}\n${output.stderr}`;
if (predicate({ ...output, text })) {
return { ...output, text };
}
if (closed) {
break;
}
await sleep(Math.min(100, Math.max(25, timeoutMs - (Date.now() - startedAt))));
}
const output = readOutput();
throw new Error(
`${formatMatrixQaCliCommand(params.args)} did not print ${label} before timeout\nstdout:\n${redactMatrixQaCliOutput(output.stdout.trim())}\nstderr:\n${redactMatrixQaCliOutput(output.stderr.trim())}`,
);
},
writeStdin: async (text) => {
if (!child.stdin.write(text)) {
await new Promise<void>((resolve) => {
child.stdin.once("drain", resolve);
});
}
},
kill: () => {
if (!closed) {
clearTimeout(timeout);
killRequested = true;
killMatrixQaCliChild(child, "SIGTERM");
scheduleForcedCleanup();
}
},
};
}
export async function runMatrixQaOpenClawCli(params: {
allowNonZero?: boolean;
args: string[];
cwd?: string;
env: NodeJS.ProcessEnv;
stdin?: string;
timeoutMs: number;
}): Promise<MatrixQaCliRunResult> {
return await startMatrixQaOpenClawCli(params).wait();
}
async function assertMatrixQaPrivatePathMode(pathToCheck: string, label: string) {
if (process.platform === "win32") {
return;
}
const mode = (await stat(pathToCheck)).mode & 0o777;
if ((mode & 0o077) !== 0) {
throw new Error(`${label} permissions are too broad: ${mode.toString(8)}`);
}
}
export async function createMatrixQaOpenClawCliRuntime(params: {
accountId: string;
accessToken: string;
artifactLabel: string;
baseUrl: string;
deviceId: string;
displayName: string;
outputDir: string;
runtimeEnv: NodeJS.ProcessEnv;
userId: string;
}) {
const rootDir = await mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-matrix-cli-qa-"),
);
const artifactDir = path.join(
params.outputDir,
params.artifactLabel.replace(/[^A-Za-z0-9_-]/g, "-"),
randomUUID().replaceAll("-", "").slice(0, 12),
);
const stateDir = path.join(rootDir, "state");
const configPath = path.join(rootDir, "config.json");
await chmod(rootDir, 0o700).catch(() => undefined);
await assertMatrixQaPrivatePathMode(rootDir, "Matrix QA CLI temp directory");
await mkdir(artifactDir, { mode: 0o700, recursive: true });
await chmod(artifactDir, 0o700).catch(() => undefined);
await assertMatrixQaPrivatePathMode(artifactDir, "Matrix QA CLI artifact directory");
await mkdir(stateDir, { mode: 0o700, recursive: true });
await chmod(stateDir, 0o700).catch(() => undefined);
await assertMatrixQaPrivatePathMode(stateDir, "Matrix QA CLI state directory");
await writeFile(
configPath,
`${JSON.stringify(
{
plugins: {
allow: ["matrix"],
entries: {
matrix: { enabled: true },
},
},
channels: {
matrix: {
defaultAccount: params.accountId,
accounts: {
[params.accountId]: {
accessToken: params.accessToken,
deviceId: params.deviceId,
encryption: true,
homeserver: params.baseUrl,
initialSyncLimit: 0,
name: params.displayName,
network: {
dangerouslyAllowPrivateNetwork: true,
},
startupVerification: "off",
userId: params.userId,
},
},
},
},
},
null,
2,
)}\n`,
{ flag: "wx", mode: 0o600 },
);
await assertMatrixQaPrivatePathMode(configPath, "Matrix QA CLI config file");
const env = {
...params.runtimeEnv,
FORCE_COLOR: "0",
NO_COLOR: "1",
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_DISABLE_AUTO_UPDATE: "1",
OPENCLAW_STATE_DIR: stateDir,
};
return {
artifactDir,
configPath,
dispose: async () => {
await rm(rootDir, { force: true, recursive: true });
},
run: async (
args: string[],
opts: { allowNonZero?: boolean; stdin?: string; timeoutMs: number },
): Promise<MatrixQaCliRunResult> =>
await runMatrixQaOpenClawCli({
allowNonZero: opts.allowNonZero,
args,
env,
stdin: opts.stdin,
timeoutMs: opts.timeoutMs,
}),
start: (args: string[], opts: { allowNonZero?: boolean; timeoutMs: number }) =>
startMatrixQaOpenClawCli({
allowNonZero: opts.allowNonZero,
args,
env,
timeoutMs: opts.timeoutMs,
}),
stateDir,
};
}
export const testing = {
killMatrixQaCliChild,
};

View File

@@ -0,0 +1,89 @@
// Qa Matrix helper module supports scenario runtime config behavior.
import { readFile } from "node:fs/promises";
import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
import { isRecord as isMatrixQaPlainRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
export { isMatrixQaPlainRecord };
function requireMatrixQaGatewayConfigObject(config: unknown): Record<string, unknown> {
if (!isMatrixQaPlainRecord(config)) {
throw new Error("Matrix QA gateway config file must contain an object");
}
return config;
}
async function readMatrixQaGatewayConfigFile(configPath: string) {
return requireMatrixQaGatewayConfigObject(
JSON.parse(await readFile(configPath, "utf8")) as unknown,
);
}
async function writeMatrixQaGatewayConfigFile(configPath: string, config: unknown) {
await replaceFileAtomic({
filePath: configPath,
content: `${JSON.stringify(config, null, 2)}\n`,
mode: 0o600,
tempPrefix: ".matrix-qa-config",
});
}
export async function readMatrixQaGatewayMatrixAccount(params: {
accountId: string;
configPath: string;
}) {
const config = await readMatrixQaGatewayConfigFile(params.configPath);
const channels = isMatrixQaPlainRecord(config.channels) ? config.channels : {};
const matrix = isMatrixQaPlainRecord(channels.matrix) ? channels.matrix : {};
const accounts = isMatrixQaPlainRecord(matrix.accounts) ? matrix.accounts : {};
const account = accounts[params.accountId];
if (!isMatrixQaPlainRecord(account)) {
throw new Error(`Matrix QA gateway account "${params.accountId}" missing from config`);
}
return account;
}
export async function replaceMatrixQaGatewayMatrixAccount(params: {
accountConfig: Record<string, unknown>;
accountId: string;
configPath: string;
}) {
const config = await readMatrixQaGatewayConfigFile(params.configPath);
const channels = isMatrixQaPlainRecord(config.channels) ? config.channels : {};
const matrix = isMatrixQaPlainRecord(channels.matrix) ? channels.matrix : {};
channels.matrix = {
...matrix,
defaultAccount: params.accountId,
accounts: {
[params.accountId]: params.accountConfig,
},
};
config.channels = channels;
await writeMatrixQaGatewayConfigFile(params.configPath, config);
}
export async function patchMatrixQaGatewayMatrixAccount(params: {
accountId: string;
accountPatch: Record<string, unknown>;
configPath: string;
}) {
const config = await readMatrixQaGatewayConfigFile(params.configPath);
const channels = isMatrixQaPlainRecord(config.channels) ? config.channels : {};
const matrix = isMatrixQaPlainRecord(channels.matrix) ? channels.matrix : {};
const accounts = isMatrixQaPlainRecord(matrix.accounts) ? matrix.accounts : {};
const existing = accounts[params.accountId];
if (!isMatrixQaPlainRecord(existing)) {
throw new Error(`Matrix QA gateway account "${params.accountId}" missing from config`);
}
channels.matrix = {
...matrix,
defaultAccount: params.accountId,
accounts: {
[params.accountId]: {
...existing,
...params.accountPatch,
},
},
};
config.channels = channels;
await writeMatrixQaGatewayConfigFile(params.configPath, config);
}

View File

@@ -0,0 +1,226 @@
// Qa Matrix plugin module implements scenario runtime dm behavior.
import { randomUUID } from "node:crypto";
import {
MATRIX_QA_DRIVER_DM_ROOM_KEY,
MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
resolveMatrixQaScenarioRoomId,
} from "./scenario-catalog.js";
import {
assertThreadReplyArtifact,
assertTopLevelReplyArtifact,
buildExactMarkerPrompt,
buildMatrixNoticeArtifact,
buildMatrixReplyArtifact,
buildMatrixReplyDetails,
createMatrixQaScenarioClient,
advanceMatrixQaActorCursor,
resolveMatrixQaNoReplyWindowMs,
runConfigurableTopLevelScenario,
type MatrixQaScenarioContext,
} from "./scenario-runtime-shared.js";
import type { MatrixQaScenarioExecution } from "./scenario-types.js";
async function runDmSharedSessionFlow(params: {
context: MatrixQaScenarioContext;
expectNotice: boolean;
}) {
const firstRoomId = resolveMatrixQaScenarioRoomId(params.context, MATRIX_QA_DRIVER_DM_ROOM_KEY);
const secondRoomId = resolveMatrixQaScenarioRoomId(
params.context,
MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
);
const firstResult = await runConfigurableTopLevelScenario({
accessToken: params.context.driverAccessToken,
actorId: "driver",
baseUrl: params.context.baseUrl,
observedEvents: params.context.observedEvents,
roomId: firstRoomId,
syncState: params.context.syncState,
syncStreams: params.context.syncStreams,
sutUserId: params.context.sutUserId,
timeoutMs: params.context.timeoutMs,
tokenPrefix: "MATRIX_QA_DM_PRIMARY",
withMention: false,
});
assertTopLevelReplyArtifact("primary DM reply", firstResult.reply);
const replyClient = createMatrixQaScenarioClient({
accessToken: params.context.driverAccessToken,
actorId: "driver",
baseUrl: params.context.baseUrl,
observedEvents: params.context.observedEvents,
syncState: params.context.syncState,
syncStreams: params.context.syncStreams,
});
const noticeClient = createMatrixQaScenarioClient({
accessToken: params.context.driverAccessToken,
actorId: "driver",
baseUrl: params.context.baseUrl,
observedEvents: params.context.observedEvents,
syncState: params.context.syncState,
syncStreams: params.context.syncStreams,
});
const [replySince, noticeSince] = await Promise.all([
replyClient.primeRoom(),
noticeClient.primeRoom(),
]);
if (!replySince || !noticeSince) {
throw new Error("Matrix DM session scenario could not prime room cursors");
}
const secondToken = `MATRIX_QA_DM_SECONDARY_${randomUUID().slice(0, 8).toUpperCase()}`;
const secondBody = buildExactMarkerPrompt(secondToken);
const secondDriverEventId = await replyClient.sendTextMessage({
body: secondBody,
roomId: secondRoomId,
});
const [replyResult, noticeResult] = await Promise.all([
replyClient.waitForRoomEvent({
observedEvents: params.context.observedEvents,
predicate: (event) =>
event.roomId === secondRoomId &&
event.sender === params.context.sutUserId &&
event.type === "m.room.message" &&
event.kind === "message" &&
(event.body ?? "").includes(secondToken),
roomId: secondRoomId,
since: replySince,
timeoutMs: params.context.timeoutMs,
}),
noticeClient.waitForOptionalRoomEvent({
observedEvents: params.context.observedEvents,
predicate: (event) =>
event.roomId === secondRoomId &&
event.sender === params.context.sutUserId &&
event.kind === "notice" &&
typeof event.body === "string" &&
event.body.includes("channels.matrix.dm.sessionScope"),
roomId: secondRoomId,
since: noticeSince,
timeoutMs: resolveMatrixQaNoReplyWindowMs(params.context.timeoutMs),
}),
]);
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: params.context.syncState,
nextSince: replyResult.since,
startSince: replySince,
});
const secondReply = buildMatrixReplyArtifact(replyResult.event, secondToken);
assertTopLevelReplyArtifact("secondary DM reply", secondReply);
const noticeArtifact = noticeResult.matched
? buildMatrixNoticeArtifact(noticeResult.event)
: undefined;
if (params.expectNotice && !noticeArtifact) {
throw new Error(
"Matrix shared DM session scenario did not emit the expected cross-room notice",
);
}
if (!params.expectNotice && noticeArtifact) {
throw new Error(
"Matrix per-room DM session scenario unexpectedly emitted a shared-session notice",
);
}
return {
firstRoomId,
noticeArtifact,
secondBody,
secondDriverEventId,
secondReply,
secondRoomId,
secondToken,
};
}
export async function runDmThreadReplyOverrideScenario(context: MatrixQaScenarioContext) {
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_DRIVER_DM_ROOM_KEY);
const result = await runConfigurableTopLevelScenario({
accessToken: context.driverAccessToken,
actorId: "driver",
baseUrl: context.baseUrl,
observedEvents: context.observedEvents,
replyPredicate: (event, params) =>
event.relatesTo?.relType === "m.thread" && event.relatesTo?.eventId === params.driverEventId,
roomId,
syncState: context.syncState,
syncStreams: context.syncStreams,
sutUserId: context.sutUserId,
timeoutMs: context.timeoutMs,
tokenPrefix: "MATRIX_QA_DM_THREAD",
withMention: false,
});
assertThreadReplyArtifact(result.reply, {
expectedRootEventId: result.driverEventId,
label: "DM thread override reply",
});
return {
artifacts: {
driverEventId: result.driverEventId,
reply: result.reply,
roomKey: MATRIX_QA_DRIVER_DM_ROOM_KEY,
token: result.token,
triggerBody: result.body,
},
details: [
`room key: ${MATRIX_QA_DRIVER_DM_ROOM_KEY}`,
`room id: ${roomId}`,
`driver event: ${result.driverEventId}`,
...buildMatrixReplyDetails("reply", result.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runDmSharedSessionNoticeScenario(context: MatrixQaScenarioContext) {
const result = await runDmSharedSessionFlow({
context,
expectNotice: true,
});
return {
artifacts: {
driverEventId: result.secondDriverEventId,
noticeBodyPreview: result.noticeArtifact?.bodyPreview,
noticeEventId: result.noticeArtifact?.eventId,
reply: result.secondReply,
roomKey: MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
token: result.secondToken,
triggerBody: result.secondBody,
},
details: [
`primary room id: ${result.firstRoomId}`,
`secondary room id: ${result.secondRoomId}`,
`secondary driver event: ${result.secondDriverEventId}`,
`notice event: ${result.noticeArtifact?.eventId ?? "<none>"}`,
`notice preview: ${result.noticeArtifact?.bodyPreview ?? "<none>"}`,
...buildMatrixReplyDetails("secondary reply", result.secondReply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runDmPerRoomSessionOverrideScenario(context: MatrixQaScenarioContext) {
const result = await runDmSharedSessionFlow({
context,
expectNotice: false,
});
return {
artifacts: {
driverEventId: result.secondDriverEventId,
reply: result.secondReply,
roomKey: MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
token: result.secondToken,
triggerBody: result.secondBody,
},
details: [
`primary room id: ${result.firstRoomId}`,
`secondary room id: ${result.secondRoomId}`,
`secondary driver event: ${result.secondDriverEventId}`,
"shared-session notice: suppressed",
...buildMatrixReplyDetails("secondary reply", result.secondReply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}

View File

@@ -0,0 +1,61 @@
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
createPluginStateSyncKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { afterEach, describe, expect, it } from "vitest";
import { testing } from "./scenario-runtime-e2ee-destructive.js";
const storageMetadataRuntime = {
normalizeMatrixStorageMetadata(value: unknown) {
if (!value || typeof value !== "object") {
return null;
}
const metadata = value as { deviceId?: unknown; userId?: unknown };
return {
...(typeof metadata.deviceId === "string" ? { deviceId: metadata.deviceId } : {}),
...(typeof metadata.userId === "string" ? { userId: metadata.userId } : {}),
};
},
openMatrixStorageMetaStoreOptions(storageRootDir: string) {
return {
namespace: "storage-meta",
maxEntries: 10,
env: { ...process.env, OPENCLAW_STATE_DIR: storageRootDir },
};
},
};
describe("Matrix destructive E2EE storage discovery", () => {
const tempDirs: string[] = [];
afterEach(async () => {
resetPluginStateStoreForTests();
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })));
});
it("finds account metadata stored in account-local SQLite", async () => {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-storage-"));
tempDirs.push(stateDir);
const accountRoot = path.join(stateDir, "matrix", "accounts", "stored-key", "server", "token");
createPluginStateSyncKeyedStoreForTests(
"matrix",
storageMetadataRuntime.openMatrixStorageMetaStoreOptions(accountRoot),
).register("current", {
deviceId: "DEVICE",
userId: "@owner:matrix-qa.test",
});
resetPluginStateStoreForTests();
await expect(
testing.findMatrixQaCliAccountRoot({
deviceId: "DEVICE",
runtime: { stateDir },
storageMetadataRuntime,
userId: "@owner:matrix-qa.test",
}),
).resolves.toBe(accountRoot);
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,91 @@
// Qa Matrix plugin module implements scenario runtime edit behavior.
import { randomUUID } from "node:crypto";
import {
assertNoSutReplyWindow,
buildExactMarkerPrompt,
buildMatrixReplyDetails,
buildMentionPrompt,
primeMatrixQaDriverScenarioClient,
runAssertedDriverTopLevelScenario,
type MatrixQaScenarioContext,
} from "./scenario-runtime-shared.js";
import type { MatrixQaScenarioExecution } from "./scenario-types.js";
export async function runInboundEditIgnoredScenario(context: MatrixQaScenarioContext) {
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
const ignoredToken = `MATRIX_QA_EDIT_IGNORED_SOURCE_${randomUUID().slice(0, 8).toUpperCase()}`;
const editedToken = `MATRIX_QA_EDIT_IGNORED_${randomUUID().slice(0, 8).toUpperCase()}`;
const rootEventId = await client.sendTextMessage({
body: buildExactMarkerPrompt(ignoredToken),
roomId: context.roomId,
});
const editEventId = await client.sendReplacementMessage({
body: buildMentionPrompt(context.sutUserId, editedToken),
mentionUserIds: [context.sutUserId],
roomId: context.roomId,
targetEventId: rootEventId,
});
const { noReplyWindowMs } = await assertNoSutReplyWindow({
actorId: "driver",
client,
context,
roomId: context.roomId,
since: startSince,
startSince,
unexpectedMessage: "unexpected SUT reply after Matrix edit-to-mention event",
});
return {
artifacts: {
editEventId,
editedToken,
expectedNoReplyWindowMs: noReplyWindowMs,
rootEventId,
},
details: [
`root event: ${rootEventId}`,
`edit event: ${editEventId}`,
`waited ${noReplyWindowMs}ms with no SUT reply`,
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runInboundEditNoDuplicateTriggerScenario(context: MatrixQaScenarioContext) {
const first = await runAssertedDriverTopLevelScenario({
context,
label: "pre-edit reply",
tokenPrefix: "MATRIX_QA_EDIT_ORIGINAL",
});
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
const editedToken = `MATRIX_QA_EDIT_DUPLICATE_${randomUUID().slice(0, 8).toUpperCase()}`;
const editEventId = await client.sendReplacementMessage({
body: buildMentionPrompt(context.sutUserId, editedToken),
mentionUserIds: [context.sutUserId],
roomId: context.roomId,
targetEventId: first.driverEventId,
});
const { noReplyWindowMs } = await assertNoSutReplyWindow({
actorId: "driver",
client,
context,
roomId: context.roomId,
since: startSince,
startSince,
unexpectedMessage: "unexpected duplicate SUT reply after Matrix edit",
});
return {
artifacts: {
editEventId,
editedToken,
expectedNoReplyWindowMs: noReplyWindowMs,
originalDriverEventId: first.driverEventId,
originalReply: first.reply,
originalToken: first.token,
},
details: [
`original driver event: ${first.driverEventId}`,
...buildMatrixReplyDetails("original reply", first.reply),
`edit event: ${editEventId}`,
`waited ${noReplyWindowMs}ms with no duplicate SUT reply`,
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}

View File

@@ -0,0 +1,463 @@
// Qa Matrix plugin module implements scenario runtime media behavior.
import type { MatrixQaObservedEvent } from "../../substrate/events.js";
import { MATRIX_QA_MEDIA_ROOM_KEY, resolveMatrixQaScenarioRoomId } from "./scenario-catalog.js";
import {
buildMatrixQaImageGenerationPrompt,
buildMatrixQaImageUnderstandingPrompt,
createMatrixQaVoicePreflightWav,
createMatrixQaSplitColorImagePng,
hasMatrixQaExpectedColorReply,
MATRIX_QA_IMAGE_ATTACHMENT_FILENAME,
MATRIX_QA_MEDIA_TYPE_COVERAGE_CASES,
MATRIX_QA_VOICE_PREFLIGHT_FILENAME,
MATRIX_QA_VOICE_PREFLIGHT_REPLY_MARKER,
} from "./scenario-media-fixtures.js";
import {
advanceMatrixQaActorCursor,
assertNoSutReplyWindow,
buildMatrixQaToken,
buildMatrixReplyArtifact,
buildMatrixReplyDetails,
isMatrixQaExactMarkerReply,
isMatrixQaMessageLikeKind,
primeMatrixQaActorCursor,
type MatrixQaScenarioContext,
} from "./scenario-runtime-shared.js";
import type { MatrixQaScenarioExecution } from "./scenario-types.js";
function requireMatrixQaImageAttachment(event: MatrixQaObservedEvent, scenarioLabel: string) {
if (event.msgtype !== "m.image" || event.attachment?.kind !== "image") {
throw new Error(
`${scenarioLabel} expected an m.image attachment but saw ${event.msgtype ?? "<none>"}`,
);
}
return event.attachment;
}
function buildMatrixQaAttachmentDetailLines(params: {
attachmentEvent: MatrixQaObservedEvent;
label: string;
}) {
return [
`${params.label} event: ${params.attachmentEvent.eventId}`,
`${params.label} msgtype: ${params.attachmentEvent.msgtype ?? "<none>"}`,
`${params.label} attachment kind: ${params.attachmentEvent.attachment?.kind ?? "<none>"}`,
`${params.label} attachment filename: ${params.attachmentEvent.attachment?.filename ?? "<none>"}`,
`${params.label} body preview: ${params.attachmentEvent.body?.slice(0, 200) ?? "<none>"}`,
];
}
async function primeMatrixQaDriverMediaClient(context: MatrixQaScenarioContext) {
return await primeMatrixQaActorCursor({
accessToken: context.driverAccessToken,
actorId: "driver",
baseUrl: context.baseUrl,
observedEvents: context.observedEvents,
syncState: context.syncState,
syncStreams: context.syncStreams,
});
}
function buildMatrixQaMediaTypeCoveragePrompt(params: {
label: string;
sutUserId: string;
token: string;
}) {
return `${params.sutUserId} Matrix media type coverage (${params.label}): ignore the attachment content and reply with only this exact marker: ${params.token}`;
}
function normalizeMatrixQaVoiceReply(value: string | undefined) {
return (value ?? "")
.toUpperCase()
.replace(/[^A-Z0-9]+/g, " ")
.trim();
}
function hasMatrixQaVoicePreflightReply(body: string | undefined) {
return normalizeMatrixQaVoiceReply(body).includes(MATRIX_QA_VOICE_PREFLIGHT_REPLY_MARKER);
}
export async function runImageUnderstandingAttachmentScenario(context: MatrixQaScenarioContext) {
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_MEDIA_ROOM_KEY);
const { client, startSince } = await primeMatrixQaDriverMediaClient(context);
const triggerBody = buildMatrixQaImageUnderstandingPrompt(context.sutUserId);
const driverEventId = await client.sendMediaMessage({
body: triggerBody,
buffer: createMatrixQaSplitColorImagePng(),
contentType: "image/png",
fileName: MATRIX_QA_IMAGE_ATTACHMENT_FILENAME,
kind: "image",
mentionUserIds: [context.sutUserId],
roomId,
});
const attachmentEvent = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.roomId === roomId &&
event.eventId === driverEventId &&
event.sender === context.driverUserId &&
event.attachment?.kind === "image" &&
event.attachment.caption === triggerBody,
roomId,
since: startSince,
timeoutMs: context.timeoutMs,
});
const matched = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.roomId === roomId &&
event.sender === context.sutUserId &&
event.type === "m.room.message" &&
event.relatesTo === undefined &&
isMatrixQaMessageLikeKind(event.kind) &&
hasMatrixQaExpectedColorReply(event.body),
roomId,
since: attachmentEvent.since,
timeoutMs: context.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: matched.since,
startSince,
});
const reply = buildMatrixReplyArtifact(matched.event);
return {
artifacts: {
attachmentCaptionPreview: attachmentEvent.event.attachment?.caption?.slice(0, 200),
attachmentFilename: MATRIX_QA_IMAGE_ATTACHMENT_FILENAME,
driverEventId,
reply,
roomId,
triggerBody,
},
details: [
`room id: ${roomId}`,
`driver attachment event: ${driverEventId}`,
`sent attachment filename: ${MATRIX_QA_IMAGE_ATTACHMENT_FILENAME}`,
`sent attachment caption: ${attachmentEvent.event.attachment?.caption ?? "<none>"}`,
...buildMatrixReplyDetails("reply", reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runMediaTypeCoverageScenario(context: MatrixQaScenarioContext) {
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_MEDIA_ROOM_KEY);
const { client, startSince } = await primeMatrixQaDriverMediaClient(context);
const attachments: NonNullable<MatrixQaScenarioExecution["artifacts"]>["attachments"] = [];
const replies: NonNullable<MatrixQaScenarioExecution["artifacts"]>["replies"] = [];
const details = [`room id: ${roomId}`];
let since = startSince;
for (const mediaCase of MATRIX_QA_MEDIA_TYPE_COVERAGE_CASES) {
const token = buildMatrixQaToken(mediaCase.tokenPrefix);
const triggerBody = buildMatrixQaMediaTypeCoveragePrompt({
label: mediaCase.label,
sutUserId: context.sutUserId,
token,
});
const driverEventId = await client.sendMediaMessage({
body: triggerBody,
buffer: mediaCase.createBuffer(),
contentType: mediaCase.contentType,
fileName: mediaCase.fileName,
kind: mediaCase.kind,
mentionUserIds: [context.sutUserId],
roomId,
});
const attachmentEvent = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.roomId === roomId &&
event.eventId === driverEventId &&
event.sender === context.driverUserId &&
event.msgtype === mediaCase.expectedMsgtype &&
event.attachment?.kind === mediaCase.expectedAttachmentKind &&
event.attachment.filename === mediaCase.fileName &&
event.attachment.caption === triggerBody,
roomId,
since,
timeoutMs: context.timeoutMs,
});
const matched = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
isMatrixQaExactMarkerReply(event, {
roomId,
sutUserId: context.sutUserId,
token,
}) && event.relatesTo === undefined,
roomId,
since: attachmentEvent.since,
timeoutMs: context.timeoutMs,
});
since = matched.since ?? since;
const reply = buildMatrixReplyArtifact(matched.event, token);
attachments.push({
eventId: driverEventId,
filename: mediaCase.fileName,
kind: attachmentEvent.event.attachment?.kind,
label: mediaCase.label,
msgtype: attachmentEvent.event.msgtype,
});
replies.push({
eventId: reply.eventId,
label: mediaCase.label,
token,
tokenMatched: reply.tokenMatched,
});
details.push(
`${mediaCase.label} event: ${driverEventId}`,
`${mediaCase.label} msgtype: ${attachmentEvent.event.msgtype ?? "<none>"}`,
`${mediaCase.label} attachment kind: ${attachmentEvent.event.attachment?.kind ?? "<none>"}`,
`${mediaCase.label} attachment filename: ${attachmentEvent.event.attachment?.filename ?? "<none>"}`,
...buildMatrixReplyDetails(`${mediaCase.label} reply`, reply),
);
}
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: since,
startSince,
});
return {
artifacts: {
attachments,
replies,
roomId,
},
details: details.join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runVoicePreflightMentionScenario(context: MatrixQaScenarioContext) {
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_MEDIA_ROOM_KEY);
const { client, startSince } = await primeMatrixQaDriverMediaClient(context);
const driverEventId = await client.sendMediaMessage({
buffer: createMatrixQaVoicePreflightWav(),
contentType: "audio/wav",
fileName: MATRIX_QA_VOICE_PREFLIGHT_FILENAME,
kind: "audio",
roomId,
});
const attachmentEvent = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.roomId === roomId &&
event.eventId === driverEventId &&
event.sender === context.driverUserId &&
event.msgtype === "m.audio" &&
event.attachment?.kind === "audio" &&
event.attachment.filename === MATRIX_QA_VOICE_PREFLIGHT_FILENAME &&
event.attachment.caption === undefined,
roomId,
since: startSince,
timeoutMs: context.timeoutMs,
});
const matched = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.roomId === roomId &&
event.sender === context.sutUserId &&
event.type === "m.room.message" &&
event.relatesTo === undefined &&
isMatrixQaMessageLikeKind(event.kind) &&
hasMatrixQaVoicePreflightReply(event.body),
roomId,
since: attachmentEvent.since,
timeoutMs: context.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: matched.since,
startSince,
});
const reply = buildMatrixReplyArtifact(matched.event, MATRIX_QA_VOICE_PREFLIGHT_REPLY_MARKER);
return {
artifacts: {
attachmentFilename: MATRIX_QA_VOICE_PREFLIGHT_FILENAME,
driverEventId,
reply,
roomId,
expectedMarker: MATRIX_QA_VOICE_PREFLIGHT_REPLY_MARKER,
},
details: [
`room id: ${roomId}`,
`driver voice event: ${driverEventId}`,
`voice filename: ${MATRIX_QA_VOICE_PREFLIGHT_FILENAME}`,
...buildMatrixReplyDetails("reply", reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runAttachmentOnlyIgnoredScenario(context: MatrixQaScenarioContext) {
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_MEDIA_ROOM_KEY);
const { client, startSince } = await primeMatrixQaDriverMediaClient(context);
const driverEventId = await client.sendMediaMessage({
buffer: createMatrixQaSplitColorImagePng(),
contentType: "image/png",
fileName: MATRIX_QA_IMAGE_ATTACHMENT_FILENAME,
kind: "image",
roomId,
});
const attachmentEvent = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.roomId === roomId &&
event.eventId === driverEventId &&
event.sender === context.driverUserId &&
event.attachment?.kind === "image" &&
event.attachment.caption === undefined,
roomId,
since: startSince,
timeoutMs: context.timeoutMs,
});
const { noReplyWindowMs } = await assertNoSutReplyWindow({
actorId: "driver",
client,
context,
roomId,
since: attachmentEvent.since,
startSince,
unexpectedMessage: "unexpected SUT reply to attachment-only group media",
});
return {
artifacts: {
attachmentFilename: MATRIX_QA_IMAGE_ATTACHMENT_FILENAME,
driverEventId,
expectedNoReplyWindowMs: noReplyWindowMs,
roomId,
},
details: [
`room id: ${roomId}`,
`driver attachment event: ${driverEventId}`,
`waited ${noReplyWindowMs}ms with no SUT reply`,
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runUnsupportedMediaSafeScenario(context: MatrixQaScenarioContext) {
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_MEDIA_ROOM_KEY);
const { client, startSince } = await primeMatrixQaDriverMediaClient(context);
const token = buildMatrixQaToken("MATRIX_QA_UNSUPPORTED_MEDIA");
const triggerBody = `${context.sutUserId} Unsupported media QA check: ignore the attached text file and reply with only this exact marker: ${token}`;
const driverEventId = await client.sendMediaMessage({
body: triggerBody,
buffer: Buffer.from("unsupported Matrix QA attachment body\n", "utf8"),
contentType: "text/plain",
fileName: "unsupported-matrix-qa.txt",
kind: "file",
mentionUserIds: [context.sutUserId],
roomId,
});
const matched = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
isMatrixQaExactMarkerReply(event, {
roomId,
sutUserId: context.sutUserId,
token,
}) && event.relatesTo === undefined,
roomId,
since: startSince,
timeoutMs: context.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: matched.since,
startSince,
});
const reply = buildMatrixReplyArtifact(matched.event, token);
return {
artifacts: {
attachmentFilename: "unsupported-matrix-qa.txt",
attachmentKind: "file",
driverEventId,
reply,
roomId,
token,
triggerBody,
},
details: [
`room id: ${roomId}`,
`driver file event: ${driverEventId}`,
...buildMatrixReplyDetails("reply", reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runGeneratedImageDeliveryScenario(context: MatrixQaScenarioContext) {
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_MEDIA_ROOM_KEY);
const { client, startSince } = await primeMatrixQaDriverMediaClient(context);
const triggerBody = buildMatrixQaImageGenerationPrompt(context.sutUserId);
const driverEventIds: string[] = [];
const isGeneratedImageEvent = (event: MatrixQaObservedEvent) =>
event.roomId === roomId &&
event.sender === context.sutUserId &&
event.type === "m.room.message" &&
event.relatesTo === undefined &&
event.msgtype === "m.image" &&
event.attachment?.kind === "image";
let matched = await client.waitForOptionalRoomEvent({
observedEvents: context.observedEvents,
predicate: isGeneratedImageEvent,
roomId,
since: startSince,
timeoutMs: 0,
});
for (let attempt = 1; !matched.matched && attempt <= 2; attempt += 1) {
const driverEventId = await client.sendTextMessage({
body: triggerBody,
mentionUserIds: [context.sutUserId],
roomId,
});
driverEventIds.push(driverEventId);
matched = await client.waitForOptionalRoomEvent({
observedEvents: context.observedEvents,
predicate: isGeneratedImageEvent,
roomId,
since: matched.since ?? startSince,
timeoutMs: context.timeoutMs,
});
}
if (!matched.matched) {
throw new Error(
`timed out after ${context.timeoutMs}ms waiting for Matrix generated image after ${driverEventIds.length} attempt(s)`,
);
}
const matchedEvent = matched.event;
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: matched.since,
startSince,
});
const attachment = requireMatrixQaImageAttachment(
matchedEvent,
"Matrix generated image delivery scenario",
);
return {
artifacts: {
attachmentBodyPreview: matchedEvent.body?.slice(0, 200),
attachmentEventId: matchedEvent.eventId,
attachmentFilename: attachment.filename,
attachmentKind: attachment.kind,
attachmentMsgtype: matchedEvent.msgtype,
driverEventId: driverEventIds[0],
driverEventIds,
roomId,
triggerBody,
},
details: [
`room id: ${roomId}`,
`driver events: ${driverEventIds.join(", ")}`,
...buildMatrixQaAttachmentDetailLines({
attachmentEvent: matchedEvent,
label: "generated image",
}),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}

View File

@@ -0,0 +1,240 @@
// Qa Matrix plugin module implements scenario runtime reaction behavior.
import type { MatrixQaObservedEvent } from "../../substrate/events.js";
import {
advanceMatrixQaActorCursor,
assertNoSutReplyWindow,
createMatrixQaDriverScenarioClient,
primeMatrixQaActorCursor,
type MatrixQaActorId,
type MatrixQaScenarioContext,
type MatrixQaSyncState,
} from "./scenario-runtime-shared.js";
import type { MatrixQaScenarioExecution } from "./scenario-types.js";
export function buildMatrixQaReactionDetailLines(params: {
actorUserId?: string;
observedReactionKey?: string;
reactionEmoji: string;
reactionEventId: string;
reactionTargetEventId: string;
}) {
return [
`reaction event: ${params.reactionEventId}`,
`reaction target: ${params.reactionTargetEventId}`,
`reaction emoji: ${params.reactionEmoji}`,
...(params.actorUserId ? [`reaction sender: ${params.actorUserId}`] : []),
...(params.observedReactionKey ? [`observed reaction key: ${params.observedReactionKey}`] : []),
];
}
function requireMatrixQaReactionTargetEventId(
reactionTargetEventId: string | undefined,
scenarioLabel: string,
) {
const normalizedReactionTargetEventId = reactionTargetEventId?.trim();
if (!normalizedReactionTargetEventId) {
throw new Error(`${scenarioLabel} requires a canary reply event id`);
}
return normalizedReactionTargetEventId;
}
export async function observeReactionScenario(params: {
actorId: MatrixQaActorId;
actorUserId: string;
accessToken: string;
baseUrl: string;
observedEvents: MatrixQaObservedEvent[];
reactionEmoji?: string;
reactionTargetEventId: string;
roomId: string;
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaScenarioContext["syncStreams"];
timeoutMs: number;
}) {
const { client, startSince } = await primeMatrixQaActorCursor({
accessToken: params.accessToken,
actorId: params.actorId,
baseUrl: params.baseUrl,
observedEvents: params.observedEvents,
syncState: params.syncState,
syncStreams: params.syncStreams,
});
const reactionEmoji = params.reactionEmoji ?? "👍";
const reactionEventId = await client.sendReaction({
emoji: reactionEmoji,
messageId: params.reactionTargetEventId,
roomId: params.roomId,
});
const matched = await client.waitForRoomEvent({
observedEvents: params.observedEvents,
predicate: (event) =>
event.roomId === params.roomId &&
event.sender === params.actorUserId &&
event.type === "m.reaction" &&
event.eventId === reactionEventId &&
event.reaction?.eventId === params.reactionTargetEventId &&
event.reaction?.key === reactionEmoji,
roomId: params.roomId,
since: startSince,
timeoutMs: params.timeoutMs,
});
return {
actorId: params.actorId,
actorUserId: params.actorUserId,
event: matched.event,
reactionEmoji,
reactionEventId,
reactionTargetEventId: params.reactionTargetEventId,
since: matched.since,
startSince,
};
}
export function buildMatrixQaReactionArtifacts(params: {
actorUserId?: string;
expectedNoReplyWindowMs?: number;
reaction: Awaited<ReturnType<typeof observeReactionScenario>>;
}) {
return {
...(params.actorUserId ? { actorUserId: params.actorUserId } : {}),
...(params.expectedNoReplyWindowMs === undefined
? {}
: { expectedNoReplyWindowMs: params.expectedNoReplyWindowMs }),
reactionEmoji: params.reaction.reactionEmoji,
reactionEventId: params.reaction.reactionEventId,
reactionTargetEventId: params.reaction.reactionTargetEventId,
};
}
export async function runReactionNotificationScenario(context: MatrixQaScenarioContext) {
const reactionTargetEventId = requireMatrixQaReactionTargetEventId(
context.canary?.reply.eventId,
"Matrix reaction scenario",
);
const result = await observeReactionScenario({
actorId: "driver",
actorUserId: context.driverUserId,
accessToken: context.driverAccessToken,
baseUrl: context.baseUrl,
observedEvents: context.observedEvents,
reactionTargetEventId,
roomId: context.roomId,
syncState: context.syncState,
syncStreams: context.syncStreams,
timeoutMs: context.timeoutMs,
});
return {
artifacts: buildMatrixQaReactionArtifacts({ reaction: result }),
details: buildMatrixQaReactionDetailLines({
actorUserId: result.actorUserId,
observedReactionKey: result.event.reaction?.key,
reactionEmoji: result.reactionEmoji,
reactionEventId: result.reactionEventId,
reactionTargetEventId: result.reactionTargetEventId,
}).join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runReactionNotAReplyScenario(context: MatrixQaScenarioContext) {
const reactionTargetEventId = requireMatrixQaReactionTargetEventId(
context.canary?.reply.eventId,
"Matrix reaction no-reply scenario",
);
const reaction = await observeReactionScenario({
actorId: "driver",
actorUserId: context.driverUserId,
accessToken: context.driverAccessToken,
baseUrl: context.baseUrl,
observedEvents: context.observedEvents,
reactionTargetEventId,
roomId: context.roomId,
syncState: context.syncState,
syncStreams: context.syncStreams,
timeoutMs: context.timeoutMs,
});
const client = createMatrixQaDriverScenarioClient(context);
const { noReplyWindowMs } = await assertNoSutReplyWindow({
actorId: reaction.actorId,
client,
context,
roomId: context.roomId,
since: reaction.since,
startSince: reaction.startSince,
unexpectedLines: [
`reaction target: ${reaction.reactionTargetEventId}`,
`reaction event: ${reaction.reactionEventId}`,
],
unexpectedMessage: `unexpected SUT reply after reaction from ${context.driverUserId}`,
});
return {
artifacts: buildMatrixQaReactionArtifacts({
actorUserId: context.driverUserId,
expectedNoReplyWindowMs: noReplyWindowMs,
reaction,
}),
details: [
...buildMatrixQaReactionDetailLines({
reactionEmoji: reaction.reactionEmoji,
reactionEventId: reaction.reactionEventId,
reactionTargetEventId: reaction.reactionTargetEventId,
}),
`waited ${noReplyWindowMs}ms with no SUT reply`,
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runReactionRedactionObservedScenario(context: MatrixQaScenarioContext) {
const reactionTargetEventId = requireMatrixQaReactionTargetEventId(
context.canary?.reply.eventId,
"Matrix reaction redaction scenario",
);
const reaction = await observeReactionScenario({
actorId: "driver",
actorUserId: context.driverUserId,
accessToken: context.driverAccessToken,
baseUrl: context.baseUrl,
observedEvents: context.observedEvents,
reactionTargetEventId,
roomId: context.roomId,
syncState: context.syncState,
syncStreams: context.syncStreams,
timeoutMs: context.timeoutMs,
});
const client = createMatrixQaDriverScenarioClient(context);
const redactionEventId = await client.redactEvent({
eventId: reaction.reactionEventId,
reason: "matrix qa reaction removal",
roomId: context.roomId,
});
const redaction = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
event.roomId === context.roomId &&
event.eventId === redactionEventId &&
event.sender === context.driverUserId &&
event.kind === "redaction",
roomId: context.roomId,
since: reaction.since,
timeoutMs: context.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: reaction.actorId,
syncState: context.syncState,
nextSince: redaction.since,
startSince: reaction.startSince,
});
return {
artifacts: {
...buildMatrixQaReactionArtifacts({ reaction }),
redactionEventId,
},
details: [
...buildMatrixQaReactionDetailLines({
reactionEmoji: reaction.reactionEmoji,
reactionEventId: reaction.reactionEventId,
reactionTargetEventId: reaction.reactionTargetEventId,
}),
`redaction event: ${redactionEventId}`,
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}

View File

@@ -0,0 +1,429 @@
// Qa Matrix plugin module implements scenario runtime restart behavior.
import {
MATRIX_QA_HOMESERVER_ROOM_KEY,
MATRIX_QA_RESTART_ROOM_KEY,
MATRIX_QA_STALE_SYNC_ROOM_KEY,
resolveMatrixQaScenarioRoomId,
} from "./scenario-catalog.js";
import {
buildMatrixReplyDetails,
buildMatrixQaToken,
buildMentionPrompt,
buildMatrixReplyArtifact,
isMatrixQaExactMarkerReply,
assertTopLevelReplyArtifact,
advanceMatrixQaActorCursor,
primeMatrixQaDriverScenarioClient,
resolveMatrixQaNoReplyWindowMs,
runAssertedDriverTopLevelScenario,
type MatrixQaScenarioContext,
} from "./scenario-runtime-shared.js";
import {
rewriteMatrixSyncStoreCursor,
waitForMatrixInboundDedupeEntry,
waitForMatrixSyncStoreWithCursor,
} from "./scenario-runtime-state-files.js";
import type { MatrixQaScenarioExecution } from "./scenario-types.js";
type MatrixQaDriverClient = Awaited<ReturnType<typeof primeMatrixQaDriverScenarioClient>>["client"];
type MatrixReplyArtifact = ReturnType<typeof buildMatrixReplyArtifact>;
export async function runHomeserverRestartResumeScenario(context: MatrixQaScenarioContext) {
if (!context.interruptTransport) {
throw new Error("Matrix homeserver restart scenario requires a transport interruption hook");
}
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_HOMESERVER_ROOM_KEY);
await context.interruptTransport();
const resumed = await runAssertedDriverTopLevelScenario({
context,
label: "post-homeserver-restart reply",
roomId,
tokenPrefix: "MATRIX_QA_HOMESERVER",
});
return {
artifacts: {
driverEventId: resumed.driverEventId,
reply: resumed.reply,
roomId,
token: resumed.token,
transportInterruption: "homeserver-restart",
},
details: [
`room id: ${roomId}`,
"transport interruption: homeserver-restart",
`driver event: ${resumed.driverEventId}`,
...buildMatrixReplyDetails("reply", resumed.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runRestartResumeScenario(context: MatrixQaScenarioContext) {
if (!context.restartGateway) {
throw new Error("Matrix restart scenario requires a gateway restart callback");
}
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_RESTART_ROOM_KEY);
await context.restartGateway();
const result = await runAssertedDriverTopLevelScenario({
context,
label: "post-restart reply",
roomId,
tokenPrefix: "MATRIX_QA_RESTART",
});
return {
artifacts: {
driverEventId: result.driverEventId,
reply: result.reply,
restartSignal: "SIGUSR1",
roomId,
token: result.token,
},
details: [
`room id: ${roomId}`,
"restart signal: SIGUSR1",
`post-restart driver event: ${result.driverEventId}`,
...buildMatrixReplyDetails("reply", result.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runPostRestartRoomContinueScenario(context: MatrixQaScenarioContext) {
if (!context.restartGateway) {
throw new Error("Matrix post-restart continuity scenario requires a gateway restart callback");
}
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_RESTART_ROOM_KEY);
await context.restartGateway();
const first = await runAssertedDriverTopLevelScenario({
context,
label: "first post-restart reply",
roomId,
tokenPrefix: "MATRIX_QA_RESTART_FIRST",
});
const second = await runAssertedDriverTopLevelScenario({
context,
label: "second post-restart reply",
roomId,
tokenPrefix: "MATRIX_QA_RESTART_SECOND",
});
return {
artifacts: {
firstDriverEventId: first.driverEventId,
firstReply: first.reply,
firstToken: first.token,
restartSignal: "SIGUSR1",
roomId,
secondDriverEventId: second.driverEventId,
secondReply: second.reply,
secondToken: second.token,
},
details: [
`room id: ${roomId}`,
"restart signal: SIGUSR1",
`first post-restart driver event: ${first.driverEventId}`,
...buildMatrixReplyDetails("first reply", first.reply),
`second post-restart driver event: ${second.driverEventId}`,
...buildMatrixReplyDetails("second reply", second.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runInitialCatchupThenIncrementalScenario(context: MatrixQaScenarioContext) {
if (!context.restartGatewayWithQueuedMessage) {
throw new Error(
"Matrix initial catchup scenario requires a queued-message gateway restart callback",
);
}
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_RESTART_ROOM_KEY);
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
const catchupToken = buildMatrixQaToken("MATRIX_QA_CATCHUP");
const catchupBody = buildMentionPrompt(context.sutUserId, catchupToken);
let catchupDriverEventId = "";
await context.restartGatewayWithQueuedMessage(async () => {
catchupDriverEventId = await client.sendTextMessage({
body: catchupBody,
mentionUserIds: [context.sutUserId],
roomId,
});
});
const catchupMatched = await client.waitForRoomEvent({
observedEvents: context.observedEvents,
predicate: (event) =>
isMatrixQaExactMarkerReply(event, {
roomId,
sutUserId: context.sutUserId,
token: catchupToken,
}) && event.relatesTo === undefined,
roomId,
since: startSince,
timeoutMs: context.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: context.syncState,
nextSince: catchupMatched.since,
startSince,
});
const catchupReply = buildMatrixReplyArtifact(catchupMatched.event, catchupToken);
assertTopLevelReplyArtifact("catchup reply", catchupReply);
const incremental = await runAssertedDriverTopLevelScenario({
context,
label: "incremental reply after catchup",
roomId,
tokenPrefix: "MATRIX_QA_INCREMENTAL",
});
return {
artifacts: {
catchupDriverEventId,
catchupReply,
catchupToken,
incrementalDriverEventId: incremental.driverEventId,
incrementalReply: incremental.reply,
incrementalToken: incremental.token,
restartSignal: "SIGUSR1",
roomId,
},
details: [
`room id: ${roomId}`,
"restart signal: SIGUSR1",
`catchup driver event: ${catchupDriverEventId}`,
...buildMatrixReplyDetails("catchup reply", catchupReply),
`incremental driver event: ${incremental.driverEventId}`,
...buildMatrixReplyDetails("incremental reply", incremental.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
async function sendAndAssertRestartReplayReply(params: {
context: MatrixQaScenarioContext;
replyLabel: string;
roomId: string;
tokenPrefix: string;
}) {
const { client, startSince } = await primeMatrixQaDriverScenarioClient(params.context);
const replayToken = buildMatrixQaToken(params.tokenPrefix);
const replayBody = buildMentionPrompt(params.context.sutUserId, replayToken);
const replayDriverEventId = await client.sendTextMessage({
body: replayBody,
mentionUserIds: [params.context.sutUserId],
roomId: params.roomId,
});
const firstMatched = await client.waitForRoomEvent({
observedEvents: params.context.observedEvents,
predicate: (event) =>
isMatrixQaExactMarkerReply(event, {
roomId: params.roomId,
sutUserId: params.context.sutUserId,
token: replayToken,
}) && event.relatesTo === undefined,
roomId: params.roomId,
since: startSince,
timeoutMs: params.context.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: params.context.syncState,
nextSince: firstMatched.since,
startSince,
});
const firstReply = buildMatrixReplyArtifact(firstMatched.event, replayToken);
assertTopLevelReplyArtifact(params.replyLabel, firstReply);
return { client, firstMatched, firstReply, replayDriverEventId, replayToken, startSince };
}
async function assertNoRestartReplayDuplicate(params: {
client: MatrixQaDriverClient;
context: MatrixQaScenarioContext;
errorDetails: string[];
errorTitle: string;
firstMatchedSince: string | undefined;
firstReply: MatrixReplyArtifact;
replayToken: string;
roomId: string;
startSince: string;
}) {
const duplicate = await params.client.waitForOptionalRoomEvent({
observedEvents: params.context.observedEvents,
predicate: (event) =>
event.eventId !== params.firstReply.eventId &&
isMatrixQaExactMarkerReply(event, {
roomId: params.roomId,
sutUserId: params.context.sutUserId,
token: params.replayToken,
}),
roomId: params.roomId,
timeoutMs: resolveMatrixQaNoReplyWindowMs(params.context.timeoutMs),
});
if (duplicate.matched) {
throw new Error(
[
params.errorTitle,
...params.errorDetails,
...buildMatrixReplyDetails("first reply", params.firstReply),
...buildMatrixReplyDetails(
"duplicate reply",
buildMatrixReplyArtifact(duplicate.event, params.replayToken),
),
].join("\n"),
);
}
advanceMatrixQaActorCursor({
actorId: "driver",
syncState: params.context.syncState,
nextSince: duplicate.since,
startSince: params.firstMatchedSince ?? params.startSince,
});
}
export async function runRestartReplayDedupeScenario(context: MatrixQaScenarioContext) {
if (!context.restartGateway) {
throw new Error("Matrix restart replay dedupe scenario requires a gateway restart callback");
}
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_RESTART_ROOM_KEY);
const { client, firstMatched, firstReply, replayDriverEventId, replayToken, startSince } =
await sendAndAssertRestartReplayReply({
context,
replyLabel: "first replay-dedupe reply",
roomId,
tokenPrefix: "MATRIX_QA_REPLAY_DEDUPE",
});
await context.restartGateway();
await assertNoRestartReplayDuplicate({
client,
context,
errorDetails: [`original driver event: ${replayDriverEventId}`],
errorTitle: "Matrix restart replayed an already handled event",
firstMatchedSince: firstMatched.since,
firstReply,
replayToken,
roomId,
startSince,
});
const postRestart = await runAssertedDriverTopLevelScenario({
context,
label: "fresh post-restart reply",
roomId,
tokenPrefix: "MATRIX_QA_REPLAY_DEDUPE_FRESH",
});
return {
artifacts: {
duplicateWindowMs: resolveMatrixQaNoReplyWindowMs(context.timeoutMs),
firstDriverEventId: replayDriverEventId,
firstReply,
firstToken: replayToken,
freshDriverEventId: postRestart.driverEventId,
freshReply: postRestart.reply,
freshToken: postRestart.token,
restartSignal: "SIGUSR1",
roomId,
},
details: [
`room id: ${roomId}`,
"restart signal: SIGUSR1",
`first driver event: ${replayDriverEventId}`,
...buildMatrixReplyDetails("first reply", firstReply),
`duplicate replay window: ${resolveMatrixQaNoReplyWindowMs(context.timeoutMs)}ms`,
`fresh post-restart driver event: ${postRestart.driverEventId}`,
...buildMatrixReplyDetails("fresh reply", postRestart.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runStaleSyncReplayDedupeScenario(context: MatrixQaScenarioContext) {
if (!context.restartGatewayAfterStateMutation) {
throw new Error(
"Matrix stale sync replay dedupe scenario requires a persisted-state restart callback",
);
}
if (!context.gatewayStateDir) {
throw new Error("Matrix stale sync replay dedupe scenario requires a gateway state directory");
}
const stateDir = context.gatewayStateDir;
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_STALE_SYNC_ROOM_KEY);
const syncStore = await waitForMatrixSyncStoreWithCursor({
context,
stateDir,
timeoutMs: Math.min(5_000, context.timeoutMs),
});
const staleCursor = syncStore.cursor;
const { client, firstMatched, firstReply, replayDriverEventId, replayToken, startSince } =
await sendAndAssertRestartReplayReply({
context,
replyLabel: "first stale-sync replay-dedupe reply",
roomId,
tokenPrefix: "MATRIX_QA_STALE_SYNC_DEDUPE",
});
await waitForMatrixInboundDedupeEntry({
context,
eventId: replayDriverEventId,
roomId,
stateDir,
timeoutMs: Math.min(5_000, context.timeoutMs),
});
await context.restartGatewayAfterStateMutation(async () => {
await rewriteMatrixSyncStoreCursor({
cursor: staleCursor,
pathname: syncStore.pathname,
source: syncStore.source,
stateKey: syncStore.stateKey,
});
});
await assertNoRestartReplayDuplicate({
client,
context,
errorDetails: [
`original driver event: ${replayDriverEventId}`,
`stale sync cursor: ${staleCursor}`,
],
errorTitle: "Matrix stale sync cursor replayed an already handled event",
firstMatchedSince: firstMatched.since,
firstReply,
replayToken,
roomId,
startSince,
});
const postRestart = await runAssertedDriverTopLevelScenario({
context,
label: "fresh post-stale-sync-restart reply",
roomId,
tokenPrefix: "MATRIX_QA_STALE_SYNC_DEDUPE_FRESH",
});
return {
artifacts: {
dedupeCommitObserved: true,
duplicateWindowMs: resolveMatrixQaNoReplyWindowMs(context.timeoutMs),
firstDriverEventId: replayDriverEventId,
firstReply,
firstToken: replayToken,
freshDriverEventId: postRestart.driverEventId,
freshReply: postRestart.reply,
freshToken: postRestart.token,
restartSignal: "hard-restart",
roomId,
staleSyncCursor: staleCursor,
},
details: [
`room id: ${roomId}`,
"restart signal: hard-restart",
`stale sync cursor: ${staleCursor}`,
`first driver event: ${replayDriverEventId}`,
...buildMatrixReplyDetails("first reply", firstReply),
`duplicate replay window: ${resolveMatrixQaNoReplyWindowMs(context.timeoutMs)}ms`,
`fresh post-restart driver event: ${postRestart.driverEventId}`,
...buildMatrixReplyDetails("fresh reply", postRestart.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
// Qa Matrix tests cover scenario runtime shared plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveMatrixQaNoReplyWindowMs } from "./scenario-runtime-shared.js";
describe("matrix scenario runtime shared", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("normalizes the Matrix QA no-reply window env", () => {
expect(resolveMatrixQaNoReplyWindowMs(30_000)).toBe(8_000);
vi.stubEnv("OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS", "12000");
expect(resolveMatrixQaNoReplyWindowMs(30_000)).toBe(12_000);
expect(resolveMatrixQaNoReplyWindowMs(5_000)).toBe(5_000);
for (const value of ["1e3", "0x1000", "1.5", "nope"]) {
vi.stubEnv("OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS", value);
expect(resolveMatrixQaNoReplyWindowMs(30_000)).toBe(8_000);
}
});
});

View File

@@ -0,0 +1,716 @@
// Qa Matrix plugin module implements scenario runtime shared behavior.
import { randomUUID } from "node:crypto";
import { createMatrixQaClient, type MatrixQaRoomObserver } from "../../substrate/client.js";
import type { MatrixQaObservedEvent } from "../../substrate/events.js";
import type { MatrixQaFaultProxyObserver } from "../../substrate/fault-proxy.js";
import { createMatrixQaRoomObserver } from "../../substrate/sync.js";
import type { MatrixQaProvisionedTopology } from "../../substrate/topology.js";
import { resolveMatrixQaScenarioRoomId } from "./scenario-catalog.js";
import type {
MatrixQaCanaryArtifact,
MatrixQaReplyArtifact,
MatrixQaScenarioExecution,
} from "./scenario-types.js";
export type MatrixQaActorId = "driver" | "observer";
export type MatrixQaSyncState = Partial<Record<MatrixQaActorId, string>>;
export type MatrixQaSyncStreams = Partial<Record<MatrixQaActorId, MatrixQaRoomObserver>>;
export type MatrixQaScenarioContext = {
baseUrl: string;
canary?: MatrixQaCanaryArtifact;
driverAccessToken: string;
driverDeviceId?: string;
driverPassword?: string;
driverUserId: string;
faultProxyObserver?: MatrixQaFaultProxyObserver;
faultProxyTargetBaseUrl?: string;
observedEvents: MatrixQaObservedEvent[];
observerAccessToken: string;
observerDeviceId?: string;
observerPassword?: string;
observerUserId: string;
gatewayRuntimeEnv?: NodeJS.ProcessEnv;
gatewayStateDir?: string;
gatewayWorkspaceDir?: string;
gatewayCall?: (
method: string,
params?: Record<string, unknown>,
opts?: { expectFinal?: boolean; timeoutMs?: number },
) => Promise<unknown>;
outputDir?: string;
registrationToken?: string;
restartGateway?: () => Promise<void>;
restartGatewayAfterStateMutation?: (
mutateState: (context: { stateDir: string }) => Promise<void>,
opts?: { timeoutMs?: number; waitAccountId?: string },
) => Promise<void>;
restartGatewayWithQueuedMessage?: (queueMessage: () => Promise<void>) => Promise<void>;
roomId: string;
interruptTransport?: () => Promise<void>;
sutAccessToken: string;
sutAccountId?: string;
sutDeviceId?: string;
sutPassword?: string;
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaSyncStreams;
sutUserId: string;
timeoutMs: number;
topology: MatrixQaProvisionedTopology;
patchGatewayConfig?: (
patch: Record<string, unknown>,
opts?: { replacePaths?: string[]; restartDelayMs?: number },
) => Promise<void>;
waitGatewayAccountReady?: (accountId: string, opts?: { timeoutMs?: number }) => Promise<void>;
};
const NO_REPLY_WINDOW_MS = 8_000;
const NO_REPLY_WINDOW_ENV = "OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS";
export function resolveMatrixQaNoReplyWindowMs(timeoutMs: number) {
const raw = process.env[NO_REPLY_WINDOW_ENV]?.trim();
const parsed =
raw === undefined ? NO_REPLY_WINDOW_MS : /^\d+$/.test(raw) ? Number(raw) : Number.NaN;
const windowMs = Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : NO_REPLY_WINDOW_MS;
return Math.min(windowMs, timeoutMs);
}
export function buildMentionPrompt(sutUserId: string, token: string) {
return `${sutUserId} reply with only this exact marker: ${token}`;
}
export function buildExactMarkerPrompt(token: string) {
return `reply with only this exact marker: ${token}`;
}
export function buildMatrixQaToken(prefix: string) {
return `${prefix}_${randomUUID().slice(0, 8).toUpperCase()}`;
}
export function buildMatrixQuietStreamingPrompt(sutUserId: string, text: string) {
return `${sutUserId} Quiet streaming QA check: reply exactly \`${text}\`.`;
}
export function buildMatrixPartialStreamingPrompt(sutUserId: string, text: string) {
return `${sutUserId} Partial streaming QA check: reply exactly \`${text}\`.`;
}
export const MATRIX_QA_TOOL_PROGRESS_TASK_FILENAME = "QA_KICKOFF_TASK.md";
export const MATRIX_QA_TOOL_PROGRESS_MENTION_FILENAME =
"matrix-progress-@room-@alice:matrix-qa.test-!room:matrix-qa.test.txt";
export const MATRIX_QA_TOOL_PROGRESS_COMMAND = "printf 'matrix-command-progress-start\\n'; sleep 2";
export function buildMatrixToolProgressTaskContent(text: string) {
return [
"Matrix tool progress QA task.",
"Reply with only this exact marker and no other text:",
text,
].join("\n");
}
export function buildMatrixToolProgressPrompt(sutUserId: string) {
return [
`${sutUserId} Tool progress QA check: call the read tool exactly once on \`${MATRIX_QA_TOOL_PROGRESS_TASK_FILENAME}\` before answering.`,
`The QA harness must observe that read tool call; the only valid final marker is inside that file.`,
`Do not guess or send any marker before the tool result returns.`,
`Do not read \`HEARTBEAT.md\` for this check.`,
`After that read completes, reply with only the exact marker from the file and no other text.`,
].join(" ");
}
export function buildMatrixToolProgressCommandPrompt(sutUserId: string, text: string) {
return [
`${sutUserId} Tool progress QA check: call the exec tool exactly once with this exact command before answering: \`${MATRIX_QA_TOOL_PROGRESS_COMMAND}\`.`,
`The QA harness must observe that exec command preview and its completion as edits to one Matrix draft.`,
`After that exec command completes or fails, reply exactly \`${text}\`.`,
].join(" ");
}
export function buildMatrixToolProgressErrorPrompt(sutUserId: string, text: string) {
return [
`${sutUserId} Tool progress error QA check: read \`missing-matrix-tool-progress-target.txt\` before answering.`,
`After the read fails, reply exactly \`${text}\`.`,
].join(" ");
}
export function buildMatrixToolProgressMentionSafetyPrompt(sutUserId: string, text: string) {
return [
`${sutUserId} Tool progress QA check: read the missing workspace file \`${MATRIX_QA_TOOL_PROGRESS_MENTION_FILENAME}\` before answering.`,
`The QA harness must observe that failed read in a Matrix tool-progress preview.`,
`Do not guess or send any marker before the tool result returns.`,
`After that read fails, reply exactly \`${text}\`.`,
].join(" ");
}
export function buildMatrixBlockStreamingPrompt(
sutUserId: string,
firstText: string,
secondText: string,
) {
return [
`${sutUserId} Block streaming QA check: complete this whole sequence in one turn.`,
`Step 1: send an assistant text block containing only this exact marker: \`${firstText}\`.`,
"That first marker block must be emitted before any tool call.",
"Step 2: after the first marker block, use the read tool exactly once on `QA_KICKOFF_TASK.md`.",
`Step 3: after that read completes, send a final assistant text block containing only this exact marker: \`${secondText}\`.`,
"Never put both markers in the same assistant text block.",
].join("\n");
}
export function isMatrixQaMessageLikeKind(kind: MatrixQaObservedEvent["kind"]) {
return kind === "message" || kind === "notice";
}
export function doesMatrixQaReplyBodyMatchToken(event: MatrixQaObservedEvent, token: string) {
return event.body?.trim() === token;
}
export function isMatrixQaExactMarkerReply(
event: MatrixQaObservedEvent,
params: {
roomId: string;
sutUserId: string;
token: string;
},
) {
return (
event.roomId === params.roomId &&
event.sender === params.sutUserId &&
event.type === "m.room.message" &&
isMatrixQaMessageLikeKind(event.kind) &&
doesMatrixQaReplyBodyMatchToken(event, params.token)
);
}
export function buildMatrixReplyArtifact(
event: MatrixQaObservedEvent,
token?: string,
): MatrixQaReplyArtifact {
const replyBody = event.body?.trim();
return {
bodyPreview: replyBody?.slice(0, 200),
eventId: event.eventId,
mentions: event.mentions,
relatesTo: event.relatesTo,
sender: event.sender,
...(token ? { tokenMatched: doesMatrixQaReplyBodyMatchToken(event, token) } : {}),
};
}
export function buildMatrixNoticeArtifact(event: MatrixQaObservedEvent) {
return {
bodyPreview: event.body?.trim().slice(0, 200),
eventId: event.eventId,
sender: event.sender,
};
}
export function buildMatrixReplyDetails(label: string, artifact: MatrixQaReplyArtifact) {
return [
`${label} event: ${artifact.eventId}`,
`${label} token matched: ${
artifact.tokenMatched === undefined ? "n/a" : artifact.tokenMatched ? "yes" : "no"
}`,
`${label} rel_type: ${artifact.relatesTo?.relType ?? "<none>"}`,
`${label} in_reply_to: ${artifact.relatesTo?.inReplyToId ?? "<none>"}`,
`${label} is_falling_back: ${artifact.relatesTo?.isFallingBack === true ? "true" : "false"}`,
];
}
export function assertTopLevelReplyArtifact(label: string, artifact: MatrixQaReplyArtifact) {
if (!artifact.tokenMatched) {
throw new Error(`${label} did not contain the expected token`);
}
if (artifact.relatesTo !== undefined) {
throw new Error(`${label} unexpectedly included relation metadata`);
}
}
export function assertThreadReplyArtifact(
artifact: MatrixQaReplyArtifact,
params: {
expectedRootEventId: string;
label: string;
},
) {
if (!artifact.tokenMatched) {
throw new Error(`${params.label} did not contain the expected token`);
}
if (artifact.relatesTo?.relType !== "m.thread") {
throw new Error(`${params.label} did not use m.thread`);
}
if (artifact.relatesTo.eventId !== params.expectedRootEventId) {
throw new Error(
`${params.label} targeted ${artifact.relatesTo.eventId ?? "<none>"} instead of ${params.expectedRootEventId}`,
);
}
if (artifact.relatesTo.isFallingBack !== true) {
throw new Error(`${params.label} did not set is_falling_back`);
}
if (!artifact.relatesTo.inReplyToId) {
throw new Error(`${params.label} did not set m.in_reply_to`);
}
}
export function readMatrixQaSyncCursor(syncState: MatrixQaSyncState, actorId: MatrixQaActorId) {
return syncState[actorId];
}
export function writeMatrixQaSyncCursor(
syncState: MatrixQaSyncState,
actorId: MatrixQaActorId,
since?: string,
) {
if (since) {
syncState[actorId] = since;
}
}
function getOrCreateMatrixQaActorSyncStream(params: {
accessToken: string;
actorId: MatrixQaActorId;
baseUrl: string;
observedEvents: MatrixQaObservedEvent[];
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaSyncStreams;
}) {
const existingStream = params.syncStreams?.[params.actorId];
if (existingStream) {
return existingStream;
}
const stream = createMatrixQaRoomObserver({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
observedEvents: params.observedEvents,
since: readMatrixQaSyncCursor(params.syncState, params.actorId),
});
if (params.syncStreams) {
params.syncStreams[params.actorId] = stream;
}
return stream;
}
export function createMatrixQaScenarioClient(params: {
accessToken: string;
actorId?: MatrixQaActorId;
baseUrl: string;
observedEvents?: MatrixQaObservedEvent[];
syncState?: MatrixQaSyncState;
syncStreams?: MatrixQaSyncStreams;
}) {
const syncObserver =
params.actorId && params.observedEvents && params.syncState && params.syncStreams
? getOrCreateMatrixQaActorSyncStream({
accessToken: params.accessToken,
actorId: params.actorId,
baseUrl: params.baseUrl,
observedEvents: params.observedEvents,
syncState: params.syncState,
syncStreams: params.syncStreams,
})
: undefined;
return createMatrixQaClient({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
...(syncObserver ? { syncObserver } : {}),
});
}
export function createMatrixQaDriverScenarioClient(context: MatrixQaScenarioContext) {
return createMatrixQaScenarioClient({
accessToken: context.driverAccessToken,
actorId: "driver",
baseUrl: context.baseUrl,
observedEvents: context.observedEvents,
syncState: context.syncState,
syncStreams: context.syncStreams,
});
}
export async function primeMatrixQaActorCursor(params: {
accessToken: string;
actorId: MatrixQaActorId;
baseUrl: string;
observedEvents: MatrixQaObservedEvent[];
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaSyncStreams;
}) {
const client = createMatrixQaScenarioClient({
accessToken: params.accessToken,
actorId: params.actorId,
baseUrl: params.baseUrl,
observedEvents: params.observedEvents,
syncState: params.syncState,
syncStreams: params.syncStreams,
});
const existingSince = readMatrixQaSyncCursor(params.syncState, params.actorId);
if (existingSince) {
return { client, startSince: existingSince };
}
const startSince = await client.primeRoom();
if (!startSince) {
throw new Error(`Matrix ${params.actorId} /sync prime did not return a next_batch cursor`);
}
return { client, startSince };
}
export async function primeMatrixQaDriverScenarioClient(context: MatrixQaScenarioContext) {
return await primeMatrixQaActorCursor({
accessToken: context.driverAccessToken,
actorId: "driver",
baseUrl: context.baseUrl,
observedEvents: context.observedEvents,
syncState: context.syncState,
syncStreams: context.syncStreams,
});
}
export function advanceMatrixQaActorCursor(params: {
actorId: MatrixQaActorId;
syncState: MatrixQaSyncState;
nextSince?: string;
startSince: string;
}) {
writeMatrixQaSyncCursor(params.syncState, params.actorId, params.nextSince ?? params.startSince);
}
type MatrixQaScenarioClient = ReturnType<typeof createMatrixQaScenarioClient>;
export async function assertNoSutReplyWindow(params: {
actorId: MatrixQaActorId;
client: MatrixQaScenarioClient;
context: MatrixQaScenarioContext;
roomId: string;
since?: string;
startSince: string;
unexpectedLines?: string[];
unexpectedMessage: string;
}) {
const noReplyWindowMs = resolveMatrixQaNoReplyWindowMs(params.context.timeoutMs);
const result = await params.client.waitForOptionalRoomEvent({
observedEvents: params.context.observedEvents,
predicate: (event) =>
event.roomId === params.roomId &&
event.sender === params.context.sutUserId &&
event.type === "m.room.message",
roomId: params.roomId,
since: params.since,
timeoutMs: noReplyWindowMs,
});
if (result.matched) {
throw new Error(
[
params.unexpectedMessage,
...(params.unexpectedLines ?? []),
...buildMatrixReplyDetails("unexpected reply", buildMatrixReplyArtifact(result.event)),
].join("\n"),
);
}
advanceMatrixQaActorCursor({
actorId: params.actorId,
syncState: params.context.syncState,
nextSince: result.since,
startSince: params.startSince,
});
return {
noReplyWindowMs,
since: result.since,
};
}
export async function runConfigurableTopLevelScenario(params: {
accessToken: string;
actorId: MatrixQaActorId;
baseUrl: string;
observedEvents: MatrixQaObservedEvent[];
replyPredicate?: (
event: MatrixQaObservedEvent,
params: { driverEventId: string; token: string },
) => boolean;
roomId: string;
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaSyncStreams;
sutUserId: string;
timeoutMs: number;
tokenPrefix: string;
withMention?: boolean;
}) {
const { client, startSince } = await primeMatrixQaActorCursor({
accessToken: params.accessToken,
actorId: params.actorId,
baseUrl: params.baseUrl,
observedEvents: params.observedEvents,
syncState: params.syncState,
syncStreams: params.syncStreams,
});
const token = buildMatrixQaToken(params.tokenPrefix);
const body =
params.withMention === false
? buildExactMarkerPrompt(token)
: buildMentionPrompt(params.sutUserId, token);
const driverEventId = await client.sendTextMessage({
body,
...(params.withMention === false ? {} : { mentionUserIds: [params.sutUserId] }),
roomId: params.roomId,
});
const matched = await client.waitForRoomEvent({
observedEvents: params.observedEvents,
predicate: (event) =>
isMatrixQaExactMarkerReply(event, {
roomId: params.roomId,
sutUserId: params.sutUserId,
token,
}) &&
(params.replyPredicate?.(event, { driverEventId, token }) ?? event.relatesTo === undefined),
roomId: params.roomId,
since: startSince,
timeoutMs: params.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: params.actorId,
syncState: params.syncState,
nextSince: matched.since,
startSince,
});
return {
body,
driverEventId,
reply: buildMatrixReplyArtifact(matched.event, token),
token,
};
}
async function runTopLevelMentionScenario(params: {
accessToken: string;
actorId: MatrixQaActorId;
baseUrl: string;
observedEvents: MatrixQaObservedEvent[];
roomId: string;
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaSyncStreams;
sutUserId: string;
timeoutMs: number;
tokenPrefix: string;
withMention?: boolean;
}) {
return await runConfigurableTopLevelScenario(params);
}
export async function runDriverTopLevelMentionScenario(params: {
baseUrl: string;
driverAccessToken: string;
observedEvents: MatrixQaObservedEvent[];
roomId: string;
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaSyncStreams;
sutUserId: string;
timeoutMs: number;
tokenPrefix: string;
}) {
return await runTopLevelMentionScenario({
accessToken: params.driverAccessToken,
actorId: "driver",
baseUrl: params.baseUrl,
observedEvents: params.observedEvents,
roomId: params.roomId,
syncState: params.syncState,
syncStreams: params.syncStreams,
sutUserId: params.sutUserId,
timeoutMs: params.timeoutMs,
tokenPrefix: params.tokenPrefix,
});
}
export async function runAssertedDriverTopLevelScenario(params: {
context: MatrixQaScenarioContext;
label: string;
roomId?: string;
tokenPrefix: string;
}) {
const result = await runDriverTopLevelMentionScenario({
baseUrl: params.context.baseUrl,
driverAccessToken: params.context.driverAccessToken,
observedEvents: params.context.observedEvents,
roomId: params.roomId ?? params.context.roomId,
syncState: params.context.syncState,
syncStreams: params.context.syncStreams,
sutUserId: params.context.sutUserId,
timeoutMs: params.context.timeoutMs,
tokenPrefix: params.tokenPrefix,
});
assertTopLevelReplyArtifact(params.label, result.reply);
return result;
}
export async function waitForMembershipEvent(params: {
accessToken: string;
actorId: MatrixQaActorId;
baseUrl: string;
membership: "invite" | "join" | "leave";
observedEvents: MatrixQaObservedEvent[];
roomId: string;
stateKey: string;
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaSyncStreams;
timeoutMs: number;
}) {
const { client, startSince } = await primeMatrixQaActorCursor({
accessToken: params.accessToken,
actorId: params.actorId,
baseUrl: params.baseUrl,
observedEvents: params.observedEvents,
syncState: params.syncState,
syncStreams: params.syncStreams,
});
const matched = await client.waitForRoomEvent({
observedEvents: params.observedEvents,
predicate: (event) =>
event.roomId === params.roomId &&
event.type === "m.room.member" &&
event.stateKey === params.stateKey &&
event.membership === params.membership,
roomId: params.roomId,
since: startSince,
timeoutMs: params.timeoutMs,
});
advanceMatrixQaActorCursor({
actorId: params.actorId,
syncState: params.syncState,
nextSince: matched.since,
startSince,
});
return matched.event;
}
export async function runTopologyScopedTopLevelScenario(params: {
accessToken: string;
actorId: MatrixQaActorId;
actorUserId: string;
context: MatrixQaScenarioContext;
roomKey: string;
tokenPrefix: string;
withMention?: boolean;
}) {
const roomId = resolveMatrixQaScenarioRoomId(params.context, params.roomKey);
const result = await runTopLevelMentionScenario({
accessToken: params.accessToken,
actorId: params.actorId,
baseUrl: params.context.baseUrl,
observedEvents: params.context.observedEvents,
roomId,
syncState: params.context.syncState,
syncStreams: params.context.syncStreams,
sutUserId: params.context.sutUserId,
timeoutMs: params.context.timeoutMs,
tokenPrefix: params.tokenPrefix,
withMention: params.withMention,
});
assertTopLevelReplyArtifact(`reply in ${params.roomKey}`, result.reply);
return {
artifacts: {
actorUserId: params.actorUserId,
driverEventId: result.driverEventId,
reply: result.reply,
roomKey: params.roomKey,
token: result.token,
triggerBody: result.body,
},
details: [
`room key: ${params.roomKey}`,
`room id: ${roomId}`,
`driver event: ${result.driverEventId}`,
`trigger sender: ${params.actorUserId}`,
...buildMatrixReplyDetails("reply", result.reply),
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runNoReplyExpectedScenario(params: {
accessToken: string;
actorId: MatrixQaActorId;
actorUserId: string;
baseUrl: string;
body: string;
mentionUserIds?: string[];
observedEvents: MatrixQaObservedEvent[];
roomId: string;
sendClient?: MatrixQaScenarioClient;
syncState: MatrixQaSyncState;
syncStreams?: MatrixQaSyncStreams;
sutUserId: string;
replyPredicate?: (
event: MatrixQaObservedEvent,
match: { driverEventId: string; token: string },
) => boolean;
timeoutMs: number;
token: string;
}) {
const { client, startSince } = await primeMatrixQaActorCursor({
accessToken: params.accessToken,
actorId: params.actorId,
baseUrl: params.baseUrl,
observedEvents: params.observedEvents,
syncState: params.syncState,
syncStreams: params.syncStreams,
});
const sendClient = params.sendClient ?? client;
const triggerEventId = await sendClient.sendTextMessage({
body: params.body,
...(params.mentionUserIds ? { mentionUserIds: params.mentionUserIds } : {}),
roomId: params.roomId,
});
let observedTriggerEvent = false;
const result = await client.waitForOptionalRoomEvent({
observedEvents: params.observedEvents,
predicate: (event) => {
if (event.roomId !== params.roomId) {
return false;
}
if (event.eventId === triggerEventId) {
observedTriggerEvent = true;
return false;
}
return (
observedTriggerEvent &&
event.sender === params.sutUserId &&
event.type === "m.room.message" &&
(params.replyPredicate?.(event, { driverEventId: triggerEventId, token: params.token }) ??
true)
);
},
roomId: params.roomId,
since: startSince,
timeoutMs: params.timeoutMs,
});
if (result.matched) {
const unexpectedReply = buildMatrixReplyArtifact(result.event, params.token);
throw new Error(
[
`unexpected SUT reply from ${params.sutUserId}`,
`trigger sender: ${params.actorUserId}`,
...buildMatrixReplyDetails("unexpected reply", unexpectedReply),
].join("\n"),
);
}
advanceMatrixQaActorCursor({
actorId: params.actorId,
syncState: params.syncState,
nextSince: result.since,
startSince,
});
return {
artifacts: {
actorUserId: params.actorUserId,
driverEventId: triggerEventId,
expectedNoReplyWindowMs: params.timeoutMs,
token: params.token,
triggerBody: params.body,
},
details: [
`trigger event: ${triggerEventId}`,
`trigger sender: ${params.actorUserId}`,
`waited ${params.timeoutMs}ms with no SUT reply`,
].join("\n"),
} satisfies MatrixQaScenarioExecution;
}

View File

@@ -0,0 +1,69 @@
import { createHash } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
createPluginStateSyncKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { afterEach, describe, expect, it } from "vitest";
import type { MatrixQaScenarioContext } from "./scenario-runtime-shared.js";
import { waitForMatrixInboundDedupeEntry } from "./scenario-runtime-state-files.js";
const dedupeStoreRuntime = {
openMatrixInboundDedupeStoreOptions(params: { stateDir?: string }) {
return {
namespace: "inbound-dedupe",
maxEntries: 20_000,
env: { ...process.env, OPENCLAW_STATE_DIR: params.stateDir },
};
},
};
function buildDedupeKey(params: { accountId: string; eventId: string; roomId: string }) {
return `${params.accountId}:${createHash("sha256")
.update(params.accountId)
.update("\0")
.update(params.roomId)
.update("\0")
.update(params.eventId)
.digest("hex")}`;
}
describe("Matrix QA persisted state probes", () => {
const tempDirs: string[] = [];
afterEach(async () => {
resetPluginStateStoreForTests();
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })));
});
it("observes inbound dedupe entries through the canonical plugin-state store", async () => {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-dedupe-"));
tempDirs.push(stateDir);
const accountRoot = path.join(stateDir, "matrix", "accounts", "sut", "server", "token");
const accountId = "sut";
const eventId = "$event";
const roomId = "!room:matrix-qa.test";
const options = dedupeStoreRuntime.openMatrixInboundDedupeStoreOptions({
stateDir: accountRoot,
});
const runtimeAccountId = "runtime-default";
createPluginStateSyncKeyedStoreForTests("matrix", options).register(
buildDedupeKey({ accountId: runtimeAccountId, eventId, roomId }),
{ eventId, roomId, ts: Date.now() },
);
resetPluginStateStoreForTests();
await expect(
waitForMatrixInboundDedupeEntry({
context: { sutAccountId: accountId } as MatrixQaScenarioContext,
dedupeStoreRuntime,
eventId,
roomId,
stateDir,
timeoutMs: 1_000,
}),
).resolves.toBe(path.join(accountRoot, "state", "openclaw.sqlite"));
});
});

View File

@@ -0,0 +1,645 @@
// Qa Matrix plugin module implements scenario runtime state files behavior.
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { loadMatrixQaE2eeRuntime } from "../../substrate/e2ee-client.js";
import type { MatrixQaScenarioContext } from "./scenario-runtime-shared.js";
const MATRIX_SYNC_STORE_FILENAME = "bot-storage.json";
const MATRIX_INBOUND_DEDUPE_FILENAME = "inbound-dedupe.json";
const MATRIX_PLUGIN_ID = "matrix";
const MATRIX_SYNC_CACHE_NAMESPACE = "sync-cache";
const MATRIX_STATE_POLL_INTERVAL_MS = 100;
const MATRIX_SYNC_CACHE_MAX_ENTRIES = 20_000;
const MATRIX_SYNC_CACHE_MAX_CHUNKS = Math.floor((MATRIX_SYNC_CACHE_MAX_ENTRIES - 1) / 2);
// PluginState serializes this string inside a row object; 24KB leaves room for JSON escaping.
const MATRIX_SYNC_CACHE_CHUNK_BYTES = 24_000;
type MatrixQaInboundDedupeStoreRuntime = {
openMatrixInboundDedupeStoreOptions: (params: {
env?: NodeJS.ProcessEnv;
stateDir?: string;
}) => OpenKeyedStoreOptions;
};
type MatrixSyncStoreCursor = {
cursor: string;
pathname: string;
source: "json" | "sqlite";
stateKey?: string;
};
async function readJsonFile(pathname: string): Promise<unknown> {
return JSON.parse(await fs.readFile(pathname, "utf8")) as unknown;
}
async function writeJsonFile(pathname: string, value: unknown) {
await fs.writeFile(pathname, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
async function findFilesByName(params: {
filename: string;
rootDir: string;
maxDepth?: number;
}): Promise<string[]> {
const maxDepth = params.maxDepth ?? 8;
const matches: string[] = [];
async function visit(dir: string, depth: number): Promise<void> {
if (depth > maxDepth) {
return;
}
let entries: Array<{ isDirectory(): boolean; isFile(): boolean; name: string }>;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isFile() && entry.name === params.filename) {
matches.push(entryPath);
continue;
}
if (entry.isDirectory()) {
await visit(entryPath, depth + 1);
}
}
}
await visit(params.rootDir, 0);
return matches.toSorted();
}
function readPersistedMatrixSyncCursor(parsed: unknown): string | null {
if (!isRecord(parsed)) {
return null;
}
const savedSync = parsed.savedSync;
if (isRecord(savedSync) && typeof savedSync.nextBatch === "string") {
return savedSync.nextBatch;
}
if (typeof parsed.next_batch === "string") {
return parsed.next_batch;
}
return null;
}
function writePersistedMatrixSyncCursor(parsed: unknown, cursor: string): unknown {
if (!isRecord(parsed)) {
throw new Error("Matrix sync store was not a JSON object");
}
const savedSync = parsed.savedSync;
if (isRecord(savedSync) && typeof savedSync.nextBatch === "string") {
return {
...parsed,
savedSync: {
...savedSync,
nextBatch: cursor,
},
};
}
if (typeof parsed.nextBatch === "string") {
return {
...parsed,
nextBatch: cursor,
};
}
if (typeof parsed.next_batch === "string") {
return {
...parsed,
next_batch: cursor,
};
}
throw new Error("Matrix sync store did not contain a persisted sync cursor");
}
async function readMatrixSyncStoreCursor(pathname: string): Promise<string | null> {
return readPersistedMatrixSyncCursor(await readJsonFile(pathname));
}
function parsePluginStateJson(raw: unknown): unknown {
if (typeof raw !== "string") {
return undefined;
}
try {
return JSON.parse(raw) as unknown;
} catch {
return undefined;
}
}
function readMatrixSyncCacheCursorFromRows(
rows: Array<{ entryKey?: unknown; valueJson?: unknown }>,
): MatrixSyncStoreCursor[] {
const rowsByKey = new Map<string, unknown>();
for (const row of rows) {
if (typeof row.entryKey === "string") {
rowsByKey.set(row.entryKey, parsePluginStateJson(row.valueJson));
}
}
const cursors: MatrixSyncStoreCursor[] = [];
for (const [entryKey, rawMeta] of rowsByKey) {
if (!entryKey.endsWith(":meta") || !isRecord(rawMeta) || rawMeta.kind !== "meta") {
continue;
}
const stateKey = entryKey.slice(0, -":meta".length);
const generation = typeof rawMeta.generation === "string" ? rawMeta.generation : "";
const chunkCount =
typeof rawMeta.chunkCount === "number" &&
Number.isSafeInteger(rawMeta.chunkCount) &&
rawMeta.chunkCount <= MATRIX_SYNC_CACHE_MAX_CHUNKS
? rawMeta.chunkCount
: 0;
const chunks: string[] = [];
for (let index = 0; index < chunkCount; index += 1) {
const chunk = rowsByKey.get(`${stateKey}:sync:${generation}:${index}`);
if (!isRecord(chunk) || typeof chunk.data !== "string") {
chunks.length = 0;
break;
}
chunks.push(chunk.data);
}
if (chunks.length === 0) {
continue;
}
try {
const cursor = readPersistedMatrixSyncCursor({
savedSync: JSON.parse(chunks.join("")) as unknown,
});
if (cursor) {
cursors.push({ cursor, pathname: "", source: "sqlite", stateKey });
}
} catch {
continue;
}
}
return cursors;
}
async function readMatrixSyncCacheCursorsFromSqlite(params: {
accountId?: string;
context: MatrixQaScenarioContext;
stateDir: string;
userId?: string;
}): Promise<MatrixSyncStoreCursor[]> {
const databasePaths = await findFilesByName({
filename: "openclaw.sqlite",
rootDir: params.stateDir,
maxDepth: 10,
});
const cursors: Array<MatrixSyncStoreCursor & { score: number }> = [];
try {
const sqlite = await import("node:sqlite");
for (const databasePath of databasePaths) {
try {
const db = new sqlite.DatabaseSync(databasePath, { readOnly: true });
try {
const rows = db
.prepare(
`SELECT entry_key AS entryKey, value_json AS valueJson
FROM plugin_state_entries
WHERE plugin_id = ?
AND namespace = ?
AND (expires_at IS NULL OR expires_at > ?)`,
)
.all(MATRIX_PLUGIN_ID, MATRIX_SYNC_CACHE_NAMESPACE, Date.now()) as Array<{
entryKey?: unknown;
valueJson?: unknown;
}>;
for (const cursor of readMatrixSyncCacheCursorFromRows(rows)) {
const storageRootDir = path.dirname(path.dirname(databasePath));
cursors.push({
...cursor,
pathname: databasePath,
score: await scoreMatrixStateFile({
context: params.context,
pathname: path.join(storageRootDir, MATRIX_SYNC_STORE_FILENAME),
...(params.accountId ? { accountId: params.accountId } : {}),
...(params.userId ? { userId: params.userId } : {}),
}),
});
}
} finally {
db.close();
}
} catch {
continue;
}
}
} catch {
return [];
}
return cursors
.toSorted((a, b) => b.score - a.score || a.pathname.localeCompare(b.pathname))
.map(({ score: _score, ...cursor }) => cursor);
}
function chunkMatrixSyncCacheJson(value: string): string[] {
const chunks: string[] = [];
let current = "";
let currentBytes = 0;
for (const char of value) {
const charBytes = Buffer.byteLength(char, "utf8");
if (current && currentBytes + charBytes > MATRIX_SYNC_CACHE_CHUNK_BYTES) {
chunks.push(current);
current = "";
currentBytes = 0;
}
current += char;
currentBytes += charBytes;
}
if (current) {
chunks.push(current);
}
return chunks;
}
function digestText(value: string): string {
return createHash("sha256").update(value, "utf8").digest("hex");
}
async function rewriteMatrixSyncCacheRows(params: {
cursor: string;
pathname: string;
stateKey: string;
}) {
const sqlite = await import("node:sqlite");
const db = new sqlite.DatabaseSync(params.pathname);
try {
const rows = db
.prepare(
`SELECT entry_key AS entryKey, value_json AS valueJson
FROM plugin_state_entries
WHERE plugin_id = ?
AND namespace = ?
AND entry_key LIKE ?`,
)
.all(MATRIX_PLUGIN_ID, MATRIX_SYNC_CACHE_NAMESPACE, `${params.stateKey}:%`) as Array<{
entryKey?: unknown;
valueJson?: unknown;
}>;
const meta = parsePluginStateJson(
rows.find((row) => row.entryKey === `${params.stateKey}:meta`)?.valueJson,
);
if (!isRecord(meta)) {
throw new Error("Matrix sync cache metadata row was missing");
}
const cursorEntry = readMatrixSyncCacheCursorFromRows(rows)[0];
if (!cursorEntry) {
throw new Error("Matrix sync cache did not contain a persisted sync cursor");
}
const generation = typeof meta.generation === "string" ? meta.generation : "";
const chunkCount =
typeof meta.chunkCount === "number" &&
Number.isSafeInteger(meta.chunkCount) &&
meta.chunkCount <= MATRIX_SYNC_CACHE_MAX_CHUNKS
? meta.chunkCount
: 0;
const chunks: string[] = [];
for (let index = 0; index < chunkCount; index += 1) {
const chunk = parsePluginStateJson(
rows.find((row) => row.entryKey === `${params.stateKey}:sync:${generation}:${index}`)
?.valueJson,
);
if (!isRecord(chunk) || typeof chunk.data !== "string") {
throw new Error("Matrix sync cache chunk row was missing");
}
chunks.push(chunk.data);
}
const syncJson = JSON.stringify(
writePersistedMatrixSyncCursor(JSON.parse(chunks.join("")), params.cursor),
);
const nextGeneration = randomUUID().replaceAll("-", "");
const nextChunks = chunkMatrixSyncCacheJson(syncJson);
const now = Date.now();
const upsert = db.prepare(
`INSERT INTO plugin_state_entries (plugin_id, namespace, entry_key, value_json, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, NULL)
ON CONFLICT(plugin_id, namespace, entry_key)
DO UPDATE SET value_json = excluded.value_json, created_at = excluded.created_at, expires_at = NULL`,
);
for (const [index, data] of nextChunks.entries()) {
upsert.run(
MATRIX_PLUGIN_ID,
MATRIX_SYNC_CACHE_NAMESPACE,
`${params.stateKey}:sync:${nextGeneration}:${index}`,
JSON.stringify({ kind: "sync-chunk", index, data }),
now,
);
}
upsert.run(
MATRIX_PLUGIN_ID,
MATRIX_SYNC_CACHE_NAMESPACE,
`${params.stateKey}:meta`,
JSON.stringify({
...meta,
generation: nextGeneration,
chunkCount: nextChunks.length,
syncDigest: digestText(syncJson),
}),
now,
);
db.prepare(
`DELETE FROM plugin_state_entries
WHERE plugin_id = ?
AND namespace = ?
AND entry_key LIKE ?
AND entry_key NOT LIKE ?`,
).run(
MATRIX_PLUGIN_ID,
MATRIX_SYNC_CACHE_NAMESPACE,
`${params.stateKey}:sync:%`,
`${params.stateKey}:sync:${nextGeneration}:%`,
);
} finally {
db.close();
}
}
export async function rewriteMatrixSyncStoreCursor(params: {
cursor: string;
pathname: string;
source?: "json" | "sqlite";
stateKey?: string;
}) {
if (params.source === "sqlite" || params.stateKey) {
if (!params.stateKey) {
throw new Error("Matrix sync cache rewrite requires a state key");
}
await rewriteMatrixSyncCacheRows({
cursor: params.cursor,
pathname: params.pathname,
stateKey: params.stateKey,
});
return;
}
const parsed = await readJsonFile(params.pathname);
await writeJsonFile(params.pathname, writePersistedMatrixSyncCursor(parsed, params.cursor));
}
export async function deleteMatrixSyncStoreCursor(params: MatrixSyncStoreCursor) {
if (params.source !== "sqlite" || !params.stateKey) {
await fs.rm(params.pathname, { force: true });
return;
}
const sqlite = await import("node:sqlite");
const db = new sqlite.DatabaseSync(params.pathname);
try {
db.prepare(
`DELETE FROM plugin_state_entries
WHERE plugin_id = ?
AND namespace = ?
AND (entry_key = ? OR entry_key LIKE ?)`,
).run(
MATRIX_PLUGIN_ID,
MATRIX_SYNC_CACHE_NAMESPACE,
`${params.stateKey}:meta`,
`${params.stateKey}:sync:%`,
);
} finally {
db.close();
}
}
async function scoreMatrixStateFile(params: {
accountId?: string;
context: MatrixQaScenarioContext;
pathname: string;
userId?: string;
}) {
let score = params.pathname.includes(`${path.sep}matrix${path.sep}`) ? 4 : 0;
const expectedUserId = params.userId ?? params.context.sutUserId;
const expectedAccountId = params.accountId ?? params.context.sutAccountId;
try {
const metadata = await readJsonFile(
path.join(path.dirname(params.pathname), "storage-meta.json"),
);
if (isRecord(metadata) && metadata.userId === expectedUserId) {
score += 16;
}
if (isRecord(metadata) && metadata.accountId === expectedAccountId) {
score += 8;
}
} catch {
// Missing metadata is allowed; the Matrix client may not have flushed it yet.
}
return score;
}
async function resolveBestMatrixStateFile(params: {
accountId?: string;
context: MatrixQaScenarioContext;
filename: string;
stateDir: string;
userId?: string;
}) {
const candidates = await findFilesByName({
filename: params.filename,
rootDir: params.stateDir,
});
if (candidates.length === 0) {
return null;
}
const scored = await Promise.all(
candidates.map(async (pathname) => ({
pathname,
score: await scoreMatrixStateFile({
context: params.context,
pathname,
...(params.accountId ? { accountId: params.accountId } : {}),
...(params.userId ? { userId: params.userId } : {}),
}),
})),
);
scored.sort((a, b) => b.score - a.score || a.pathname.localeCompare(b.pathname));
return scored[0]?.pathname ?? null;
}
export async function waitForMatrixSyncStoreWithCursor(params: {
accountId?: string;
context: MatrixQaScenarioContext;
stateDir: string;
timeoutMs: number;
userId?: string;
}) {
const startedAt = Date.now();
let lastPath: string | null = null;
while (Date.now() - startedAt < params.timeoutMs) {
const sqliteCursors = await readMatrixSyncCacheCursorsFromSqlite({
context: params.context,
stateDir: params.stateDir,
...(params.accountId ? { accountId: params.accountId } : {}),
...(params.userId ? { userId: params.userId } : {}),
});
if (sqliteCursors.length > 0) {
return sqliteCursors[0];
}
const pathname = await resolveBestMatrixStateFile({
context: params.context,
filename: MATRIX_SYNC_STORE_FILENAME,
stateDir: params.stateDir,
...(params.accountId ? { accountId: params.accountId } : {}),
...(params.userId ? { userId: params.userId } : {}),
});
lastPath = pathname;
if (pathname) {
const cursor = await readMatrixSyncStoreCursor(pathname);
if (cursor) {
return { cursor, pathname, source: "json" as const };
}
}
await sleep(MATRIX_STATE_POLL_INTERVAL_MS);
}
throw new Error(
`timed out waiting for Matrix sync store cursor under ${params.stateDir}; last path ${lastPath ?? "<none>"}`,
);
}
function hasPersistedMatrixDedupeEntry(params: {
parsed: unknown;
roomId: string;
eventId: string;
}) {
if (!isRecord(params.parsed) || !Array.isArray(params.parsed.entries)) {
return false;
}
const expectedKey = `${params.roomId}|${params.eventId}`;
return params.parsed.entries.some((entry) => isRecord(entry) && entry.key === expectedKey);
}
function buildMatrixInboundDedupePluginStateKey(params: {
accountId: string;
eventId: string;
roomId: string;
}): string {
const accountId = params.accountId.trim() || "sut";
const roomId = params.roomId.trim();
const eventId = params.eventId.trim();
const digest = createHash("sha256")
.update(accountId)
.update("\0")
.update(roomId)
.update("\0")
.update(eventId)
.digest("hex");
return `${accountId}:${digest}`;
}
async function hasPersistedMatrixPluginStateDedupeEntry(params: {
accountId: string;
dedupeStoreRuntime?: MatrixQaInboundDedupeStoreRuntime;
eventId: string;
roomId: string;
stateDir: string;
}): Promise<string | null> {
const entryKey = buildMatrixInboundDedupePluginStateKey({
accountId: params.accountId,
eventId: params.eventId,
roomId: params.roomId,
});
const dedupeStoreRuntime = params.dedupeStoreRuntime ?? (await loadMatrixQaE2eeRuntime());
const databasePaths = await findFilesByName({
filename: "openclaw.sqlite",
rootDir: params.stateDir,
maxDepth: 10,
});
let sqlite: typeof import("node:sqlite");
try {
sqlite = await import("node:sqlite");
} catch {
return null;
}
for (const databasePath of databasePaths) {
try {
const storageRootDir = path.dirname(path.dirname(databasePath));
const options = dedupeStoreRuntime.openMatrixInboundDedupeStoreOptions({
stateDir: storageRootDir,
});
const stateRoot = options.env?.OPENCLAW_STATE_DIR?.trim();
if (
!stateRoot ||
path.resolve(stateRoot, "state", "openclaw.sqlite") !== path.resolve(databasePath)
) {
continue;
}
const db = new sqlite.DatabaseSync(databasePath, { readOnly: true });
try {
const rows = db
.prepare(
`SELECT entry_key AS entryKey, value_json AS valueJson
FROM plugin_state_entries
WHERE plugin_id = ?
AND namespace = ?
AND (expires_at IS NULL OR expires_at > ?)`,
)
.all(MATRIX_PLUGIN_ID, options.namespace, Date.now()) as Array<{
entryKey?: unknown;
valueJson?: unknown;
}>;
const matched = rows.some((row) => {
if (row.entryKey === entryKey) {
return true;
}
const entry = parsePluginStateJson(row.valueJson);
return (
isRecord(entry) && entry.roomId === params.roomId && entry.eventId === params.eventId
);
});
if (matched) {
return databasePath;
}
} finally {
db.close();
}
} catch {
continue;
}
}
return null;
}
export async function waitForMatrixInboundDedupeEntry(params: {
context: MatrixQaScenarioContext;
dedupeStoreRuntime?: MatrixQaInboundDedupeStoreRuntime;
eventId: string;
roomId: string;
stateDir: string;
timeoutMs: number;
}) {
const startedAt = Date.now();
while (Date.now() - startedAt < params.timeoutMs) {
const sqlitePath = await hasPersistedMatrixPluginStateDedupeEntry({
accountId: params.context.sutAccountId ?? "sut",
...(params.dedupeStoreRuntime ? { dedupeStoreRuntime: params.dedupeStoreRuntime } : {}),
eventId: params.eventId,
roomId: params.roomId,
stateDir: params.stateDir,
});
if (sqlitePath) {
return sqlitePath;
}
const pathname = await resolveBestMatrixStateFile({
context: params.context,
filename: MATRIX_INBOUND_DEDUPE_FILENAME,
stateDir: params.stateDir,
});
if (pathname) {
const parsed = await readJsonFile(pathname);
if (
hasPersistedMatrixDedupeEntry({
parsed,
roomId: params.roomId,
eventId: params.eventId,
})
) {
return pathname;
}
}
await sleep(MATRIX_STATE_POLL_INTERVAL_MS);
}
throw new Error(
`timed out waiting for Matrix inbound dedupe commit for ${params.roomId}|${params.eventId}`,
);
}

View File

@@ -0,0 +1,473 @@
// Qa Matrix plugin module implements scenario runtime behavior.
import {
MATRIX_QA_DRIVER_DM_ROOM_KEY,
MATRIX_QA_SECONDARY_ROOM_KEY,
type MatrixQaScenarioDefinition,
} from "./scenario-catalog.js";
import {
runAllowBotsDefaultBlockScenario,
runAllowBotsMentionsDmUnmentionedScenario,
runAllowBotsMentionsMentionedRoomScenario,
runAllowBotsMentionsUnmentionedOpenRoomBlockScenario,
runAllowBotsRoomOverrideBlocksAccountTrueScenario,
runAllowBotsRoomOverrideEnablesAccountOffScenario,
runAllowBotsSelfSenderIgnoredScenario,
runAllowBotsTrueUnmentionedOpenRoomScenario,
} from "./scenario-runtime-allowbots.js";
import {
runApprovalChannelTargetBothScenario,
runApprovalDenyReactionScenario,
runApprovalExecMetadataChunkedScenario,
runApprovalExecMetadataSingleEventScenario,
runApprovalPluginMetadataSingleEventScenario,
runApprovalThreadTargetScenario,
} from "./scenario-runtime-approval.js";
import {
runDmPerRoomSessionOverrideScenario,
runDmSharedSessionNoticeScenario,
runDmThreadReplyOverrideScenario,
} from "./scenario-runtime-dm.js";
import {
runMatrixQaE2eeCorruptCryptoIdbSnapshotScenario,
runMatrixQaE2eeHistoryExistsBackupEmptyScenario,
runMatrixQaE2eeServerBackupDeletedLocalStateIntactScenario,
runMatrixQaE2eeServerBackupDeletedLocalReuploadRestoresScenario,
runMatrixQaE2eeServerDeviceDeletedLocalStateIntactScenario,
runMatrixQaE2eeServerDeviceDeletedReloginRecoversScenario,
runMatrixQaE2eeStaleRecoveryKeyAfterBackupResetScenario,
runMatrixQaE2eeStateLossExternalRecoveryKeyScenario,
runMatrixQaE2eeStateLossNoRecoveryKeyScenario,
runMatrixQaE2eeStateLossStoredRecoveryKeyScenario,
runMatrixQaE2eeSyncStateLossCryptoIntactScenario,
runMatrixQaE2eeWrongAccountRecoveryKeyScenario,
} from "./scenario-runtime-e2ee-destructive.js";
import {
runMatrixQaE2eeArtifactRedactionScenario,
runMatrixQaE2eeBasicReplyScenario,
runMatrixQaE2eeBootstrapSuccessScenario,
runMatrixQaE2eeCliAccountAddEnableE2eeScenario,
runMatrixQaE2eeCliEncryptionSetupBootstrapFailureScenario,
runMatrixQaE2eeCliEncryptionSetupIdempotentScenario,
runMatrixQaE2eeCliEncryptionSetupMultiAccountScenario,
runMatrixQaE2eeCliEncryptionSetupScenario,
runMatrixQaE2eeCliRecoveryKeyInvalidScenario,
runMatrixQaE2eeCliRecoveryKeySetupScenario,
runMatrixQaE2eeCliSetupThenGatewayReplyScenario,
runMatrixQaE2eeCliSelfVerificationScenario,
runMatrixQaE2eeDeviceSasVerificationScenario,
runMatrixQaE2eeDmSasVerificationScenario,
runMatrixQaE2eeKeyBootstrapFailureScenario,
runMatrixQaE2eeMediaImageScenario,
runMatrixQaE2eeQrVerificationScenario,
runMatrixQaE2eeRecoveryKeyLifecycleScenario,
runMatrixQaE2eeRecoveryOwnerVerificationRequiredScenario,
runMatrixQaE2eeRestartResumeScenario,
runMatrixQaE2eeStateAfterMissingEncryptionScenario,
runMatrixQaE2eeStaleDeviceHygieneScenario,
runMatrixQaE2eeThreadFollowUpScenario,
runMatrixQaE2eeVerificationNoticeNoTriggerScenario,
} from "./scenario-runtime-e2ee.js";
import {
runInboundEditIgnoredScenario,
runInboundEditNoDuplicateTriggerScenario,
} from "./scenario-runtime-edit.js";
import {
runAttachmentOnlyIgnoredScenario,
runGeneratedImageDeliveryScenario,
runImageUnderstandingAttachmentScenario,
runMediaTypeCoverageScenario,
runUnsupportedMediaSafeScenario,
runVoicePreflightMentionScenario,
} from "./scenario-runtime-media.js";
import {
runReactionNotAReplyScenario,
runReactionNotificationScenario,
runReactionRedactionObservedScenario,
} from "./scenario-runtime-reaction.js";
import {
runHomeserverRestartResumeScenario,
runInitialCatchupThenIncrementalScenario,
runPostRestartRoomContinueScenario,
runRestartReplayDedupeScenario,
runRestartResumeScenario,
runStaleSyncReplayDedupeScenario,
} from "./scenario-runtime-restart.js";
import {
runAllowlistHotReloadScenario,
runBlockStreamingScenario,
runMatrixQaCanary,
runMembershipLossScenario,
runObserverAllowlistOverrideScenario,
runPartialStreamingPreviewScenario,
runQuietStreamingPreviewScenario,
runReactionThreadedScenario,
runRoomAutoJoinInviteScenario,
runRoomThreadReplyOverrideScenario,
runSubagentThreadSpawnScenario,
runThreadFollowUpScenario,
runThreadIsolationScenario,
runThreadNestedReplyShapeScenario,
runThreadRootPreservationScenario,
runToolProgressErrorScenario,
runToolProgressCommandPreviewScenario,
runToolProgressMentionSafetyScenario,
runToolProgressPreviewOptOutScenario,
runToolProgressPreviewScenario,
runTopLevelReplyShapeScenario,
} from "./scenario-runtime-room.js";
import {
buildExactMarkerPrompt,
buildMatrixQaToken,
buildMatrixReplyArtifact,
buildMatrixReplyDetails,
buildMentionPrompt,
readMatrixQaSyncCursor,
resolveMatrixQaNoReplyWindowMs,
runNoReplyExpectedScenario,
runTopologyScopedTopLevelScenario,
writeMatrixQaSyncCursor,
type MatrixQaScenarioContext,
} from "./scenario-runtime-shared.js";
import type { MatrixQaScenarioExecution } from "./scenario-types.js";
export {
buildMatrixReplyArtifact,
buildMatrixReplyDetails,
buildMentionPrompt,
readMatrixQaSyncCursor,
runMatrixQaCanary,
writeMatrixQaSyncCursor,
};
export type { MatrixQaScenarioContext };
async function runDriverTopologyScopedScenario(params: {
context: MatrixQaScenarioContext;
roomKey: string;
tokenPrefix: string;
withMention?: boolean;
}) {
return await runTopologyScopedTopLevelScenario({
accessToken: params.context.driverAccessToken,
actorId: "driver",
actorUserId: params.context.driverUserId,
context: params.context,
roomKey: params.roomKey,
tokenPrefix: params.tokenPrefix,
...(params.withMention === undefined ? {} : { withMention: params.withMention }),
});
}
async function runNoReplyScenario(params: {
accessToken: string;
actorId: "driver" | "observer";
actorUserId: string;
body: string;
context: MatrixQaScenarioContext;
mentionUserIds?: string[];
timeoutMs?: number;
token: string;
}) {
const timeoutMs = params.timeoutMs ?? params.context.timeoutMs;
return await runNoReplyExpectedScenario({
accessToken: params.accessToken,
actorId: params.actorId,
actorUserId: params.actorUserId,
baseUrl: params.context.baseUrl,
body: params.body,
...(params.mentionUserIds ? { mentionUserIds: params.mentionUserIds } : {}),
observedEvents: params.context.observedEvents,
roomId: params.context.roomId,
syncState: params.context.syncState,
syncStreams: params.context.syncStreams,
sutUserId: params.context.sutUserId,
timeoutMs,
token: params.token,
});
}
async function runMultiActorOrderingScenario(context: MatrixQaScenarioContext) {
const blockedToken = buildMatrixQaToken("MATRIX_QA_MULTI_BLOCKED");
const blocked = await runNoReplyScenario({
accessToken: context.observerAccessToken,
actorId: "observer",
actorUserId: context.observerUserId,
body: buildMentionPrompt(context.sutUserId, blockedToken),
mentionUserIds: [context.sutUserId],
context,
timeoutMs: resolveMatrixQaNoReplyWindowMs(context.timeoutMs),
token: blockedToken,
});
const accepted = await runDriverTopologyScopedScenario({
context,
roomKey: context.topology.defaultRoomKey,
tokenPrefix: "MATRIX_QA_MULTI_DRIVER",
});
return {
artifacts: {
accepted: accepted.artifacts ?? {},
blocked: blocked.artifacts ?? {},
},
details: [blocked.details, accepted.details].join("\n"),
} satisfies MatrixQaScenarioExecution;
}
export async function runMatrixQaScenario(
scenario: MatrixQaScenarioDefinition,
context: MatrixQaScenarioContext,
): Promise<MatrixQaScenarioExecution> {
switch (scenario.id) {
case "matrix-thread-follow-up":
return await runThreadFollowUpScenario(context);
case "matrix-thread-root-preservation":
return await runThreadRootPreservationScenario(context);
case "matrix-thread-nested-reply-shape":
return await runThreadNestedReplyShapeScenario(context);
case "matrix-thread-isolation":
return await runThreadIsolationScenario(context);
case "matrix-subagent-thread-spawn":
return await runSubagentThreadSpawnScenario(context);
case "matrix-top-level-reply-shape":
return await runTopLevelReplyShapeScenario(context);
case "matrix-room-thread-reply-override":
return await runRoomThreadReplyOverrideScenario(context);
case "matrix-room-partial-streaming-preview":
return await runPartialStreamingPreviewScenario(context);
case "matrix-room-quiet-streaming-preview":
return await runQuietStreamingPreviewScenario(context);
case "matrix-room-tool-progress-preview":
return await runToolProgressPreviewScenario(context);
case "matrix-room-tool-progress-command-preview":
return await runToolProgressCommandPreviewScenario(context);
case "matrix-room-tool-progress-preview-opt-out":
return await runToolProgressPreviewOptOutScenario(context);
case "matrix-room-tool-progress-error":
return await runToolProgressErrorScenario(context);
case "matrix-room-tool-progress-mention-safety":
return await runToolProgressMentionSafetyScenario(context);
case "matrix-room-block-streaming":
return await runBlockStreamingScenario(context);
case "matrix-room-image-understanding-attachment":
return await runImageUnderstandingAttachmentScenario(context);
case "matrix-room-generated-image-delivery":
return await runGeneratedImageDeliveryScenario(context);
case "matrix-media-type-coverage":
return await runMediaTypeCoverageScenario(context);
case "matrix-voice-preflight-mention":
return await runVoicePreflightMentionScenario(context);
case "matrix-attachment-only-ignored":
return await runAttachmentOnlyIgnoredScenario(context);
case "matrix-unsupported-media-safe":
return await runUnsupportedMediaSafeScenario(context);
case "matrix-dm-reply-shape":
return await runDriverTopologyScopedScenario({
context,
roomKey: MATRIX_QA_DRIVER_DM_ROOM_KEY,
tokenPrefix: "MATRIX_QA_DM",
withMention: false,
});
case "matrix-dm-shared-session-notice":
return await runDmSharedSessionNoticeScenario(context);
case "matrix-dm-thread-reply-override":
return await runDmThreadReplyOverrideScenario(context);
case "matrix-dm-per-room-session-override":
return await runDmPerRoomSessionOverrideScenario(context);
case "matrix-room-autojoin-invite":
return await runRoomAutoJoinInviteScenario(context);
case "matrix-secondary-room-reply":
return await runDriverTopologyScopedScenario({
context,
roomKey: MATRIX_QA_SECONDARY_ROOM_KEY,
tokenPrefix: "MATRIX_QA_SECONDARY",
});
case "matrix-secondary-room-open-trigger":
return await runDriverTopologyScopedScenario({
context,
roomKey: MATRIX_QA_SECONDARY_ROOM_KEY,
tokenPrefix: "MATRIX_QA_SECONDARY_OPEN",
withMention: false,
});
case "matrix-reaction-notification":
return await runReactionNotificationScenario(context);
case "matrix-reaction-threaded":
return await runReactionThreadedScenario(context);
case "matrix-reaction-not-a-reply":
return await runReactionNotAReplyScenario(context);
case "matrix-reaction-redaction-observed":
return await runReactionRedactionObservedScenario(context);
case "matrix-approval-exec-metadata-single-event":
return await runApprovalExecMetadataSingleEventScenario(context);
case "matrix-approval-exec-metadata-chunked":
return await runApprovalExecMetadataChunkedScenario(context);
case "matrix-approval-plugin-metadata-single-event":
return await runApprovalPluginMetadataSingleEventScenario(context);
case "matrix-approval-deny-reaction":
return await runApprovalDenyReactionScenario(context);
case "matrix-approval-thread-target":
return await runApprovalThreadTargetScenario(context);
case "matrix-approval-channel-target-both":
return await runApprovalChannelTargetBothScenario(context);
case "matrix-restart-resume":
return await runRestartResumeScenario(context);
case "matrix-post-restart-room-continue":
return await runPostRestartRoomContinueScenario(context);
case "matrix-initial-catchup-then-incremental":
return await runInitialCatchupThenIncrementalScenario(context);
case "matrix-restart-replay-dedupe":
return await runRestartReplayDedupeScenario(context);
case "matrix-stale-sync-replay-dedupe":
return await runStaleSyncReplayDedupeScenario(context);
case "matrix-room-membership-loss":
return await runMembershipLossScenario(context);
case "matrix-homeserver-restart-resume":
return await runHomeserverRestartResumeScenario(context);
case "matrix-mention-gating": {
const token = buildMatrixQaToken("MATRIX_QA_NOMENTION");
return await runNoReplyScenario({
accessToken: context.driverAccessToken,
actorId: "driver",
actorUserId: context.driverUserId,
body: buildExactMarkerPrompt(token),
context,
token,
});
}
case "matrix-allowbots-default-block":
return await runAllowBotsDefaultBlockScenario(context);
case "matrix-allowbots-true-unmentioned-open-room":
return await runAllowBotsTrueUnmentionedOpenRoomScenario(context);
case "matrix-allowbots-mentions-mentioned-room":
return await runAllowBotsMentionsMentionedRoomScenario(context);
case "matrix-allowbots-mentions-unmentioned-open-room-block":
return await runAllowBotsMentionsUnmentionedOpenRoomBlockScenario(context);
case "matrix-allowbots-mentions-dm-unmentioned":
return await runAllowBotsMentionsDmUnmentionedScenario(context);
case "matrix-allowbots-room-override-blocks-account-true":
return await runAllowBotsRoomOverrideBlocksAccountTrueScenario(context);
case "matrix-allowbots-room-override-enables-account-off":
return await runAllowBotsRoomOverrideEnablesAccountOffScenario(context);
case "matrix-allowbots-self-sender-ignored":
return await runAllowBotsSelfSenderIgnoredScenario(context);
case "matrix-mxid-prefixed-command-block": {
const token = buildMatrixQaToken("MATRIX_QA_MXID_COMMAND");
return await runNoReplyScenario({
accessToken: context.observerAccessToken,
actorId: "observer",
actorUserId: context.observerUserId,
body: `${context.sutUserId} /new`,
mentionUserIds: [context.sutUserId],
context,
token,
});
}
case "matrix-mention-metadata-spoof-block": {
const token = buildMatrixQaToken("MATRIX_QA_METADATA_SPOOF");
return await runNoReplyScenario({
accessToken: context.driverAccessToken,
actorId: "driver",
actorUserId: context.driverUserId,
body: buildExactMarkerPrompt(token),
mentionUserIds: [context.sutUserId],
context,
token,
});
}
case "matrix-observer-allowlist-override":
return await runObserverAllowlistOverrideScenario(context);
case "matrix-allowlist-block": {
const token = buildMatrixQaToken("MATRIX_QA_ALLOWLIST");
return await runNoReplyScenario({
accessToken: context.observerAccessToken,
actorId: "observer",
actorUserId: context.observerUserId,
body: buildMentionPrompt(context.sutUserId, token),
mentionUserIds: [context.sutUserId],
context,
token,
});
}
case "matrix-allowlist-hot-reload":
return await runAllowlistHotReloadScenario(context);
case "matrix-multi-actor-ordering":
return await runMultiActorOrderingScenario(context);
case "matrix-inbound-edit-ignored":
return await runInboundEditIgnoredScenario(context);
case "matrix-inbound-edit-no-duplicate-trigger":
return await runInboundEditNoDuplicateTriggerScenario(context);
case "matrix-e2ee-basic-reply":
return await runMatrixQaE2eeBasicReplyScenario(context);
case "matrix-e2ee-state-after-missing-encryption":
return await runMatrixQaE2eeStateAfterMissingEncryptionScenario(context);
case "matrix-e2ee-thread-follow-up":
return await runMatrixQaE2eeThreadFollowUpScenario(context);
case "matrix-e2ee-bootstrap-success":
return await runMatrixQaE2eeBootstrapSuccessScenario(context);
case "matrix-e2ee-recovery-key-lifecycle":
return await runMatrixQaE2eeRecoveryKeyLifecycleScenario(context);
case "matrix-e2ee-recovery-owner-verification-required":
return await runMatrixQaE2eeRecoveryOwnerVerificationRequiredScenario(context);
case "matrix-e2ee-cli-account-add-enable-e2ee":
return await runMatrixQaE2eeCliAccountAddEnableE2eeScenario(context);
case "matrix-e2ee-cli-encryption-setup":
return await runMatrixQaE2eeCliEncryptionSetupScenario(context);
case "matrix-e2ee-cli-encryption-setup-idempotent":
return await runMatrixQaE2eeCliEncryptionSetupIdempotentScenario(context);
case "matrix-e2ee-cli-encryption-setup-bootstrap-failure":
return await runMatrixQaE2eeCliEncryptionSetupBootstrapFailureScenario(context);
case "matrix-e2ee-cli-recovery-key-setup":
return await runMatrixQaE2eeCliRecoveryKeySetupScenario(context);
case "matrix-e2ee-cli-recovery-key-invalid":
return await runMatrixQaE2eeCliRecoveryKeyInvalidScenario(context);
case "matrix-e2ee-cli-encryption-setup-multi-account":
return await runMatrixQaE2eeCliEncryptionSetupMultiAccountScenario(context);
case "matrix-e2ee-cli-setup-then-gateway-reply":
return await runMatrixQaE2eeCliSetupThenGatewayReplyScenario(context);
case "matrix-e2ee-cli-self-verification":
return await runMatrixQaE2eeCliSelfVerificationScenario(context);
case "matrix-e2ee-state-loss-external-recovery-key":
return await runMatrixQaE2eeStateLossExternalRecoveryKeyScenario(context);
case "matrix-e2ee-state-loss-stored-recovery-key":
return await runMatrixQaE2eeStateLossStoredRecoveryKeyScenario(context);
case "matrix-e2ee-state-loss-no-recovery-key":
return await runMatrixQaE2eeStateLossNoRecoveryKeyScenario(context);
case "matrix-e2ee-stale-recovery-key-after-backup-reset":
return await runMatrixQaE2eeStaleRecoveryKeyAfterBackupResetScenario(context);
case "matrix-e2ee-server-backup-deleted-local-state-intact":
return await runMatrixQaE2eeServerBackupDeletedLocalStateIntactScenario(context);
case "matrix-e2ee-server-backup-deleted-local-reupload-restores":
return await runMatrixQaE2eeServerBackupDeletedLocalReuploadRestoresScenario(context);
case "matrix-e2ee-corrupt-crypto-idb-snapshot":
return await runMatrixQaE2eeCorruptCryptoIdbSnapshotScenario(context);
case "matrix-e2ee-server-device-deleted-local-state-intact":
return await runMatrixQaE2eeServerDeviceDeletedLocalStateIntactScenario(context);
case "matrix-e2ee-server-device-deleted-relogin-recovers":
return await runMatrixQaE2eeServerDeviceDeletedReloginRecoversScenario(context);
case "matrix-e2ee-sync-state-loss-crypto-intact":
return await runMatrixQaE2eeSyncStateLossCryptoIntactScenario(context);
case "matrix-e2ee-wrong-account-recovery-key":
return await runMatrixQaE2eeWrongAccountRecoveryKeyScenario(context);
case "matrix-e2ee-history-exists-backup-empty":
return await runMatrixQaE2eeHistoryExistsBackupEmptyScenario(context);
case "matrix-e2ee-device-sas-verification":
return await runMatrixQaE2eeDeviceSasVerificationScenario(context);
case "matrix-e2ee-qr-verification":
return await runMatrixQaE2eeQrVerificationScenario(context);
case "matrix-e2ee-stale-device-hygiene":
return await runMatrixQaE2eeStaleDeviceHygieneScenario(context);
case "matrix-e2ee-dm-sas-verification":
return await runMatrixQaE2eeDmSasVerificationScenario(context);
case "matrix-e2ee-restart-resume":
return await runMatrixQaE2eeRestartResumeScenario(context);
case "matrix-e2ee-verification-notice-no-trigger":
return await runMatrixQaE2eeVerificationNoticeNoTriggerScenario(context);
case "matrix-e2ee-artifact-redaction":
return await runMatrixQaE2eeArtifactRedactionScenario(context);
case "matrix-e2ee-media-image":
return await runMatrixQaE2eeMediaImageScenario(context);
case "matrix-e2ee-key-bootstrap-failure":
return await runMatrixQaE2eeKeyBootstrapFailureScenario(context);
default: {
const exhaustiveScenarioId: never = scenario.id;
return exhaustiveScenarioId;
}
}
}

View File

@@ -0,0 +1,186 @@
// Qa Matrix plugin module implements scenario types behavior.
import type { MatrixQaObservedEvent } from "../../substrate/events.js";
export type MatrixQaReplyArtifact = {
bodyPreview?: string;
eventId: string;
mentions?: MatrixQaObservedEvent["mentions"];
relatesTo?: MatrixQaObservedEvent["relatesTo"];
sender?: string;
tokenMatched?: boolean;
};
export type MatrixQaCanaryArtifact = {
driverEventId: string;
reply: MatrixQaReplyArtifact;
token: string;
};
export type MatrixQaScenarioArtifacts = {
accepted?: MatrixQaScenarioArtifacts;
approval?: MatrixQaObservedEvent["approval"] & {
eventId: string;
roomId: string;
};
approvals?: Array<
MatrixQaObservedEvent["approval"] & {
eventId: string;
roomId: string;
}
>;
attachments?: Array<{
eventId: string;
filename?: string;
kind?: string;
label: string;
msgtype?: string;
}>;
attachmentCaptionPreview?: string;
attachmentBodyPreview?: string;
attachmentEventId?: string;
attachmentFilename?: string;
attachmentKind?: string;
attachmentMsgtype?: string;
accountId?: string;
actorUserId?: string;
blocked?: MatrixQaScenarioArtifacts;
catchupDriverEventId?: string;
catchupReply?: MatrixQaReplyArtifact;
catchupToken?: string;
dedupeCommitObserved?: boolean;
duplicateWindowMs?: number;
driverEventId?: string;
driverEventIds?: string[];
driverUserId?: string;
editEventId?: string;
editedToken?: string;
expectedNoReplyWindowMs?: number;
expectedMarker?: string;
firstDriverEventId?: string;
firstReply?: MatrixQaReplyArtifact;
firstToken?: string;
freshDriverEventId?: string;
freshReply?: MatrixQaReplyArtifact;
freshToken?: string;
incrementalDriverEventId?: string;
incrementalReply?: MatrixQaReplyArtifact;
incrementalToken?: string;
originalDriverEventId?: string;
originalReply?: MatrixQaReplyArtifact;
originalToken?: string;
reactionEmoji?: string;
reactionEventId?: string;
reactionTargetEventId?: string;
redactionEventId?: string;
reply?: MatrixQaReplyArtifact;
replies?: Array<{
eventId: string;
label: string;
token: string;
tokenMatched?: boolean;
}>;
recoveredDriverEventId?: string;
recoveredReply?: MatrixQaReplyArtifact;
rootEventId?: string;
roomKey?: string;
roomId?: string;
restartSignal?: string;
secondDriverEventId?: string;
secondReply?: MatrixQaReplyArtifact;
secondToken?: string;
staleSyncCursor?: string;
subagentCompletion?: MatrixQaReplyArtifact;
subagentIntro?: MatrixQaReplyArtifact;
threadDriverEventId?: string;
threadReply?: MatrixQaReplyArtifact;
threadRootEventId?: string;
threadToken?: string;
token?: string;
topLevelDriverEventId?: string;
topLevelReply?: MatrixQaReplyArtifact;
topLevelToken?: string;
triggerBody?: string;
membershipJoinEventId?: string;
membershipLeaveEventId?: string;
noticeBodyPreview?: string;
noticeEventId?: string;
previewBodyPreview?: string;
previewEventId?: string;
previewFormattedBodyPreview?: string;
previewMentions?: MatrixQaObservedEvent["mentions"];
blockEventIds?: string[];
bootstrapActor?: "driver" | "observer" | "sut";
bootstrapErrorPreview?: string;
bootstrapSuccess?: boolean;
backupCreatedVersion?: string | null;
backupDeletedHttpStatus?: number;
backupPreviousVersion?: string | null;
backupRestored?: boolean;
backupReset?: boolean;
completedVerificationId?: string;
backupVersion?: string | null;
cliDeviceId?: string | null;
completedVerificationIds?: string[];
currentDeviceId?: string | null;
accountRoot?: string;
corruptedPath?: string;
deletedSyncStorePath?: string;
deletedDeviceIds?: string[];
deletedDeviceId?: string;
deletedBackupVersion?: string | null;
faultedEndpoint?: string;
faultHitCount?: number;
faultProxyBaseUrl?: string;
faultRuleId?: string;
historyEventId?: string;
observerRecoveryDeviceId?: string;
qrBytes?: number;
recoveryDeviceId?: string;
recoveryKeyPreserved?: boolean;
decoyAccountPreserved?: boolean;
defaultAccountPreserved?: boolean;
recoveryKeyAccepted?: boolean;
recoveryKeyId?: string | null;
recoveryKeyRejected?: boolean;
recoveryKeyStored?: boolean;
rotatedRecoveryKeyId?: string | null;
remainingDeviceIds?: string[];
restoreError?: string;
restoreErrorAfterDelete?: string;
restoreExitCode?: number;
restoreImported?: number;
restoreTotal?: number;
sasEmoji?: string[];
secondaryDeviceId?: string;
seededEventId?: string;
replyEventId?: string;
statusError?: string;
statusExitCode?: number;
defaultStatusError?: string;
defaultStatusExitCode?: number;
serverDeviceKnown?: boolean | null;
replacementDeviceId?: string;
selfVerificationTransactionId?: string | null;
transportInterruption?: string;
encryptionChanged?: boolean;
encryptionEnabled?: boolean;
firstEncryptionChanged?: boolean;
gatewayUserId?: string;
secondEncryptionChanged?: boolean;
setupSuccess?: boolean;
stateAfterFaultHitCount?: number;
stateAfterFaultRuleId?: string;
strippedSyncStateAfterParam?: boolean;
verificationBootstrapAttempted?: boolean;
verificationBootstrapSuccess?: boolean;
gatewayReply?: MatrixQaReplyArtifact;
verificationRoomId?: string;
joinedRoomId?: string;
localEventId?: string;
verificationExitCode?: number;
};
export type MatrixQaScenarioExecution = {
artifacts?: MatrixQaScenarioArtifacts;
details: string;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
// Qa Matrix plugin module implements scenarios behavior.
import {
MATRIX_QA_DRIVER_DM_ROOM_KEY,
MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
MATRIX_QA_MEDIA_ROOM_KEY,
MATRIX_QA_SCENARIOS,
MATRIX_QA_SECONDARY_ROOM_KEY,
MATRIX_QA_STANDARD_SCENARIO_IDS,
buildMatrixQaE2eeScenarioRoomKey,
buildMatrixQaTopologyForScenarios,
findMatrixQaScenarios,
resolveMatrixQaScenarioRoomId,
matrixQaProfileTesting,
} from "./scenario-catalog.js";
import {
buildMatrixReplyArtifact,
buildMatrixReplyDetails,
buildMentionPrompt,
runMatrixQaCanary,
runMatrixQaScenario,
type MatrixQaScenarioContext,
} from "./scenario-runtime.js";
import type { MatrixQaCanaryArtifact, MatrixQaScenarioArtifacts } from "./scenario-types.js";
export {
MATRIX_QA_SCENARIOS,
buildMatrixReplyDetails,
buildMatrixQaTopologyForScenarios,
findMatrixQaScenarios,
runMatrixQaCanary,
runMatrixQaScenario,
};
export type { MatrixQaCanaryArtifact, MatrixQaScenarioArtifacts };
export type { MatrixQaScenarioContext };
export const testing = {
MATRIX_QA_DRIVER_DM_ROOM_KEY,
MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
MATRIX_QA_MEDIA_ROOM_KEY,
MATRIX_QA_SECONDARY_ROOM_KEY,
MATRIX_QA_STANDARD_SCENARIO_IDS,
buildMatrixQaE2eeScenarioRoomKey,
buildMatrixQaTopologyForScenarios,
buildMatrixReplyDetails,
buildMatrixReplyArtifact,
buildMentionPrompt,
findMatrixQaScenarios,
getMatrixQaProfileScenarioIds: matrixQaProfileTesting.getMatrixQaProfileScenarioIds,
normalizeMatrixQaProfile: matrixQaProfileTesting.normalizeMatrixQaProfile,
resolveMatrixQaScenarioRoomId,
};
export { testing as __testing };

View File

@@ -0,0 +1,10 @@
// Qa Matrix tests cover runtime api plugin behavior.
import { describe, expect, it } from "vitest";
describe("matrix qa runtime api surface", () => {
it("keeps runner discovery lightweight", async () => {
const runtimeApi = await import("../runtime-api.js");
expect(Object.keys(runtimeApi).toSorted()).toEqual(["qaRunnerCliRegistrations"]);
});
});

View File

@@ -0,0 +1,6 @@
// Qa Matrix plugin module implements live transport artifact behavior.
import { randomUUID } from "node:crypto";
export function createLiveTransportQaRunId() {
return `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
}

View File

@@ -0,0 +1,93 @@
// Qa Matrix tests cover live transport cli plugin behavior.
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { startLiveTransportQaOutputTee } from "openclaw/plugin-sdk/qa-runtime";
import { afterEach, describe, expect, it } from "vitest";
import { resolveLiveTransportQaRunOptions } from "./live-transport-cli.runtime.js";
const tmpDirs: string[] = [];
describe("live transport CLI runtime", () => {
afterEach(async () => {
await Promise.all(tmpDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
it("uses unique default output dirs for CLI runs", () => {
const repoRoot = "/repo";
const firstOutputDir = resolveLiveTransportQaRunOptions({ repoRoot }).outputDir;
const secondOutputDir = resolveLiveTransportQaRunOptions({ repoRoot }).outputDir;
expect(path.dirname(firstOutputDir)).toBe(path.join(repoRoot, ".artifacts", "qa-e2e"));
expect(path.basename(firstOutputDir)).toMatch(/^matrix-[a-z0-9]+-[a-f0-9]{8}$/u);
expect(secondOutputDir).not.toBe(firstOutputDir);
expect(
resolveLiveTransportQaRunOptions({
repoRoot,
outputDir: ".artifacts/custom",
}).outputDir,
).toBe(path.join(repoRoot, ".artifacts/custom"));
});
it("tees stdout and stderr into an output artifact", async () => {
const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-output-"));
tmpDirs.push(outputDir);
const originalStdoutWrite = process.stdout["write"];
const originalStderrWrite = process.stderr["write"];
process.stdout.write = (() => true) as typeof process.stdout.write;
process.stderr.write = (() => true) as typeof process.stderr.write;
const tee = await startLiveTransportQaOutputTee({
fileName: "matrix-qa-output.log",
outputDir,
});
try {
process.stdout.write("stdout marker\n");
process.stderr.write("stderr marker\n");
await tee.stop();
} finally {
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
}
expect(process.stdout["write"]).toBe(originalStdoutWrite);
expect(process.stderr["write"]).toBe(originalStderrWrite);
await expect(readFile(tee.outputPath, "utf8")).resolves.toContain("stdout marker\n");
await expect(readFile(tee.outputPath, "utf8")).resolves.toContain("stderr marker\n");
});
it("surfaces output artifact stream errors after restoring process writes", async () => {
const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-output-"));
tmpDirs.push(outputDir);
await rm(path.join(outputDir, "matrix-qa-output.log"), { recursive: true, force: true });
await mkdir(path.join(outputDir, "matrix-qa-output.log"), { recursive: true });
const originalStdoutWrite = process.stdout["write"];
const originalStderrWrite = process.stderr["write"];
const mutedStdoutWrite = (() => true) as typeof process.stdout.write;
const mutedStderrWrite = (() => true) as typeof process.stderr.write;
process.stdout.write = mutedStdoutWrite;
process.stderr.write = mutedStderrWrite;
try {
const tee = await startLiveTransportQaOutputTee({
fileName: "matrix-qa-output.log",
outputDir,
});
process.stdout.write("stdout marker\n");
let stopError: unknown;
try {
await tee.stop();
} catch (caught) {
stopError = caught;
}
expect(stopError).toBeInstanceOf(Error);
expect((stopError as NodeJS.ErrnoException).code).toBe("EISDIR");
expect(process.stdout["write"]).toBe(mutedStdoutWrite);
expect(process.stderr["write"]).toBe(mutedStderrWrite);
} finally {
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
}
});
});

View File

@@ -0,0 +1,37 @@
// Qa Matrix plugin module implements live transport cli behavior.
import path from "node:path";
import { resolveRepoRelativeOutputDir } from "../cli-paths.js";
import type { QaProviderMode } from "../run-config.js";
import { normalizeQaProviderMode } from "../run-config.js";
import { createLiveTransportQaRunId } from "./live-transport-artifacts.js";
import type { LiveTransportQaCommandOptions } from "./live-transport-cli.js";
export function resolveLiveTransportQaRunOptions(
opts: LiveTransportQaCommandOptions,
): LiveTransportQaCommandOptions & {
outputDir: string;
repoRoot: string;
providerMode: QaProviderMode;
} {
const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
const outputDir =
resolveRepoRelativeOutputDir(repoRoot, opts.outputDir) ??
path.join(repoRoot, ".artifacts", "qa-e2e", `matrix-${createLiveTransportQaRunId()}`);
return {
repoRoot,
outputDir,
providerMode:
opts.providerMode === undefined
? "live-frontier"
: normalizeQaProviderMode(opts.providerMode),
primaryModel: opts.primaryModel,
alternateModel: opts.alternateModel,
fastMode: opts.fastMode,
failFast: opts.failFast,
profile: opts.profile?.trim(),
scenarioIds: opts.scenarioIds,
sutAccountId: opts.sutAccountId,
credentialSource: opts.credentialSource?.trim(),
credentialRole: opts.credentialRole?.trim(),
};
}

View File

@@ -0,0 +1,27 @@
// Qa Matrix plugin module implements live transport cli behavior.
import {
createLiveTransportQaCliRegistration as createSharedLiveTransportQaCliRegistration,
type LiveTransportQaCliRegistrationOptions,
} from "openclaw/plugin-sdk/qa-runtime";
export {
createLazyCliRuntimeLoader,
type LiveTransportQaCliRegistration,
type LiveTransportQaCommandOptions,
} from "openclaw/plugin-sdk/qa-runtime";
type MatrixLiveTransportQaCliRegistrationOptions = Omit<
LiveTransportQaCliRegistrationOptions,
"defaultProviderMode" | "providerModeHelp"
>;
export function createLiveTransportQaCliRegistration(
params: MatrixLiveTransportQaCliRegistrationOptions,
) {
return createSharedLiveTransportQaCliRegistration({
...params,
defaultProviderMode: "live-frontier",
providerModeHelp:
"Provider mode: mock-openai or live-frontier (legacy live-openai still works)",
});
}

View File

@@ -0,0 +1,9 @@
// Qa Matrix plugin module implements live transport scenarios behavior.
export {
LIVE_TRANSPORT_BASELINE_STANDARD_SCENARIO_IDS,
collectLiveTransportStandardScenarioCoverage,
findMissingLiveTransportStandardScenarios,
selectLiveTransportScenarios,
type LiveTransportScenarioDefinition,
type LiveTransportStandardScenarioId,
} from "openclaw/plugin-sdk/qa-live-transport-scenarios";

View File

@@ -0,0 +1,209 @@
// Qa Matrix tests cover artifacts plugin behavior.
import { describe, expect, it } from "vitest";
import { buildMatrixQaObservedEventsArtifact } from "./artifacts.js";
describe("matrix observed event artifacts", () => {
it("redacts Matrix observed event content by default in artifacts", () => {
expect(
buildMatrixQaObservedEventsArtifact({
includeContent: false,
observedEvents: [
{
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$event",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
body: "secret",
formattedBody: "<p>secret</p>",
msgtype: "m.image",
originServerTs: 1_700_000_000_000,
attachment: {
kind: "image",
caption: "secret",
filename: "qa-lighthouse.png",
},
relatesTo: {
relType: "m.thread",
eventId: "$root",
inReplyToId: "$driver",
isFallingBack: true,
},
},
],
}),
).toEqual([
{
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$event",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
msgtype: "m.image",
originServerTs: 1_700_000_000_000,
attachment: {
kind: "image",
filename: "qa-lighthouse.png",
},
relatesTo: {
relType: "m.thread",
eventId: "$root",
inReplyToId: "$driver",
isFallingBack: true,
},
},
]);
});
it("keeps reaction metadata in redacted Matrix observed-event artifacts", () => {
expect(
buildMatrixQaObservedEventsArtifact({
includeContent: false,
observedEvents: [
{
kind: "reaction",
roomId: "!room:matrix-qa.test",
eventId: "$reaction",
sender: "@driver:matrix-qa.test",
type: "m.reaction",
reaction: {
eventId: "$reply",
key: "👍",
},
relatesTo: {
relType: "m.annotation",
eventId: "$reply",
},
},
],
}),
).toEqual([
{
kind: "reaction",
roomId: "!room:matrix-qa.test",
eventId: "$reaction",
sender: "@driver:matrix-qa.test",
type: "m.reaction",
originServerTs: undefined,
msgtype: undefined,
membership: undefined,
relatesTo: {
relType: "m.annotation",
eventId: "$reply",
},
mentions: undefined,
reaction: {
eventId: "$reply",
key: "👍",
},
},
]);
});
it("keeps approval summaries in redacted Matrix observed-event artifacts", () => {
expect(
buildMatrixQaObservedEventsArtifact({
includeContent: false,
observedEvents: [
{
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$approval",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
body: "secret command body",
approval: {
id: "approval-1",
kind: "exec",
state: "pending",
type: "approval.request",
version: 1,
allowedDecisions: ["allow-once", "deny"],
hasCommandText: true,
commandTextPreview: "printf MATRIX_QA",
},
},
],
}),
).toEqual([
{
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$approval",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
originServerTs: undefined,
msgtype: undefined,
membership: undefined,
relatesTo: undefined,
mentions: undefined,
reaction: undefined,
approval: {
id: "approval-1",
kind: "exec",
state: "pending",
type: "approval.request",
version: 1,
allowedDecisions: ["allow-once", "deny"],
hasCommandText: true,
commandTextPreview: "printf MATRIX_QA",
},
},
]);
});
it("keeps redaction metadata while still stripping Matrix event content", () => {
expect(
buildMatrixQaObservedEventsArtifact({
includeContent: false,
observedEvents: [
{
kind: "redaction",
roomId: "!room:matrix-qa.test",
eventId: "$redaction",
sender: "@driver:matrix-qa.test",
type: "m.room.redaction",
originServerTs: 1_700_000_000_123,
},
{
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$message",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
body: "private body",
formattedBody: "<p>private body</p>",
msgtype: "m.text",
},
],
}),
).toEqual([
{
kind: "redaction",
roomId: "!room:matrix-qa.test",
eventId: "$redaction",
sender: "@driver:matrix-qa.test",
type: "m.room.redaction",
originServerTs: 1_700_000_000_123,
msgtype: undefined,
membership: undefined,
relatesTo: undefined,
mentions: undefined,
reaction: undefined,
},
{
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$message",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
originServerTs: undefined,
msgtype: "m.text",
membership: undefined,
relatesTo: undefined,
mentions: undefined,
reaction: undefined,
},
]);
});
});

View File

@@ -0,0 +1,33 @@
// Qa Matrix plugin module implements artifacts behavior.
import type { MatrixQaObservedEvent } from "./events.js";
export function buildMatrixQaObservedEventsArtifact(params: {
includeContent: boolean;
observedEvents: MatrixQaObservedEvent[];
}) {
return params.observedEvents.map((event) =>
params.includeContent
? event
: {
kind: event.kind,
roomId: event.roomId,
eventId: event.eventId,
sender: event.sender,
stateKey: event.stateKey,
type: event.type,
originServerTs: event.originServerTs,
msgtype: event.msgtype,
membership: event.membership,
relatesTo: event.relatesTo,
mentions: event.mentions,
reaction: event.reaction,
...(event.approval ? { approval: event.approval } : {}),
attachment: event.attachment
? {
kind: event.attachment.kind,
...(event.attachment.filename ? { filename: event.attachment.filename } : {}),
}
: undefined,
},
);
}

View File

@@ -0,0 +1,737 @@
// Qa Matrix tests cover client plugin behavior.
import { describe, expect, it } from "vitest";
import { testing, createMatrixQaClient, provisionMatrixQaRoom } from "./client.js";
import { buildDefaultMatrixQaTopologySpec } from "./topology.js";
function resolveRequestUrl(input: RequestInfo | URL) {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.toString();
}
return input.url;
}
function parseJsonRequestBody(init?: RequestInit) {
if (typeof init?.body !== "string") {
return {};
}
return JSON.parse(init.body) as Record<string, unknown>;
}
describe("matrix driver client", () => {
it("builds Matrix HTML mentions for QA driver messages", () => {
expect(
testing.buildMatrixQaMessageContent({
body: "@sut:matrix-qa.test reply with exactly: TOKEN",
mentionUserIds: ["@sut:matrix-qa.test"],
}),
).toEqual({
body: "@sut:matrix-qa.test reply with exactly: TOKEN",
msgtype: "m.text",
format: "org.matrix.custom.html",
formatted_body:
'<a href="https://matrix.to/#/%40sut%3Amatrix-qa.test">@sut:matrix-qa.test</a> reply with exactly: TOKEN',
"m.mentions": {
user_ids: ["@sut:matrix-qa.test"],
},
});
});
it("omits Matrix HTML markup when the body has no visible mention token", () => {
expect(
testing.buildMatrixQaMessageContent({
body: "reply with exactly: TOKEN",
mentionUserIds: ["@sut:matrix-qa.test"],
}),
).toEqual({
body: "reply with exactly: TOKEN",
msgtype: "m.text",
"m.mentions": {
user_ids: ["@sut:matrix-qa.test"],
},
});
});
it("builds trimmed Matrix reaction relations for QA driver events", () => {
expect(testing.buildMatrixReactionRelation(" $msg-1 ", " 👍 ")).toEqual({
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$msg-1",
key: "👍",
},
});
});
it("builds Matrix replacement messages with replacement-local mention metadata", () => {
expect(
testing.buildMatrixQaReplacementMessageContent({
body: "@sut:matrix-qa.test updated prompt",
mentionUserIds: ["@sut:matrix-qa.test"],
targetEventId: " $msg-1 ",
}),
).toEqual({
body: "* @sut:matrix-qa.test updated prompt",
msgtype: "m.text",
"m.new_content": {
body: "@sut:matrix-qa.test updated prompt",
msgtype: "m.text",
format: "org.matrix.custom.html",
formatted_body:
'<a href="https://matrix.to/#/%40sut%3Amatrix-qa.test">@sut:matrix-qa.test</a> updated prompt',
"m.mentions": {
user_ids: ["@sut:matrix-qa.test"],
},
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$msg-1",
},
});
});
it("advances Matrix registration through token then dummy auth stages", () => {
const firstStage = testing.resolveNextRegistrationAuth({
registrationToken: "reg-token",
response: {
session: "uiaa-session",
flows: [{ stages: ["m.login.registration_token", "m.login.dummy"] }],
},
});
expect(firstStage).toEqual({
session: "uiaa-session",
type: "m.login.registration_token",
token: "reg-token",
});
expect(
testing.resolveNextRegistrationAuth({
registrationToken: "reg-token",
response: {
session: "uiaa-session",
completed: ["m.login.registration_token"],
flows: [{ stages: ["m.login.registration_token", "m.login.dummy"] }],
},
}),
).toEqual({
session: "uiaa-session",
type: "m.login.dummy",
});
});
it("rejects Matrix UIAA flows that require unsupported stages", () => {
expect(() =>
testing.resolveNextRegistrationAuth({
registrationToken: "reg-token",
response: {
session: "uiaa-session",
flows: [{ stages: ["m.login.registration_token", "m.login.recaptcha", "m.login.dummy"] }],
},
}),
).toThrow("Matrix registration requires unsupported auth stages:");
});
it("logs in with Matrix password auth to create a secondary QA device", async () => {
const requests: Array<{ body: Record<string, unknown>; url: string }> = [];
const fetchImpl: typeof fetch = async (input, init) => {
requests.push({
body: parseJsonRequestBody(init),
url: resolveRequestUrl(input),
});
return new Response(
JSON.stringify({
access_token: "secondary-token",
device_id: "SECONDARYDEVICE",
user_id: "@qa-driver:matrix-qa.test",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
};
const client = createMatrixQaClient({
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
});
const login = await client.loginWithPassword({
deviceName: "OpenClaw Matrix QA Stale Device",
password: "driver-password",
userId: "@qa-driver:matrix-qa.test",
});
expect(login.accessToken).toBe("secondary-token");
expect(login.deviceId).toBe("SECONDARYDEVICE");
expect(login.password).toBe("driver-password");
expect(login.userId).toBe("@qa-driver:matrix-qa.test");
expect(requests).toEqual([
{
url: "http://127.0.0.1:28008/_matrix/client/v3/login",
body: {
type: "m.login.password",
identifier: {
type: "m.id.user",
user: "@qa-driver:matrix-qa.test",
},
initial_device_display_name: "OpenClaw Matrix QA Stale Device",
password: "driver-password",
},
},
]);
});
it("issues Matrix room membership control requests for QA topology changes", async () => {
const requests: Array<{ body: Record<string, unknown>; url: string }> = [];
const fetchImpl: typeof fetch = async (input, init) => {
requests.push({
body: parseJsonRequestBody(init),
url: resolveRequestUrl(input),
});
return new Response(JSON.stringify({}), {
status: 200,
headers: { "content-type": "application/json" },
});
};
const client = createMatrixQaClient({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
});
await client.inviteUserToRoom({
roomId: "!room:matrix-qa.test",
userId: "@observer:matrix-qa.test",
});
await client.kickUserFromRoom({
reason: "topology reset",
roomId: "!room:matrix-qa.test",
userId: "@observer:matrix-qa.test",
});
await client.leaveRoom("!room:matrix-qa.test");
expect(requests).toEqual([
{
url: "http://127.0.0.1:28008/_matrix/client/v3/rooms/!room%3Amatrix-qa.test/invite",
body: {
user_id: "@observer:matrix-qa.test",
},
},
{
url: "http://127.0.0.1:28008/_matrix/client/v3/rooms/!room%3Amatrix-qa.test/kick",
body: {
reason: "topology reset",
user_id: "@observer:matrix-qa.test",
},
},
{
url: "http://127.0.0.1:28008/_matrix/client/v3/rooms/!room%3Amatrix-qa.test/leave",
body: {},
},
]);
});
it("sends Matrix reactions through the protocol send endpoint", async () => {
const fetchImpl: typeof fetch = async (input, init) => {
expect(resolveRequestUrl(input)).toContain(
"/_matrix/client/v3/rooms/!room%3Amatrix-qa.test/send/m.reaction/",
);
expect(parseJsonRequestBody(init)).toEqual({
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$msg-1",
key: "👍",
},
});
return new Response(JSON.stringify({ event_id: "$reaction-1" }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
const client = createMatrixQaClient({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
});
await expect(
client.sendReaction({
emoji: "👍",
messageId: "$msg-1",
roomId: "!room:matrix-qa.test",
}),
).resolves.toBe("$reaction-1");
});
it("sends Matrix replacements and redactions through protocol endpoints", async () => {
const requests: Array<{ body: Record<string, unknown>; url: string }> = [];
const fetchImpl: typeof fetch = async (input, init) => {
requests.push({
body: parseJsonRequestBody(init),
url: resolveRequestUrl(input),
});
const eventId = requests.length === 1 ? "$replacement-1" : "$redaction-1";
return new Response(JSON.stringify({ event_id: eventId }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
const client = createMatrixQaClient({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
});
await expect(
client.sendReplacementMessage({
body: "@sut:matrix-qa.test updated prompt",
mentionUserIds: ["@sut:matrix-qa.test"],
roomId: "!room:matrix-qa.test",
targetEventId: "$msg-1",
}),
).resolves.toBe("$replacement-1");
await expect(
client.redactEvent({
eventId: "$reaction-1",
reason: "qa cleanup",
roomId: "!room:matrix-qa.test",
}),
).resolves.toBe("$redaction-1");
expect(requests[0]?.url).toContain(
"/_matrix/client/v3/rooms/!room%3Amatrix-qa.test/send/m.room.message/",
);
const relation = requests[0]?.body?.["m.relates_to"] as
| { event_id?: string; rel_type?: string }
| undefined;
expect(relation?.rel_type).toBe("m.replace");
expect(relation?.event_id).toBe("$msg-1");
expect(requests[1]?.url).toMatch(
/^http:\/\/127\.0\.0\.1:28008\/_matrix\/client\/v3\/rooms\/!room%3Amatrix-qa\.test\/redact\/%24reaction-1\/[0-9a-f-]{36}$/,
);
expect(requests[1]?.body).toEqual({
reason: "qa cleanup",
});
});
it("uploads Matrix media before sending the room event", async () => {
const requests: Array<{
body: RequestInit["body"];
headers: HeadersInit | undefined;
url: string;
}> = [];
const fetchImpl: typeof fetch = async (input, init) => {
requests.push({
body: init?.body,
headers: init?.headers,
url: resolveRequestUrl(input),
});
if (requests.length === 1) {
return new Response(
JSON.stringify({ content_uri: "mxc://matrix-qa.test/red-top-blue-bottom" }),
{
status: 200,
headers: { "content-type": "application/json" },
},
);
}
return new Response(JSON.stringify({ event_id: "$media-1" }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
const client = createMatrixQaClient({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
});
await expect(
client.sendMediaMessage({
body: "@sut:matrix-qa.test Image understanding check",
buffer: Buffer.from("png-bytes"),
contentType: "image/png",
fileName: "red-top-blue-bottom.png",
kind: "image",
mentionUserIds: ["@sut:matrix-qa.test"],
roomId: "!room:matrix-qa.test",
}),
).resolves.toBe("$media-1");
expect(requests).toHaveLength(2);
expect(requests[0]?.url).toBe(
"http://127.0.0.1:28008/_matrix/media/v3/upload?filename=red-top-blue-bottom.png",
);
expect(requests[0]?.body).toBeInstanceOf(Uint8Array);
expect(Array.from(requests[0]?.body as Uint8Array)).toEqual(
Array.from(Buffer.from("png-bytes")),
);
expect(requests[1]?.url).toContain(
"/_matrix/client/v3/rooms/!room%3Amatrix-qa.test/send/m.room.message/",
);
const messageBody =
typeof requests[1]?.body === "string" ? JSON.parse(requests[1].body) : requests[1]?.body;
expect(messageBody.body).toBe("@sut:matrix-qa.test Image understanding check");
expect(messageBody.msgtype).toBe("m.image");
expect(messageBody.filename).toBe("red-top-blue-bottom.png");
expect(messageBody.url).toBe("mxc://matrix-qa.test/red-top-blue-bottom");
expect(messageBody.info?.mimetype).toBe("image/png");
expect(messageBody.info?.size).toBe("png-bytes".length);
expect(messageBody["m.mentions"]?.user_ids).toEqual(["@sut:matrix-qa.test"]);
});
it("fails closed when the media upload response streams an over-cap body", async () => {
// Sibling coverage to requestMatrixJson: the /_matrix/media/v3/upload
// response is also parsed from an external homeserver, so an oversized
// upload body must trip the same 16 MiB cap and cancel the stream rather
// than buffering it whole and OOMing the QA runner.
const chunkSize = 1024 * 1024;
const chunkCount = 32; // 32 MiB total, past the 16 MiB cap
let reads = 0;
let canceled = false;
const encoder = new TextEncoder();
const fetchImpl: typeof fetch = async () =>
new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
reads += 1;
controller.enqueue(encoder.encode("a".repeat(chunkSize)));
if (reads >= chunkCount) {
controller.close();
}
},
cancel() {
canceled = true;
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
const client = createMatrixQaClient({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
});
await expect(
client.sendMediaMessage({
body: "@sut:matrix-qa.test Image understanding check",
buffer: Buffer.from("png-bytes"),
contentType: "image/png",
fileName: "huge.png",
kind: "image",
mentionUserIds: ["@sut:matrix-qa.test"],
roomId: "!room:matrix-qa.test",
}),
).rejects.toThrow(/Matrix homeserver response exceeds 16777216 bytes/);
expect(canceled).toBe(true);
expect(reads).toBeLessThan(chunkCount);
});
it("still tolerates malformed in-bounds media upload JSON", async () => {
// Malformed-but-in-bounds upload bodies fall back to `{}`, so the upload
// surfaces the pre-existing "did not return content_uri" error rather than
// a parse crash — unchanged from before the bound was added.
const fetchImpl: typeof fetch = async () =>
new Response("{ not json", {
status: 200,
headers: { "content-type": "application/json" },
});
const client = createMatrixQaClient({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
});
await expect(
client.sendMediaMessage({
body: "@sut:matrix-qa.test Image understanding check",
buffer: Buffer.from("png-bytes"),
contentType: "image/png",
fileName: "bad.png",
kind: "image",
mentionUserIds: ["@sut:matrix-qa.test"],
roomId: "!room:matrix-qa.test",
}),
).rejects.toThrow("Matrix media upload did not return content_uri.");
});
it("adds Matrix room encryption state when provisioning encrypted QA rooms", async () => {
const createRoomBodies: Array<Record<string, unknown>> = [];
const fetchImpl: typeof fetch = async (input, init) => {
createRoomBodies.push(parseJsonRequestBody(init));
expect(resolveRequestUrl(input)).toBe("http://127.0.0.1:28008/_matrix/client/v3/createRoom");
return new Response(JSON.stringify({ room_id: "!encrypted:matrix-qa.test" }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
const client = createMatrixQaClient({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
});
await expect(
client.createPrivateRoom({
encrypted: true,
inviteUserIds: ["@sut:matrix-qa.test"],
name: "Encrypted QA Room",
}),
).resolves.toBe("!encrypted:matrix-qa.test");
expect(createRoomBodies).toStrictEqual([
{
creation_content: { "m.federate": false },
initial_state: [
{
type: "m.room.history_visibility",
state_key: "",
content: { history_visibility: "joined" },
},
{
type: "m.room.encryption",
state_key: "",
content: { algorithm: "m.megolm.v1.aes-sha2" },
},
],
invite: ["@sut:matrix-qa.test"],
is_direct: false,
name: "Encrypted QA Room",
preset: "private_chat",
},
]);
});
it("provisions a three-member room so Matrix QA runs in a group context", async () => {
const createRoomBodies: Array<Record<string, unknown>> = [];
const fetchImpl: typeof fetch = async (input, init) => {
const url = resolveRequestUrl(input);
const body = parseJsonRequestBody(init);
if (url.endsWith("/_matrix/client/v3/register")) {
const username = typeof body.username === "string" ? body.username : "";
const auth = typeof body.auth === "object" && body.auth ? body.auth : undefined;
if (!auth) {
return new Response(
JSON.stringify({
session: `session-${username}`,
flows: [{ stages: ["m.login.registration_token", "m.login.dummy"] }],
}),
{ status: 401, headers: { "content-type": "application/json" } },
);
}
if ((auth as { type?: string }).type === "m.login.registration_token") {
return new Response(
JSON.stringify({
session: `session-${username}`,
completed: ["m.login.registration_token"],
flows: [{ stages: ["m.login.registration_token", "m.login.dummy"] }],
}),
{ status: 401, headers: { "content-type": "application/json" } },
);
}
return new Response(
JSON.stringify({
access_token: `token-${username}`,
device_id: `device-${username}`,
user_id: `@${username}:matrix-qa.test`,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (url.endsWith("/_matrix/client/v3/createRoom")) {
createRoomBodies.push(body);
return new Response(JSON.stringify({ room_id: "!room:matrix-qa.test" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (url.includes("/_matrix/client/v3/join/")) {
return new Response(JSON.stringify({ room_id: "!room:matrix-qa.test" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
throw new Error(`unexpected fetch ${url}`);
};
const result = await provisionMatrixQaRoom({
baseUrl: "http://127.0.0.1:28008/",
driverLocalpart: "qa-driver",
observerLocalpart: "qa-observer",
registrationToken: "reg-token",
roomName: "OpenClaw Matrix QA",
sutLocalpart: "qa-sut",
fetchImpl,
topology: buildDefaultMatrixQaTopologySpec({
defaultRoomName: "OpenClaw Matrix QA",
}),
});
expect(result.roomId).toBe("!room:matrix-qa.test");
expect(result.topology).toEqual({
defaultRoomId: "!room:matrix-qa.test",
defaultRoomKey: "main",
rooms: [
{
key: "main",
kind: "group",
memberRoles: ["driver", "observer", "sut"],
memberUserIds: [
"@qa-driver:matrix-qa.test",
"@qa-observer:matrix-qa.test",
"@qa-sut:matrix-qa.test",
],
requireMention: true,
roomId: "!room:matrix-qa.test",
name: "OpenClaw Matrix QA",
encrypted: false,
},
],
});
expect(result.observer.userId).toBe("@qa-observer:matrix-qa.test");
expect(createRoomBodies).toEqual([
{
creation_content: { "m.federate": false },
initial_state: [
{
type: "m.room.history_visibility",
state_key: "",
content: { history_visibility: "joined" },
},
],
invite: ["@qa-observer:matrix-qa.test", "@qa-sut:matrix-qa.test"],
is_direct: false,
name: "OpenClaw Matrix QA",
preset: "private_chat",
},
]);
});
it("provisions direct-message topology rooms with Matrix direct-room flags", async () => {
const createRoomBodies: Array<Record<string, unknown>> = [];
const roomIds = ["!group:matrix-qa.test", "!dm:matrix-qa.test"];
let registerCount = 0;
const fetchImpl: typeof fetch = async (input, init) => {
const url = resolveRequestUrl(input);
const body = parseJsonRequestBody(init);
if (url.endsWith("/_matrix/client/v3/register")) {
registerCount += 1;
const role = ["driver", "sut", "observer"][registerCount - 1];
return new Response(
JSON.stringify({
access_token: `token-${role}`,
user_id: `@qa-${role}:matrix-qa.test`,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (url.endsWith("/_matrix/client/v3/createRoom")) {
createRoomBodies.push(body);
return new Response(JSON.stringify({ room_id: roomIds.shift() }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (url.includes("/_matrix/client/v3/join/")) {
return new Response(JSON.stringify({ room_id: "!joined:matrix-qa.test" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
throw new Error(`unexpected fetch ${url}`);
};
const result = await provisionMatrixQaRoom({
baseUrl: "http://127.0.0.1:28008/",
driverLocalpart: "qa-driver",
observerLocalpart: "qa-observer",
registrationToken: "reg-token",
roomName: "unused",
sutLocalpart: "qa-sut",
fetchImpl,
topology: {
defaultRoomKey: "group",
rooms: [
{
key: "group",
kind: "group",
members: ["driver", "observer", "sut"],
name: "Matrix Group",
requireMention: true,
},
{
key: "sut-dm",
kind: "dm",
members: ["driver", "sut"],
name: "Matrix Driver/SUT DM",
},
],
},
});
expect(result.topology.rooms).toEqual([
{
encrypted: false,
key: "group",
kind: "group",
memberRoles: ["driver", "observer", "sut"],
memberUserIds: [
"@qa-driver:matrix-qa.test",
"@qa-observer:matrix-qa.test",
"@qa-sut:matrix-qa.test",
],
name: "Matrix Group",
requireMention: true,
roomId: "!group:matrix-qa.test",
},
{
encrypted: false,
key: "sut-dm",
kind: "dm",
memberRoles: ["driver", "sut"],
memberUserIds: ["@qa-driver:matrix-qa.test", "@qa-sut:matrix-qa.test"],
name: "Matrix Driver/SUT DM",
requireMention: false,
roomId: "!dm:matrix-qa.test",
},
]);
expect(createRoomBodies).toEqual([
{
creation_content: { "m.federate": false },
initial_state: [
{
type: "m.room.history_visibility",
state_key: "",
content: { history_visibility: "joined" },
},
],
invite: ["@qa-observer:matrix-qa.test", "@qa-sut:matrix-qa.test"],
is_direct: false,
name: "Matrix Group",
preset: "private_chat",
},
{
creation_content: { "m.federate": false },
initial_state: [
{
type: "m.room.history_visibility",
state_key: "",
content: { history_visibility: "joined" },
},
],
invite: ["@qa-sut:matrix-qa.test"],
is_direct: true,
name: "Matrix Driver/SUT DM",
preset: "private_chat",
},
]);
});
});

View File

@@ -0,0 +1,928 @@
// Qa Matrix plugin module implements client behavior.
import { randomUUID } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { uniqueStrings, uniqueValues } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { MatrixQaObservedEvent } from "./events.js";
import { MATRIX_QA_JSON_MAX_BYTES, requestMatrixJson, type MatrixQaFetchLike } from "./request.js";
import {
createMatrixQaRoomObserver,
primeMatrixQaRoom,
waitForMatrixQaRoomEvent,
waitForOptionalMatrixQaRoomEvent,
type MatrixQaRoomObserver,
} from "./sync.js";
import {
findMatrixQaProvisionedRoom,
type MatrixQaParticipantRole,
type MatrixQaProvisionedTopology,
type MatrixQaTopologyRoomSpec,
type MatrixQaTopologySpec,
} from "./topology.js";
export type { MatrixQaRoomObserver } from "./sync.js";
type MatrixQaAuthStage = "m.login.dummy" | "m.login.registration_token";
type MatrixQaRegisterResponse = {
access_token?: string;
device_id?: string;
user_id?: string;
};
type MatrixQaLoginResponse = MatrixQaRegisterResponse;
type MatrixQaRoomCreateResponse = {
room_id?: string;
};
type MatrixQaSendMessageContent = {
body: string;
format?: "org.matrix.custom.html";
formatted_body?: string;
"m.new_content"?: MatrixQaSendMessageContent;
"m.mentions"?: {
user_ids?: string[];
};
"m.relates_to"?:
| {
rel_type: "m.thread";
event_id: string;
is_falling_back: true;
"m.in_reply_to": {
event_id: string;
};
}
| {
rel_type: "m.replace";
event_id: string;
};
msgtype: "m.text";
};
type MatrixQaMediaMessageType = "m.audio" | "m.file" | "m.image" | "m.video";
type MatrixQaSendMediaMessageContent = Omit<MatrixQaSendMessageContent, "msgtype"> & {
filename?: string;
info?: {
mimetype?: string;
size?: number;
};
msgtype: MatrixQaMediaMessageType;
url: string;
};
type MatrixQaSendReactionContent = {
"m.relates_to": {
event_id: string;
key: string;
rel_type: "m.annotation";
};
};
type MatrixQaRoomInitialState = Array<{
content: Record<string, unknown>;
state_key: string;
type: string;
}>;
type MatrixQaUiaaResponse = {
completed?: string[];
flows?: Array<{ stages?: string[] }>;
session?: string;
};
type MatrixQaRegisteredAccount = {
accessToken: string;
deviceId?: string;
localpart: string;
password: string;
userId: string;
};
export type MatrixQaProvisionResult = {
driver: MatrixQaRegisteredAccount;
observer: MatrixQaRegisteredAccount;
roomId: string;
sut: MatrixQaRegisteredAccount;
topology: MatrixQaProvisionedTopology;
};
function buildMatrixThreadRelation(threadRootEventId: string, replyToEventId?: string) {
return {
"m.relates_to": {
rel_type: "m.thread" as const,
event_id: threadRootEventId,
is_falling_back: true as const,
"m.in_reply_to": {
event_id: replyToEventId?.trim() || threadRootEventId,
},
},
};
}
function buildMatrixReplacementRelation(targetEventId: string) {
const normalizedTargetEventId = targetEventId.trim();
if (!normalizedTargetEventId) {
throw new Error("Matrix replacement requires a target event id");
}
return {
"m.relates_to": {
rel_type: "m.replace" as const,
event_id: normalizedTargetEventId,
},
};
}
function buildMatrixReactionRelation(
messageId: string,
emoji: string,
): MatrixQaSendReactionContent {
const normalizedMessageId = messageId.trim();
const normalizedEmoji = emoji.trim();
if (!normalizedMessageId) {
throw new Error("Matrix reaction requires a messageId");
}
if (!normalizedEmoji) {
throw new Error("Matrix reaction requires an emoji");
}
return {
"m.relates_to": {
rel_type: "m.annotation",
event_id: normalizedMessageId,
key: normalizedEmoji,
},
};
}
function buildMatrixQaRoomInitialState(encrypted?: boolean): MatrixQaRoomInitialState {
const initialState: MatrixQaRoomInitialState = [
{
type: "m.room.history_visibility",
state_key: "",
content: { history_visibility: "joined" },
},
];
if (encrypted === true) {
initialState.push({
type: "m.room.encryption",
state_key: "",
content: { algorithm: "m.megolm.v1.aes-sha2" },
});
}
return initialState;
}
function escapeMatrixHtml(value: string): string {
return value.replace(/[&<>"']/g, (char) => {
switch (char) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
case "'":
return "&#39;";
default:
return char;
}
});
}
function buildMatrixMentionLink(userId: string) {
const href = `https://matrix.to/#/${encodeURIComponent(userId)}`;
const label = escapeMatrixHtml(userId);
return `<a href="${href}">${label}</a>`;
}
export function buildMatrixQaMessageContent(params: {
body: string;
mentionUserIds?: string[];
replyToEventId?: string;
threadRootEventId?: string;
}): MatrixQaSendMessageContent {
const body = params.body;
const uniqueMentionUserIds = uniqueStrings(params.mentionUserIds?.filter(Boolean) ?? []);
const formattedParts: string[] = [];
let cursor = 0;
let usedFormattedMention = false;
while (cursor < body.length) {
let matchedUserId: string | null = null;
for (const userId of uniqueMentionUserIds) {
if (body.startsWith(userId, cursor)) {
matchedUserId = userId;
break;
}
}
if (matchedUserId) {
formattedParts.push(buildMatrixMentionLink(matchedUserId));
cursor += matchedUserId.length;
usedFormattedMention = true;
continue;
}
formattedParts.push(escapeMatrixHtml(body[cursor] ?? ""));
cursor += 1;
}
return {
body,
msgtype: "m.text",
...(usedFormattedMention
? {
format: "org.matrix.custom.html" as const,
formatted_body: formattedParts.join(""),
}
: {}),
...(uniqueMentionUserIds.length > 0
? { "m.mentions": { user_ids: uniqueMentionUserIds } }
: {}),
...(params.threadRootEventId
? buildMatrixThreadRelation(params.threadRootEventId, params.replyToEventId)
: {}),
};
}
function buildMatrixQaReplacementMessageContent(params: {
body: string;
mentionUserIds?: string[];
targetEventId: string;
}): MatrixQaSendMessageContent {
const newContent = buildMatrixQaMessageContent({
body: params.body,
mentionUserIds: params.mentionUserIds,
});
return {
body: `* ${params.body}`,
msgtype: "m.text",
"m.new_content": newContent,
...buildMatrixReplacementRelation(params.targetEventId),
};
}
function resolveMatrixQaMediaMsgtype(params: {
contentType?: string;
kind?: "audio" | "file" | "image" | "video";
}): MatrixQaMediaMessageType {
if (params.kind === "audio" || params.contentType?.startsWith("audio/")) {
return "m.audio";
}
if (params.kind === "video" || params.contentType?.startsWith("video/")) {
return "m.video";
}
if (params.kind === "image" || params.contentType?.startsWith("image/")) {
return "m.image";
}
return "m.file";
}
function buildMatrixQaMediaMessageContent(params: {
body?: string;
contentType?: string;
fileName?: string;
kind?: "audio" | "file" | "image" | "video";
mentionUserIds?: string[];
replyToEventId?: string;
size: number;
threadRootEventId?: string;
url: string;
}): MatrixQaSendMediaMessageContent {
const normalizedBody = params.body?.trim() || params.fileName?.trim() || "(file)";
const content = buildMatrixQaMessageContent({
body: normalizedBody,
mentionUserIds: params.mentionUserIds,
replyToEventId: params.replyToEventId,
threadRootEventId: params.threadRootEventId,
});
return {
...content,
filename: params.fileName?.trim() || undefined,
info: {
...(params.contentType ? { mimetype: params.contentType } : {}),
size: params.size,
},
msgtype: resolveMatrixQaMediaMsgtype({
contentType: params.contentType,
kind: params.kind,
}),
url: params.url,
};
}
async function uploadMatrixQaContent(params: {
accessToken?: string;
baseUrl: string;
buffer: Buffer;
contentType?: string;
fetchImpl: MatrixQaFetchLike;
fileName?: string;
}) {
const url = new URL("/_matrix/media/v3/upload", params.baseUrl);
const fileName = params.fileName?.trim();
if (fileName) {
url.searchParams.set("filename", fileName);
}
const uploadBody: Uint8Array<ArrayBuffer> =
params.buffer.buffer instanceof ArrayBuffer
? new Uint8Array(params.buffer.buffer, params.buffer.byteOffset, params.buffer.byteLength)
: Uint8Array.from(params.buffer);
const response = await params.fetchImpl(url, {
method: "POST",
headers: {
accept: "application/json",
"content-type": params.contentType ?? "application/octet-stream",
...(params.accessToken ? { authorization: `Bearer ${params.accessToken}` } : {}),
},
body: uploadBody,
signal: AbortSignal.timeout(20_000),
});
// Bound the media-upload response body before parsing, mirroring
// `requestMatrixJson`. The overflow error is read *outside* the parse
// try/catch so it fails closed (propagates) instead of being swallowed into
// `{}`; malformed-but-in-bounds JSON still falls back to `{}` as before.
const uploadBytes = await readResponseWithLimit(response, MATRIX_QA_JSON_MAX_BYTES, {
onOverflow: ({ maxBytes }) => new Error(`Matrix homeserver response exceeds ${maxBytes} bytes`),
});
let body: { content_uri?: string; error?: string };
try {
body = JSON.parse(new TextDecoder().decode(uploadBytes)) as {
content_uri?: string;
error?: string;
};
} catch {
body = {};
}
if (response.status !== 200) {
throw new Error(body.error ?? `Matrix media upload failed with status ${response.status}`);
}
const contentUri = body.content_uri?.trim();
if (!contentUri) {
throw new Error("Matrix media upload did not return content_uri.");
}
return contentUri;
}
function resolveNextRegistrationAuth(params: {
registrationToken: string;
response: MatrixQaUiaaResponse;
}) {
const session = params.response.session?.trim();
if (!session) {
throw new Error("Matrix registration UIAA response did not include a session id.");
}
const completed = new Set(
(params.response.completed ?? []).filter(
(stage): stage is MatrixQaAuthStage =>
stage === "m.login.dummy" || stage === "m.login.registration_token",
),
);
const supportedStages = new Set<MatrixQaAuthStage>([
"m.login.registration_token",
"m.login.dummy",
]);
for (const flow of params.response.flows ?? []) {
const flowStages = flow.stages ?? [];
if (
flowStages.length === 0 ||
flowStages.some((stage) => !supportedStages.has(stage as MatrixQaAuthStage))
) {
continue;
}
const stages = flowStages as MatrixQaAuthStage[];
const nextStage = stages.find((stage) => !completed.has(stage));
if (!nextStage) {
continue;
}
if (nextStage === "m.login.registration_token") {
return {
session,
type: nextStage,
token: params.registrationToken,
};
}
return {
session,
type: nextStage,
};
}
throw new Error(
`Matrix registration requires unsupported auth stages: ${JSON.stringify(params.response.flows ?? [])}`,
);
}
function buildRegisteredAccount(params: {
localpart: string;
password: string;
response: MatrixQaRegisterResponse;
}) {
const userId = params.response.user_id?.trim();
const accessToken = params.response.access_token?.trim();
if (!userId || !accessToken) {
throw new Error("Matrix registration did not return both user_id and access_token.");
}
return {
accessToken,
deviceId: params.response.device_id?.trim() || undefined,
localpart: params.localpart,
password: params.password,
userId,
} satisfies MatrixQaRegisteredAccount;
}
function resolveMatrixQaLoginUser(params: { localpart?: string; userId?: string }) {
const user = params.userId?.trim() || params.localpart?.trim();
if (!user) {
throw new Error("Matrix password login requires a localpart or userId.");
}
return user;
}
export function createMatrixQaClient(params: {
accessToken?: string;
baseUrl: string;
fetchImpl?: MatrixQaFetchLike;
syncObserver?: MatrixQaRoomObserver;
}) {
const fetchImpl = params.fetchImpl ?? fetch;
const syncObserver = params.syncObserver;
const sendEvent = async (opts: { body: unknown; endpoint: string; errorLabel: string }) => {
const result = await requestMatrixJson<{ event_id?: string }>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
body: opts.body,
endpoint: opts.endpoint,
fetchImpl,
method: "PUT",
});
const eventId = result.body.event_id?.trim();
if (!eventId) {
throw new Error(`Matrix ${opts.errorLabel} did not return event_id.`);
}
return eventId;
};
return {
async createPrivateRoom(opts: {
encrypted?: boolean;
inviteUserIds: string[];
isDirect?: boolean;
name: string;
}) {
const result = await requestMatrixJson<MatrixQaRoomCreateResponse>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
body: {
creation_content: { "m.federate": false },
initial_state: buildMatrixQaRoomInitialState(opts.encrypted),
invite: opts.inviteUserIds,
is_direct: opts.isDirect === true,
name: opts.name,
preset: "private_chat",
},
endpoint: "/_matrix/client/v3/createRoom",
fetchImpl,
method: "POST",
});
const roomId = result.body.room_id?.trim();
if (!roomId) {
throw new Error("Matrix createRoom did not return room_id.");
}
return roomId;
},
async primeRoom() {
if (syncObserver) {
return await syncObserver.prime();
}
return await primeMatrixQaRoom({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
fetchImpl,
});
},
async registerWithToken(opts: {
deviceName: string;
localpart: string;
password: string;
registrationToken: string;
}) {
let auth: Record<string, unknown> | undefined;
const baseBody = {
inhibit_login: false,
initial_device_display_name: opts.deviceName,
password: opts.password,
username: opts.localpart,
};
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await requestMatrixJson<MatrixQaRegisterResponse | MatrixQaUiaaResponse>({
baseUrl: params.baseUrl,
body: {
...baseBody,
...(auth ? { auth } : {}),
},
endpoint: "/_matrix/client/v3/register",
fetchImpl,
method: "POST",
okStatuses: [200, 401],
timeoutMs: 30_000,
});
if (response.status === 200) {
return buildRegisteredAccount({
localpart: opts.localpart,
password: opts.password,
response: response.body as MatrixQaRegisterResponse,
});
}
auth = resolveNextRegistrationAuth({
registrationToken: opts.registrationToken,
response: response.body as MatrixQaUiaaResponse,
});
}
throw new Error(
`Matrix registration for ${opts.localpart} did not complete after 4 attempts.`,
);
},
async loginWithPassword(opts: {
deviceName: string;
localpart?: string;
password: string;
userId?: string;
}) {
const result = await requestMatrixJson<MatrixQaLoginResponse>({
baseUrl: params.baseUrl,
body: {
type: "m.login.password",
identifier: {
type: "m.id.user",
user: resolveMatrixQaLoginUser(opts),
},
initial_device_display_name: opts.deviceName,
password: opts.password,
},
endpoint: "/_matrix/client/v3/login",
fetchImpl,
method: "POST",
timeoutMs: 30_000,
});
return buildRegisteredAccount({
localpart: opts.localpart ?? opts.userId ?? "",
password: opts.password,
response: result.body,
});
},
async sendTextMessage(opts: {
body: string;
mentionUserIds?: string[];
replyToEventId?: string;
roomId: string;
threadRootEventId?: string;
}) {
const txnId = randomUUID();
return await sendEvent({
body: buildMatrixQaMessageContent(opts),
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
errorLabel: "sendMessage",
});
},
async sendReplacementMessage(opts: {
body: string;
mentionUserIds?: string[];
roomId: string;
targetEventId: string;
}) {
const txnId = randomUUID();
return await sendEvent({
body: buildMatrixQaReplacementMessageContent(opts),
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
errorLabel: "sendReplacementMessage",
});
},
async sendMediaMessage(opts: {
body?: string;
buffer: Buffer;
contentType?: string;
fileName?: string;
kind?: "audio" | "file" | "image" | "video";
mentionUserIds?: string[];
replyToEventId?: string;
roomId: string;
threadRootEventId?: string;
}) {
const contentUri = await uploadMatrixQaContent({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
buffer: opts.buffer,
contentType: opts.contentType,
fetchImpl,
fileName: opts.fileName,
});
const txnId = randomUUID();
return await sendEvent({
body: buildMatrixQaMediaMessageContent({
body: opts.body,
contentType: opts.contentType,
fileName: opts.fileName,
kind: opts.kind,
mentionUserIds: opts.mentionUserIds,
replyToEventId: opts.replyToEventId,
size: opts.buffer.byteLength,
threadRootEventId: opts.threadRootEventId,
url: contentUri,
}),
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
errorLabel: "sendMediaMessage",
});
},
async redactEvent(opts: { eventId: string; reason?: string; roomId: string }) {
const txnId = randomUUID();
const reason = opts.reason?.trim();
return await sendEvent({
body: reason ? { reason } : {},
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/redact/${encodeURIComponent(opts.eventId)}/${encodeURIComponent(txnId)}`,
errorLabel: "redactEvent",
});
},
async sendReaction(opts: { emoji: string; messageId: string; roomId: string }) {
const txnId = randomUUID();
return await sendEvent({
body: buildMatrixReactionRelation(opts.messageId, opts.emoji),
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/send/m.reaction/${encodeURIComponent(txnId)}`,
errorLabel: "sendReaction",
});
},
async joinRoom(roomId: string) {
const result = await requestMatrixJson<{ room_id?: string }>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
body: {},
endpoint: `/_matrix/client/v3/join/${encodeURIComponent(roomId)}`,
fetchImpl,
method: "POST",
});
return result.body.room_id?.trim() || roomId;
},
async inviteUserToRoom(opts: { roomId: string; userId: string }) {
await requestMatrixJson<Record<string, never>>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
body: {
user_id: opts.userId,
},
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/invite`,
fetchImpl,
method: "POST",
});
},
async kickUserFromRoom(opts: { reason?: string; roomId: string; userId: string }) {
await requestMatrixJson<Record<string, never>>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
body: {
user_id: opts.userId,
...(opts.reason?.trim() ? { reason: opts.reason.trim() } : {}),
},
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/kick`,
fetchImpl,
method: "POST",
});
},
async leaveRoom(roomId: string) {
await requestMatrixJson<Record<string, never>>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
body: {},
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/leave`,
fetchImpl,
method: "POST",
});
},
waitForOptionalRoomEvent(opts: {
observedEvents: MatrixQaObservedEvent[];
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
since?: string;
timeoutMs: number;
}) {
if (syncObserver) {
return syncObserver.waitForOptionalRoomEvent({
predicate: opts.predicate,
roomId: opts.roomId,
timeoutMs: opts.timeoutMs,
});
}
return waitForOptionalMatrixQaRoomEvent({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
fetchImpl,
...opts,
});
},
async waitForRoomEvent(opts: {
observedEvents: MatrixQaObservedEvent[];
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
since?: string;
timeoutMs: number;
}) {
if (syncObserver) {
return await syncObserver.waitForRoomEvent({
predicate: opts.predicate,
roomId: opts.roomId,
timeoutMs: opts.timeoutMs,
});
}
return await waitForMatrixQaRoomEvent({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
fetchImpl,
...opts,
});
},
};
}
async function joinRoomWithRetry(params: {
accessToken: string;
baseUrl: string;
fetchImpl?: MatrixQaFetchLike;
roomId: string;
}) {
const client = createMatrixQaClient({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
fetchImpl: params.fetchImpl,
});
let lastError: unknown = null;
for (let attempt = 1; attempt <= 10; attempt += 1) {
try {
await client.joinRoom(params.roomId);
return;
} catch (error) {
lastError = error;
await sleep(300 * attempt);
}
}
throw new Error(`Matrix join retry failed: ${formatErrorMessage(lastError)}`);
}
function resolveProvisionedRoomRequireMention(room: MatrixQaTopologyRoomSpec) {
return room.kind === "group" ? room.requireMention !== false : false;
}
function resolveTopologyMemberAccounts(
accounts: Record<MatrixQaParticipantRole, MatrixQaRegisteredAccount>,
memberRoles: MatrixQaParticipantRole[],
) {
const uniqueRoles = uniqueValues(memberRoles);
if (uniqueRoles.length === 0) {
throw new Error("Matrix QA room provisioning requires at least one member");
}
return uniqueRoles.map((role) => ({
role,
account: accounts[role],
}));
}
async function provisionMatrixQaTopology(params: {
accounts: Record<MatrixQaParticipantRole, MatrixQaRegisteredAccount>;
baseUrl: string;
fetchImpl?: MatrixQaFetchLike;
spec: MatrixQaTopologySpec;
}): Promise<MatrixQaProvisionedTopology> {
const rooms = [];
for (const room of params.spec.rooms) {
const members = resolveTopologyMemberAccounts(params.accounts, room.members);
const creator = members[0];
const invitees = members.slice(1);
const creatorClient = createMatrixQaClient({
accessToken: creator.account.accessToken,
baseUrl: params.baseUrl,
fetchImpl: params.fetchImpl,
});
const roomId = await creatorClient.createPrivateRoom({
encrypted: room.encrypted === true,
inviteUserIds: invitees.map((entry) => entry.account.userId),
isDirect: room.kind === "dm",
name: room.name,
});
await Promise.all(
invitees.map((invitee) =>
joinRoomWithRetry({
accessToken: invitee.account.accessToken,
baseUrl: params.baseUrl,
fetchImpl: params.fetchImpl,
roomId,
}),
),
);
rooms.push({
encrypted: room.encrypted === true,
key: room.key,
kind: room.kind,
memberRoles: members.map((entry) => entry.role),
memberUserIds: members.map((entry) => entry.account.userId),
name: room.name,
requireMention: resolveProvisionedRoomRequireMention(room),
roomId,
});
}
const defaultRoom = findMatrixQaProvisionedRoom(
{
defaultRoomId: "",
defaultRoomKey: params.spec.defaultRoomKey,
rooms,
},
params.spec.defaultRoomKey,
);
return {
defaultRoomId: defaultRoom.roomId,
defaultRoomKey: params.spec.defaultRoomKey,
rooms,
};
}
export async function provisionMatrixQaRoom(params: {
baseUrl: string;
fetchImpl?: MatrixQaFetchLike;
topology?: MatrixQaTopologySpec;
roomName: string;
driverLocalpart: string;
observerLocalpart: string;
registrationToken: string;
sutLocalpart: string;
}) {
const anonClient = createMatrixQaClient({
baseUrl: params.baseUrl,
fetchImpl: params.fetchImpl,
});
const [driver, sut, observer] = await Promise.all([
anonClient.registerWithToken({
deviceName: "OpenClaw Matrix QA Driver",
localpart: params.driverLocalpart,
password: `driver-${randomUUID()}`,
registrationToken: params.registrationToken,
}),
anonClient.registerWithToken({
deviceName: "OpenClaw Matrix QA SUT",
localpart: params.sutLocalpart,
password: `sut-${randomUUID()}`,
registrationToken: params.registrationToken,
}),
anonClient.registerWithToken({
deviceName: "OpenClaw Matrix QA Observer",
localpart: params.observerLocalpart,
password: `observer-${randomUUID()}`,
registrationToken: params.registrationToken,
}),
]);
const topology = await provisionMatrixQaTopology({
accounts: {
driver,
observer,
sut,
},
baseUrl: params.baseUrl,
fetchImpl: params.fetchImpl,
spec:
params.topology ??
({
defaultRoomKey: "main",
rooms: [
{
key: "main",
kind: "group",
members: ["driver", "observer", "sut"],
name: params.roomName,
requireMention: true,
},
],
} satisfies MatrixQaTopologySpec),
});
return {
driver,
observer,
roomId: topology.defaultRoomId,
sut,
topology,
} satisfies MatrixQaProvisionResult;
}
export const testing = {
buildMatrixQaMessageContent,
buildMatrixQaReplacementMessageContent,
buildMatrixReactionRelation,
buildMatrixReplacementRelation,
buildMatrixThreadRelation,
createMatrixQaRoomObserver,
resolveNextRegistrationAuth,
};
export { testing as __testing };

View File

@@ -0,0 +1,431 @@
// Qa Matrix tests cover config plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import {
buildMatrixQaConfig,
buildMatrixQaConfigSnapshot,
summarizeMatrixQaConfigSnapshot,
} from "./config.js";
import type { MatrixQaProvisionedTopology } from "./topology.js";
describe("matrix qa config", () => {
const topology: MatrixQaProvisionedTopology = {
defaultRoomId: "!main:matrix-qa.test",
defaultRoomKey: "main",
rooms: [
{
key: "main",
kind: "group" as const,
memberRoles: ["driver", "observer", "sut"],
memberUserIds: [
"@driver:matrix-qa.test",
"@observer:matrix-qa.test",
"@sut:matrix-qa.test",
],
name: "Main",
requireMention: true,
roomId: "!main:matrix-qa.test",
},
{
key: "secondary",
kind: "group" as const,
memberRoles: ["driver", "observer", "sut"],
memberUserIds: [
"@driver:matrix-qa.test",
"@observer:matrix-qa.test",
"@sut:matrix-qa.test",
],
name: "Secondary",
requireMention: true,
roomId: "!secondary:matrix-qa.test",
},
{
key: "driver-dm",
kind: "dm" as const,
memberRoles: ["driver", "sut"],
memberUserIds: ["@driver:matrix-qa.test", "@sut:matrix-qa.test"],
name: "DM",
requireMention: false,
roomId: "!dm:matrix-qa.test",
},
],
};
it("builds default Matrix QA config from provisioned topology", () => {
const next = buildMatrixQaConfig({} as OpenClawConfig, {
driverUserId: "@driver:matrix-qa.test",
homeserver: "http://127.0.0.1:28008/",
observerUserId: "@observer:matrix-qa.test",
sutAccessToken: "sut-token",
sutAccountId: "sut",
sutUserId: "@sut:matrix-qa.test",
topology,
});
const sut = next.channels?.matrix?.accounts?.sut;
expect(sut?.dm?.allowFrom).toEqual(["@driver:matrix-qa.test"]);
expect(sut?.dm?.enabled).toBe(true);
expect(sut?.dm?.policy).toBe("allowlist");
expect(sut?.groupAllowFrom).toEqual(["@driver:matrix-qa.test"]);
expect(sut?.groupPolicy).toBe("allowlist");
expect(sut?.groups?.["!main:matrix-qa.test"]).toEqual({
enabled: true,
requireMention: true,
});
expect(sut?.groups?.["!secondary:matrix-qa.test"]).toEqual({
enabled: true,
requireMention: true,
});
expect(sut?.replyToMode).toBe("off");
expect(sut?.threadReplies).toBe("inbound");
expect(next.messages?.groupChat?.visibleReplies).toBe("automatic");
});
it("applies room-keyed Matrix QA config overrides", () => {
const next = buildMatrixQaConfig({} as OpenClawConfig, {
driverUserId: "@driver:matrix-qa.test",
homeserver: "http://127.0.0.1:28008/",
observerUserId: "@observer:matrix-qa.test",
overrides: {
autoJoin: "allowlist",
autoJoinAllowlist: [" !dm:matrix-qa.test ", "#ops:matrix-qa.test"],
agentDefaults: {
blockStreamingChunk: {
breakPreference: "newline",
maxChars: 48,
minChars: 1,
},
blockStreamingCoalesce: {
idleMs: 0,
maxChars: 48,
minChars: 1,
},
},
blockStreaming: true,
dm: {
sessionScope: "per-room",
threadReplies: "off",
},
encryption: true,
allowBots: "mentions",
configuredBotRoles: ["observer"],
groupAllowFrom: ["@driver:matrix-qa.test", "@observer:matrix-qa.test"],
groupMentionPatterns: ["\\S"],
groupsByKey: {
secondary: {
allowBots: false,
requireMention: false,
tools: {
allow: ["sessions_spawn"],
},
},
},
replyToMode: "all",
streaming: "quiet",
threadBindings: {
enabled: true,
idleHours: 1,
spawnSessions: true,
},
threadReplies: "always",
audio: {
echoTranscript: false,
enabled: true,
},
toolProfile: "coding",
},
observerAccessToken: "observer-token",
sutAccessToken: "sut-token",
sutAccountId: "sut",
sutUserId: "@sut:matrix-qa.test",
topology,
});
expect(next.agents?.defaults?.blockStreamingChunk).toEqual({
breakPreference: "newline",
maxChars: 48,
minChars: 1,
});
expect(next.agents?.defaults?.blockStreamingCoalesce).toEqual({
idleMs: 0,
maxChars: 48,
minChars: 1,
});
expect(next.tools?.profile).toBe("coding");
expect(next.tools?.media?.audio).toEqual({
echoTranscript: false,
enabled: true,
});
expect(next.messages?.groupChat?.mentionPatterns).toEqual(["\\S"]);
const observer = next.channels?.matrix?.accounts?.["qa-observer-bot-source"];
expect(observer?.accessToken).toBe("observer-token");
expect(observer?.enabled).toBe(false);
expect(observer?.homeserver).toBe("http://127.0.0.1:28008/");
expect(observer?.userId).toBe("@observer:matrix-qa.test");
const sut = next.channels?.matrix?.accounts?.sut;
expect(sut?.allowBots).toBe("mentions");
expect(sut?.autoJoin).toBe("allowlist");
expect(sut?.autoJoinAllowlist).toEqual(["!dm:matrix-qa.test", "#ops:matrix-qa.test"]);
expect(sut?.blockStreaming).toBe(true);
expect(sut?.dm?.sessionScope).toBe("per-room");
expect(sut?.dm?.threadReplies).toBe("off");
expect(sut?.encryption).toBe(true);
expect(sut?.groupAllowFrom).toEqual(["@driver:matrix-qa.test", "@observer:matrix-qa.test"]);
expect(sut?.groups?.["!main:matrix-qa.test"]).toEqual({
enabled: true,
requireMention: true,
});
expect(sut?.groups?.["!secondary:matrix-qa.test"]).toEqual({
allowBots: false,
enabled: true,
requireMention: false,
tools: {
allow: ["sessions_spawn"],
},
});
expect(sut?.replyToMode).toBe("all");
expect(sut?.streaming).toBe("quiet");
expect(sut?.threadBindings).toEqual({
enabled: true,
idleHours: 1,
spawnSessions: true,
});
expect(sut?.threadReplies).toBe("always");
});
it("rewrites the owned Matrix QA account instead of retaining stale override fields", () => {
const overridden = buildMatrixQaConfig({} as OpenClawConfig, {
driverUserId: "@driver:matrix-qa.test",
homeserver: "http://127.0.0.1:28008/",
observerUserId: "@observer:matrix-qa.test",
overrides: {
autoJoin: "allowlist",
autoJoinAllowlist: ["!ops:matrix-qa.test"],
blockStreaming: true,
streaming: "quiet",
},
sutAccessToken: "sut-token",
sutAccountId: "sut",
sutUserId: "@sut:matrix-qa.test",
topology,
});
const reset = buildMatrixQaConfig(overridden, {
driverUserId: "@driver:matrix-qa.test",
homeserver: "http://127.0.0.1:28008/",
observerUserId: "@observer:matrix-qa.test",
sutAccessToken: "sut-token",
sutAccountId: "sut",
sutUserId: "@sut:matrix-qa.test",
topology,
});
expect(reset.channels?.matrix?.accounts?.sut?.autoJoin).toBeUndefined();
expect(reset.channels?.matrix?.accounts?.sut?.autoJoinAllowlist).toBeUndefined();
expect(reset.channels?.matrix?.accounts?.sut?.blockStreaming).toBeUndefined();
expect(reset.channels?.matrix?.accounts?.sut?.streaming).toBeUndefined();
});
it("builds an effective Matrix QA config snapshot for reporting", () => {
const snapshot = buildMatrixQaConfigSnapshot({
driverUserId: "@driver:matrix-qa.test",
observerUserId: "@observer:matrix-qa.test",
overrides: {
autoJoin: "allowlist",
autoJoinAllowlist: ["!ops:matrix-qa.test"],
blockStreaming: true,
dm: {
sessionScope: "per-room",
},
groupMentionPatterns: ["\\S"],
groupPolicy: "open",
streaming: true,
},
sutUserId: "@sut:matrix-qa.test",
topology,
});
expect(snapshot).toEqual({
approvalForwarding: {
exec: false,
plugin: false,
},
allowBots: undefined,
autoJoin: "allowlist",
autoJoinAllowlist: ["!ops:matrix-qa.test"],
blockStreaming: true,
chunkMode: undefined,
dm: {
allowFrom: ["@driver:matrix-qa.test"],
enabled: true,
policy: "allowlist",
sessionScope: "per-room",
threadReplies: "inbound",
},
encryption: false,
execApprovals: undefined,
configuredBotRoles: [],
groupAllowFrom: ["@driver:matrix-qa.test"],
groupMentionPatterns: ["\\S"],
groupPolicy: "open",
groupsByKey: {
main: {
enabled: true,
requireMention: true,
roomId: "!main:matrix-qa.test",
},
secondary: {
enabled: true,
requireMention: true,
roomId: "!secondary:matrix-qa.test",
},
},
replyToMode: "off",
streaming: "partial",
streamingPreviewToolProgress: true,
textChunkLimit: undefined,
threadBindings: {},
threadReplies: "inbound",
});
expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("allowBots=<default>");
expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("configuredBotRoles=<none>");
expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("groupMentionPatterns=\\S");
expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("autoJoin=allowlist");
expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("streaming=partial");
expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain(
"streaming.preview.toolProgress=true",
);
});
it("builds Matrix QA config snapshots from structured streaming overrides", () => {
const snapshot = buildMatrixQaConfigSnapshot({
driverUserId: "@driver:matrix-qa.test",
observerUserId: "@observer:matrix-qa.test",
overrides: {
streaming: {
mode: "quiet",
preview: {
toolProgress: false,
},
},
},
sutUserId: "@sut:matrix-qa.test",
topology,
});
expect(snapshot.streaming).toBe("quiet");
expect(snapshot.streamingPreviewToolProgress).toBe(false);
expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain("streaming=quiet");
expect(summarizeMatrixQaConfigSnapshot(snapshot)).toContain(
"streaming.preview.toolProgress=false",
);
});
it("applies Matrix approval delivery overrides with gateway forwarding enabled", () => {
const next = buildMatrixQaConfig({} as OpenClawConfig, {
driverUserId: "@driver:matrix-qa.test",
homeserver: "http://127.0.0.1:28008/",
observerUserId: "@observer:matrix-qa.test",
overrides: {
approvalForwarding: {
exec: true,
plugin: true,
},
chunkMode: "length",
dm: {
enabled: true,
},
execApprovals: {
enabled: true,
target: "both",
},
textChunkLimit: 280,
},
sutAccessToken: "sut-token",
sutAccountId: "sut",
sutUserId: "@sut:matrix-qa.test",
topology,
});
expect(next.approvals?.exec).toEqual({ enabled: true, mode: "session" });
expect(next.approvals?.plugin).toEqual({ enabled: true, mode: "session" });
const sut = next.channels?.matrix?.accounts?.sut;
expect(sut?.chunkMode).toBe("length");
expect(sut?.dm?.allowFrom).toEqual(["@driver:matrix-qa.test"]);
expect(sut?.dm?.enabled).toBe(true);
expect(sut?.execApprovals).toEqual({
enabled: true,
target: "both",
});
expect(sut?.textChunkLimit).toBe(280);
});
it("resolves role-based Matrix sender allowlist overrides", () => {
const snapshot = buildMatrixQaConfigSnapshot({
driverUserId: "@driver:matrix-qa.test",
observerUserId: "@observer:matrix-qa.test",
overrides: {
groupAllowRoles: ["driver", "observer"],
},
sutUserId: "@sut:matrix-qa.test",
topology,
});
expect(snapshot.groupAllowFrom).toEqual(["@driver:matrix-qa.test", "@observer:matrix-qa.test"]);
});
it("rejects configured bot roles without matching side-account auth", () => {
expect(() =>
buildMatrixQaConfig({} as OpenClawConfig, {
driverUserId: "@driver:matrix-qa.test",
homeserver: "http://127.0.0.1:28008/",
observerUserId: "@observer:matrix-qa.test",
overrides: {
configuredBotRoles: ["observer"],
},
sutAccessToken: "sut-token",
sutAccountId: "sut",
sutUserId: "@sut:matrix-qa.test",
topology,
}),
).toThrow('Matrix QA configured bot role "observer" requires an access token');
});
it("rejects the SUT role as a configured bot source", () => {
expect(() =>
buildMatrixQaConfig({} as OpenClawConfig, {
driverUserId: "@driver:matrix-qa.test",
homeserver: "http://127.0.0.1:28008/",
observerUserId: "@observer:matrix-qa.test",
overrides: {
configuredBotRoles: ["sut"],
},
sutAccessToken: "sut-token",
sutAccountId: "sut",
sutUserId: "@sut:matrix-qa.test",
topology,
}),
).toThrow('Matrix QA configured bot role "sut" would match the SUT account itself');
});
it("rejects unknown room-key overrides", () => {
expect(() =>
buildMatrixQaConfig({} as OpenClawConfig, {
driverUserId: "@driver:matrix-qa.test",
homeserver: "http://127.0.0.1:28008/",
observerUserId: "@observer:matrix-qa.test",
overrides: {
groupsByKey: {
ghost: {
requireMention: false,
},
},
},
sutAccessToken: "sut-token",
sutAccountId: "sut",
sutUserId: "@sut:matrix-qa.test",
topology,
}),
).toThrow('Matrix QA group override references unknown room key "ghost"');
});
});

View File

@@ -0,0 +1,710 @@
// Qa Matrix helper module supports config behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { MatrixQaProvisionedTopology } from "./topology.js";
type MatrixQaReplyToMode = "off" | "first" | "all" | "batched";
type MatrixQaThreadRepliesMode = "off" | "inbound" | "always";
type MatrixQaDmPolicy = "allowlist" | "disabled" | "open" | "pairing";
type MatrixQaGroupPolicy = "allowlist" | "disabled" | "open";
type MatrixQaAutoJoinMode = "allowlist" | "always" | "off";
type MatrixQaStreamingMode = "off" | "partial" | "quiet";
type MatrixQaActorRole = "driver" | "observer" | "sut";
type MatrixQaChunkMode = "length" | "newline";
type MatrixQaExecApprovalTarget = "both" | "channel" | "dm";
type MatrixQaExecApprovalsEnabled = boolean | "auto";
type MatrixQaAllowBotsMode = boolean | "mentions";
type MatrixQaStreamingConfig = {
mode?: MatrixQaStreamingMode;
preview?: {
toolProgress?: boolean;
};
};
type MatrixQaAgentDefaultsOverrides = {
blockStreamingChunk?: {
breakPreference?: "newline" | "paragraph" | "sentence";
maxChars?: number;
minChars?: number;
};
blockStreamingCoalesce?: {
idleMs?: number;
maxChars?: number;
minChars?: number;
};
};
type MatrixQaToolConfigOverrides = {
allow?: string[];
deny?: string[];
};
type MatrixQaAudioConfigOverrides = NonNullable<
NonNullable<NonNullable<OpenClawConfig["tools"]>["media"]>["audio"]
>;
type MatrixQaGroupConfigOverrides = {
allowBots?: MatrixQaAllowBotsMode;
enabled?: boolean;
requireMention?: boolean;
tools?: MatrixQaToolConfigOverrides;
};
type MatrixQaDmConfigOverrides = {
allowFrom?: string[];
enabled?: boolean;
policy?: MatrixQaDmPolicy;
sessionScope?: "per-room" | "per-user";
threadReplies?: MatrixQaThreadRepliesMode;
};
type MatrixQaThreadBindingsConfigOverrides = {
enabled?: boolean;
idleHours?: number;
maxAgeHours?: number;
spawnSessions?: boolean;
defaultSpawnContext?: "isolated" | "fork";
/** @deprecated Use spawnSessions instead. */
spawnAcpSessions?: boolean;
/** @deprecated Use spawnSessions instead. */
spawnSubagentSessions?: boolean;
};
type MatrixQaExecApprovalsConfigOverrides = {
agentFilter?: string[];
approvers?: string[];
enabled?: MatrixQaExecApprovalsEnabled;
sessionFilter?: string[];
target?: MatrixQaExecApprovalTarget;
};
export type MatrixQaConfigOverrides = {
approvalForwarding?: {
exec?: boolean;
plugin?: boolean;
};
agentDefaults?: MatrixQaAgentDefaultsOverrides;
allowBots?: MatrixQaAllowBotsMode;
autoJoin?: MatrixQaAutoJoinMode;
autoJoinAllowlist?: string[];
blockStreaming?: boolean;
chunkMode?: MatrixQaChunkMode;
dm?: MatrixQaDmConfigOverrides;
encryption?: boolean;
execApprovals?: MatrixQaExecApprovalsConfigOverrides;
groupAllowFrom?: string[];
groupAllowRoles?: MatrixQaActorRole[];
groupMentionPatterns?: string[];
groupPolicy?: MatrixQaGroupPolicy;
configuredBotRoles?: MatrixQaActorRole[];
groupsByKey?: Record<string, MatrixQaGroupConfigOverrides>;
replyToMode?: MatrixQaReplyToMode;
startupVerification?: "if-unverified" | "off";
streaming?: MatrixQaStreamingMode | MatrixQaStreamingConfig | boolean;
textChunkLimit?: number;
threadBindings?: MatrixQaThreadBindingsConfigOverrides;
threadReplies?: MatrixQaThreadRepliesMode;
audio?: MatrixQaAudioConfigOverrides;
toolProfile?: "coding" | "messaging" | "minimal";
};
export type MatrixQaConfigSnapshot = {
approvalForwarding: {
exec: boolean;
plugin: boolean;
};
autoJoin: MatrixQaAutoJoinMode;
autoJoinAllowlist: string[];
allowBots?: MatrixQaAllowBotsMode;
blockStreaming: boolean;
chunkMode?: MatrixQaChunkMode;
dm: {
allowFrom: string[];
enabled: boolean;
policy: MatrixQaDmPolicy;
sessionScope: "per-room" | "per-user";
threadReplies: MatrixQaThreadRepliesMode;
};
encryption: boolean;
execApprovals?: MatrixQaExecApprovalsConfigOverrides;
configuredBotRoles: MatrixQaActorRole[];
groupAllowFrom: string[];
groupMentionPatterns: string[];
groupPolicy: MatrixQaGroupPolicy;
groupsByKey: Record<string, MatrixQaGroupSnapshot>;
replyToMode: MatrixQaReplyToMode;
startupVerification?: "if-unverified" | "off";
streaming: MatrixQaStreamingMode;
streamingPreviewToolProgress: boolean;
textChunkLimit?: number;
threadBindings: MatrixQaThreadBindingsConfigOverrides;
threadReplies: MatrixQaThreadRepliesMode;
};
type MatrixQaGroupSnapshot = {
allowBots?: MatrixQaAllowBotsMode;
enabled: boolean;
requireMention: boolean;
roomId: string;
tools?: MatrixQaToolConfigOverrides;
};
type MatrixQaGroupEntry = Omit<MatrixQaGroupSnapshot, "roomId">;
type MatrixQaChannelConfig = NonNullable<OpenClawConfig["channels"]>["matrix"];
type MatrixQaChannelAccountConfig = NonNullable<
NonNullable<MatrixQaChannelConfig>["accounts"]
>[string];
type MatrixQaAccountDmConfig =
| { enabled: false }
| {
allowFrom: string[];
enabled: true;
policy: MatrixQaDmPolicy;
sessionScope?: "per-room" | "per-user";
threadReplies?: MatrixQaThreadRepliesMode;
};
type MatrixQaAccountExecApprovalsConfig = {
agentFilter?: string[];
approvers?: string[];
enabled?: MatrixQaExecApprovalsEnabled;
sessionFilter?: string[];
target?: MatrixQaExecApprovalTarget;
};
function normalizeMatrixQaAllowlist(entries?: string[]) {
return uniqueStrings(normalizeStringEntries(entries ?? []));
}
function resolveMatrixQaGroupSnapshots(params: {
overrides?: MatrixQaConfigOverrides;
topology: MatrixQaProvisionedTopology;
}) {
const groupRooms = params.topology.rooms.filter((room) => room.kind === "group");
const groupsByKey = params.overrides?.groupsByKey ?? {};
const knownGroupKeys = new Set(groupRooms.map((room) => room.key));
for (const key of Object.keys(groupsByKey)) {
if (!knownGroupKeys.has(key)) {
throw new Error(`Matrix QA group override references unknown room key "${key}"`);
}
}
return Object.fromEntries(
groupRooms.map((room) => {
const override = groupsByKey[room.key];
return [
room.key,
{
roomId: room.roomId,
enabled: override?.enabled ?? true,
...(override && Object.hasOwn(override, "allowBots")
? { allowBots: override.allowBots }
: {}),
requireMention: override?.requireMention ?? room.requireMention,
...(override?.tools ? { tools: override.tools } : {}),
},
];
}),
);
}
function buildMatrixQaGroupEntries(
groupsByKey: MatrixQaConfigSnapshot["groupsByKey"],
): Record<string, MatrixQaGroupEntry> {
return Object.fromEntries(
Object.values(groupsByKey).map((group) => [
group.roomId,
{
...(group.allowBots !== undefined ? { allowBots: group.allowBots } : {}),
enabled: group.enabled,
requireMention: group.requireMention,
...(group.tools ? { tools: group.tools } : {}),
},
]),
);
}
function resolveMatrixQaDmAllowFrom(params: {
driverUserId: string;
overrides?: MatrixQaConfigOverrides;
sutUserId: string;
topology: MatrixQaProvisionedTopology;
}) {
if (params.overrides?.dm?.allowFrom) {
return normalizeMatrixQaAllowlist(params.overrides.dm.allowFrom);
}
const dmParticipantUserIds = params.topology.rooms
.filter((room) => room.kind === "dm")
.flatMap((room) => room.memberUserIds.filter((userId) => userId !== params.sutUserId));
const dmAllowFrom = uniqueStrings(dmParticipantUserIds);
return dmAllowFrom.length > 0 ? dmAllowFrom : [params.driverUserId];
}
function resolveMatrixQaDmConfigSnapshot(params: {
driverUserId: string;
overrides?: MatrixQaConfigOverrides;
sutUserId: string;
topology: MatrixQaProvisionedTopology;
}) {
const hasDmRooms = params.topology.rooms.some((room) => room.kind === "dm");
const dmOverrides = params.overrides?.dm;
const enabled = hasDmRooms || dmOverrides?.enabled === true;
return {
allowFrom: enabled ? resolveMatrixQaDmAllowFrom(params) : [],
enabled,
policy: dmOverrides?.policy ?? "allowlist",
sessionScope: dmOverrides?.sessionScope ?? "per-user",
threadReplies: dmOverrides?.threadReplies ?? params.overrides?.threadReplies ?? "inbound",
};
}
function resolveMatrixQaStreamingMode(
value: MatrixQaConfigOverrides["streaming"],
): MatrixQaStreamingMode {
if (value === true || value === "partial") {
return "partial";
}
if (value === "quiet") {
return "quiet";
}
if (isMatrixQaStreamingConfig(value)) {
if (value.mode === "partial" || value.mode === "quiet") {
return value.mode;
}
}
return "off";
}
function isMatrixQaStreamingConfig(
value: MatrixQaConfigOverrides["streaming"],
): value is MatrixQaStreamingConfig {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function resolveMatrixQaStreamingPreviewToolProgress(
value: MatrixQaConfigOverrides["streaming"],
): boolean {
if (!isMatrixQaStreamingConfig(value)) {
return true;
}
return value.preview?.toolProgress ?? true;
}
function resolveMatrixQaAutoJoinAllowlist(params: { overrides?: MatrixQaConfigOverrides }) {
if (params.overrides?.autoJoin !== "allowlist") {
return [];
}
return normalizeMatrixQaAllowlist(params.overrides.autoJoinAllowlist);
}
function resolveMatrixQaRoleAllowlist(params: {
roles?: MatrixQaActorRole[];
driverUserId: string;
observerUserId: string;
sutUserId: string;
}) {
const roleToUserId = {
driver: params.driverUserId,
observer: params.observerUserId,
sut: params.sutUserId,
} satisfies Record<MatrixQaActorRole, string>;
return (params.roles ?? []).map((role) => roleToUserId[role]);
}
function resolveMatrixQaGroupAllowFrom(params: {
driverUserId: string;
observerUserId: string;
overrides?: MatrixQaConfigOverrides;
sutUserId: string;
}) {
const explicitAllowFrom = params.overrides?.groupAllowFrom;
const roleAllowFrom = resolveMatrixQaRoleAllowlist({
roles: params.overrides?.groupAllowRoles,
driverUserId: params.driverUserId,
observerUserId: params.observerUserId,
sutUserId: params.sutUserId,
});
if (explicitAllowFrom !== undefined || params.overrides?.groupAllowRoles !== undefined) {
return normalizeMatrixQaAllowlist([...(explicitAllowFrom ?? []), ...roleAllowFrom]);
}
return [params.driverUserId];
}
function formatMatrixQaBoolean(value: boolean) {
return value ? "true" : "false";
}
function buildMatrixQaAccountDmConfig(params: {
dmOverrides?: MatrixQaConfigOverrides["dm"];
snapshot: MatrixQaConfigSnapshot;
}): MatrixQaAccountDmConfig {
if (!params.snapshot.dm.enabled) {
return { enabled: false };
}
return {
allowFrom: params.snapshot.dm.allowFrom,
enabled: true,
policy: params.snapshot.dm.policy,
...(params.dmOverrides?.sessionScope ? { sessionScope: params.snapshot.dm.sessionScope } : {}),
...(params.dmOverrides?.threadReplies
? { threadReplies: params.snapshot.dm.threadReplies }
: {}),
};
}
function buildMatrixQaAccountExecApprovalsConfig(
overrides?: MatrixQaExecApprovalsConfigOverrides,
): MatrixQaAccountExecApprovalsConfig | undefined {
if (!overrides) {
return undefined;
}
return {
...(overrides.agentFilter ? { agentFilter: overrides.agentFilter } : {}),
...(overrides.approvers ? { approvers: normalizeMatrixQaAllowlist(overrides.approvers) } : {}),
...(overrides.enabled !== undefined ? { enabled: overrides.enabled } : {}),
...(overrides.sessionFilter ? { sessionFilter: overrides.sessionFilter } : {}),
...(overrides.target ? { target: overrides.target } : {}),
};
}
function buildMatrixQaConfiguredBotAccounts(params: {
driverAccessToken: string | undefined;
driverUserId: string;
homeserver: string;
observerAccessToken: string | undefined;
observerUserId: string;
roles: MatrixQaActorRole[];
}): Record<string, MatrixQaChannelAccountConfig> {
const selectedRoles = new Set(params.roles);
if (selectedRoles.has("sut")) {
throw new Error('Matrix QA configured bot role "sut" would match the SUT account itself');
}
const botSources: Record<
Exclude<MatrixQaActorRole, "sut">,
{
accessToken: string | undefined;
accountId: string;
userId: string;
}
> = {
driver: {
accessToken: params.driverAccessToken,
accountId: "qa-driver-bot-source",
userId: params.driverUserId,
},
observer: {
accessToken: params.observerAccessToken,
accountId: "qa-observer-bot-source",
userId: params.observerUserId,
},
};
const accounts: Record<string, MatrixQaChannelAccountConfig> = {};
for (const role of selectedRoles) {
if (role !== "driver" && role !== "observer") {
continue;
}
const source = botSources[role];
if (!source.accessToken) {
throw new Error(`Matrix QA configured bot role "${role}" requires an access token`);
}
accounts[source.accountId] = {
accessToken: source.accessToken,
enabled: false,
homeserver: params.homeserver,
userId: source.userId,
};
}
return accounts;
}
function buildMatrixQaChannelAccountConfig(params: {
groups: Record<string, MatrixQaGroupEntry>;
homeserver: string;
overrides?: MatrixQaConfigOverrides;
snapshot: MatrixQaConfigSnapshot;
sutAccessToken: string;
sutDeviceId?: string;
sutUserId: string;
}): MatrixQaChannelAccountConfig {
const groupsConfig = Object.keys(params.groups).length > 0 ? { groups: params.groups } : {};
const autoJoinConfig =
params.snapshot.autoJoin !== "off" ? { autoJoin: params.snapshot.autoJoin } : {};
const autoJoinAllowlistConfig =
params.snapshot.autoJoin === "allowlist" && params.snapshot.autoJoinAllowlist.length > 0
? { autoJoinAllowlist: params.snapshot.autoJoinAllowlist }
: {};
const blockStreamingConfig =
params.overrides?.blockStreaming !== undefined
? { blockStreaming: params.snapshot.blockStreaming }
: {};
const chunkModeConfig =
params.snapshot.chunkMode !== undefined ? { chunkMode: params.snapshot.chunkMode } : {};
const execApprovalsConfig = buildMatrixQaAccountExecApprovalsConfig(
params.snapshot.execApprovals,
);
const streamingConfig =
params.overrides?.streaming !== undefined ? { streaming: params.overrides.streaming } : {};
const startupVerificationConfig =
params.snapshot.startupVerification !== undefined
? { startupVerification: params.snapshot.startupVerification }
: {};
const threadBindingsConfig =
params.overrides?.threadBindings !== undefined
? { threadBindings: params.snapshot.threadBindings }
: {};
const textChunkLimitConfig =
params.snapshot.textChunkLimit !== undefined
? { textChunkLimit: params.snapshot.textChunkLimit }
: {};
return {
accessToken: params.sutAccessToken,
...(params.sutDeviceId ? { deviceId: params.sutDeviceId } : {}),
dm: buildMatrixQaAccountDmConfig({
dmOverrides: params.overrides?.dm,
snapshot: params.snapshot,
}),
...(params.snapshot.allowBots !== undefined ? { allowBots: params.snapshot.allowBots } : {}),
enabled: true,
encryption: params.snapshot.encryption,
groupAllowFrom: params.snapshot.groupAllowFrom,
groupPolicy: params.snapshot.groupPolicy,
...groupsConfig,
homeserver: params.homeserver,
network: {
dangerouslyAllowPrivateNetwork: true,
},
replyToMode: params.snapshot.replyToMode,
...startupVerificationConfig,
...threadBindingsConfig,
threadReplies: params.snapshot.threadReplies,
userId: params.sutUserId,
...autoJoinConfig,
...autoJoinAllowlistConfig,
...blockStreamingConfig,
...chunkModeConfig,
...(execApprovalsConfig ? { execApprovals: execApprovalsConfig } : {}),
...streamingConfig,
...textChunkLimitConfig,
};
}
export function buildMatrixQaConfigSnapshot(params: {
driverUserId: string;
observerUserId: string;
overrides?: MatrixQaConfigOverrides;
sutUserId: string;
topology: MatrixQaProvisionedTopology;
}): MatrixQaConfigSnapshot {
return {
allowBots: params.overrides?.allowBots,
autoJoin: params.overrides?.autoJoin ?? "off",
autoJoinAllowlist: resolveMatrixQaAutoJoinAllowlist(params),
blockStreaming: params.overrides?.blockStreaming ?? false,
chunkMode: params.overrides?.chunkMode,
dm: resolveMatrixQaDmConfigSnapshot(params),
encryption: params.overrides?.encryption ?? false,
execApprovals: params.overrides?.execApprovals,
configuredBotRoles: [...(params.overrides?.configuredBotRoles ?? [])],
groupAllowFrom: resolveMatrixQaGroupAllowFrom(params),
groupMentionPatterns: normalizeMatrixQaAllowlist(params.overrides?.groupMentionPatterns),
groupPolicy: params.overrides?.groupPolicy ?? "allowlist",
groupsByKey: resolveMatrixQaGroupSnapshots({
overrides: params.overrides,
topology: params.topology,
}),
replyToMode: params.overrides?.replyToMode ?? "off",
startupVerification: params.overrides?.startupVerification,
streaming: resolveMatrixQaStreamingMode(params.overrides?.streaming),
streamingPreviewToolProgress: resolveMatrixQaStreamingPreviewToolProgress(
params.overrides?.streaming,
),
threadBindings: { ...params.overrides?.threadBindings },
textChunkLimit: params.overrides?.textChunkLimit,
threadReplies: params.overrides?.threadReplies ?? "inbound",
approvalForwarding: {
exec:
params.overrides?.approvalForwarding?.exec ?? params.overrides?.execApprovals !== undefined,
plugin: params.overrides?.approvalForwarding?.plugin ?? false,
},
};
}
export function summarizeMatrixQaConfigSnapshot(snapshot: MatrixQaConfigSnapshot) {
return [
`allowBots=${snapshot.allowBots ?? "<default>"}`,
`configuredBotRoles=${snapshot.configuredBotRoles.length > 0 ? snapshot.configuredBotRoles.join("|") : "<none>"}`,
`replyToMode=${snapshot.replyToMode}`,
`threadReplies=${snapshot.threadReplies}`,
`dm.enabled=${formatMatrixQaBoolean(snapshot.dm.enabled)}`,
`dm.policy=${snapshot.dm.policy}`,
`dm.sessionScope=${snapshot.dm.sessionScope}`,
`dm.threadReplies=${snapshot.dm.threadReplies}`,
`groupMentionPatterns=${snapshot.groupMentionPatterns.length > 0 ? snapshot.groupMentionPatterns.join("|") : "<default>"}`,
`streaming=${snapshot.streaming}`,
`streaming.preview.toolProgress=${formatMatrixQaBoolean(snapshot.streamingPreviewToolProgress)}`,
`textChunkLimit=${snapshot.textChunkLimit ?? "<default>"}`,
`chunkMode=${snapshot.chunkMode ?? "<default>"}`,
`execApprovals.enabled=${snapshot.execApprovals?.enabled ?? "<default>"}`,
`execApprovals.target=${snapshot.execApprovals?.target ?? "<default>"}`,
`blockStreaming=${formatMatrixQaBoolean(snapshot.blockStreaming)}`,
`autoJoin=${snapshot.autoJoin}`,
`encryption=${formatMatrixQaBoolean(snapshot.encryption)}`,
`startupVerification=${snapshot.startupVerification ?? "<default>"}`,
`threadBindings.enabled=${snapshot.threadBindings.enabled ?? "<default>"}`,
`threadBindings.spawnSessions=${snapshot.threadBindings.spawnSessions ?? "<default>"}`,
`approvals.exec.enabled=${formatMatrixQaBoolean(snapshot.approvalForwarding.exec)}`,
`approvals.plugin.enabled=${formatMatrixQaBoolean(snapshot.approvalForwarding.plugin)}`,
].join(", ");
}
export function buildMatrixQaConfig(
baseCfg: OpenClawConfig,
params: {
driverAccessToken?: string;
driverUserId: string;
homeserver: string;
observerAccessToken?: string;
observerUserId: string;
overrides?: MatrixQaConfigOverrides;
sutAccessToken: string;
sutAccountId: string;
sutDeviceId?: string;
sutUserId: string;
topology: MatrixQaProvisionedTopology;
},
): OpenClawConfig {
const pluginAllow = uniqueStrings([...(baseCfg.plugins?.allow ?? []), "matrix"]);
const snapshot = buildMatrixQaConfigSnapshot({
driverUserId: params.driverUserId,
observerUserId: params.observerUserId,
overrides: params.overrides,
sutUserId: params.sutUserId,
topology: params.topology,
});
const groups = buildMatrixQaGroupEntries(snapshot.groupsByKey);
const configuredBotAccounts = buildMatrixQaConfiguredBotAccounts({
driverAccessToken: params.driverAccessToken,
driverUserId: params.driverUserId,
homeserver: params.homeserver,
observerAccessToken: params.observerAccessToken,
observerUserId: params.observerUserId,
roles: snapshot.configuredBotRoles,
});
const approvalForwardingConfig =
snapshot.approvalForwarding.exec || snapshot.approvalForwarding.plugin
? {
approvals: {
...baseCfg.approvals,
...(snapshot.approvalForwarding.exec
? {
exec: {
...baseCfg.approvals?.exec,
enabled: true,
mode: "session" as const,
},
}
: {}),
...(snapshot.approvalForwarding.plugin
? {
plugin: {
...baseCfg.approvals?.plugin,
enabled: true,
mode: "session" as const,
},
}
: {}),
},
}
: {};
const toolsConfig =
params.overrides?.toolProfile || params.overrides?.audio
? {
...baseCfg.tools,
...(params.overrides?.toolProfile
? {
profile: params.overrides.toolProfile,
}
: {}),
...(params.overrides?.audio
? {
media: {
...baseCfg.tools?.media,
audio: {
...baseCfg.tools?.media?.audio,
...params.overrides.audio,
},
},
}
: {}),
}
: undefined;
return {
...baseCfg,
...approvalForwardingConfig,
...(toolsConfig
? {
tools: toolsConfig,
}
: {}),
...(params.overrides?.agentDefaults
? {
agents: {
...baseCfg.agents,
defaults: {
...baseCfg.agents?.defaults,
...params.overrides.agentDefaults,
},
},
}
: {}),
plugins: {
...baseCfg.plugins,
allow: pluginAllow,
entries: {
...baseCfg.plugins?.entries,
matrix: { enabled: true },
},
},
messages: {
...baseCfg.messages,
groupChat: {
...baseCfg.messages?.groupChat,
...(snapshot.groupMentionPatterns.length > 0
? { mentionPatterns: snapshot.groupMentionPatterns }
: {}),
visibleReplies: "automatic",
},
},
channels: {
...baseCfg.channels,
matrix: {
...baseCfg.channels?.matrix,
enabled: true,
defaultAccount: params.sutAccountId,
accounts: {
...baseCfg.channels?.matrix?.accounts,
...configuredBotAccounts,
[params.sutAccountId]: buildMatrixQaChannelAccountConfig({
groups,
homeserver: params.homeserver,
overrides: params.overrides,
snapshot,
sutAccessToken: params.sutAccessToken,
sutDeviceId: params.sutDeviceId,
sutUserId: params.sutUserId,
}),
},
},
},
};
}

View File

@@ -0,0 +1,78 @@
// Qa Matrix tests prove one differential probe against the Matrix substrate contract.
import { describe, expect, it } from "vitest";
import { runMatrixQaDifferentialProbe } from "./differential-probe.js";
function createProbeFetch(params?: {
missingStateErrcode?: string;
userId?: string;
}): typeof fetch {
return async (input) => {
const url = new URL(input instanceof Request ? input.url : input.toString());
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
if (url.pathname.endsWith("/versions")) {
return json({ versions: ["v1.11"] });
}
if (url.pathname.endsWith("/account/whoami")) {
return json({ user_id: params?.userId ?? "@probe:matrix.test" });
}
if (url.pathname.endsWith("/sync")) {
return json({ next_batch: url.searchParams.has("since") ? "sync-2" : "sync-1" });
}
return json({ errcode: params?.missingStateErrcode ?? "M_NOT_FOUND" }, 404);
};
}
describe("Matrix QA differential probe", () => {
it("runs unchanged against the Matrix substrate contract", async () => {
const result = await runMatrixQaDifferentialProbe({
accessToken: "token",
baseUrl: "http://matrix.test",
fetchImpl: createProbeFetch(),
roomId: "!probe:matrix.test",
userId: "@probe:matrix.test",
});
expect(result.profile).toBe("matrix-qa-v1");
expect(result.sync).toEqual({
continuity: true,
incrementalStatus: 200,
initialStatus: 200,
});
expect(result.steps.map((step) => [step.id, step.status])).toEqual([
["versions", 200],
["whoami", 200],
["sync-initial", 200],
["sync-incremental", 200],
["missing-state", 404],
]);
expect(result.steps.at(-1)?.errcode).toBe("M_NOT_FOUND");
});
it("rejects a mismatched whoami identity", async () => {
await expect(
runMatrixQaDifferentialProbe({
accessToken: "token",
baseUrl: "http://matrix.test",
fetchImpl: createProbeFetch({ userId: "@other:matrix.test" }),
roomId: "!probe:matrix.test",
userId: "@probe:matrix.test",
}),
).rejects.toThrow("unexpected user_id");
});
it("rejects a missing-state response with the wrong Matrix errcode", async () => {
await expect(
runMatrixQaDifferentialProbe({
accessToken: "token",
baseUrl: "http://matrix.test",
fetchImpl: createProbeFetch({ missingStateErrcode: "M_FORBIDDEN" }),
roomId: "!probe:matrix.test",
userId: "@probe:matrix.test",
}),
).rejects.toThrow("did not return M_NOT_FOUND");
});
});

View File

@@ -0,0 +1,174 @@
import { normalizeMatrixQaRoute } from "./recording-proxy.js";
// Qa Matrix plugin module probes Matrix substrates through one protocol contract.
import { requestMatrixJson, type MatrixQaFetchLike } from "./request.js";
const MATRIX_QA_DIFFERENTIAL_PROFILE = "matrix-qa-v1";
type MatrixQaProbeStep = {
errcode?: string;
id: string;
method: "GET";
responseFields: string[];
route: string;
status: number;
};
export type MatrixQaDifferentialProbeResult = {
profile: typeof MATRIX_QA_DIFFERENTIAL_PROFILE;
steps: MatrixQaProbeStep[];
sync: {
continuity: boolean;
incrementalStatus: number;
initialStatus: number;
};
};
function topLevelFields(value: unknown) {
return typeof value === "object" && value !== null && !Array.isArray(value)
? Object.keys(value).toSorted()
: [];
}
function errcode(value: unknown) {
if (typeof value !== "object" || value === null) {
return undefined;
}
const candidate = (value as { errcode?: unknown }).errcode;
return typeof candidate === "string" ? candidate : undefined;
}
function nextBatch(value: unknown) {
if (typeof value !== "object" || value === null) {
return undefined;
}
const candidate = (value as { next_batch?: unknown }).next_batch;
return typeof candidate === "string" ? candidate : undefined;
}
function userId(value: unknown) {
if (typeof value !== "object" || value === null) {
return undefined;
}
const candidate = (value as { user_id?: unknown }).user_id;
return typeof candidate === "string" ? candidate : undefined;
}
function buildStep(params: {
body: unknown;
endpoint: string;
id: string;
status: number;
}): MatrixQaProbeStep {
const code = errcode(params.body);
return {
...(code ? { errcode: code } : {}),
id: params.id,
method: "GET",
responseFields: topLevelFields(params.body),
route: normalizeMatrixQaRoute(new URL(params.endpoint, "http://matrix.test").pathname),
status: params.status,
};
}
export async function runMatrixQaDifferentialProbe(params: {
accessToken: string;
baseUrl: string;
fetchImpl?: MatrixQaFetchLike;
roomId: string;
userId: string;
}): Promise<MatrixQaDifferentialProbeResult> {
const fetchImpl = params.fetchImpl ?? fetch;
const versions = await requestMatrixJson<Record<string, unknown>>({
baseUrl: params.baseUrl,
endpoint: "/_matrix/client/versions",
fetchImpl,
method: "GET",
});
const whoami = await requestMatrixJson<Record<string, unknown>>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
endpoint: "/_matrix/client/v3/account/whoami",
fetchImpl,
method: "GET",
});
if (userId(whoami.body) !== params.userId) {
throw new Error("Matrix differential probe whoami returned an unexpected user_id");
}
const initialSync = await requestMatrixJson<Record<string, unknown>>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
endpoint: "/_matrix/client/v3/sync",
fetchImpl,
method: "GET",
query: { timeout: 0 },
});
const initialToken = nextBatch(initialSync.body);
if (!initialToken) {
throw new Error("Matrix differential probe initial sync did not return next_batch");
}
const incrementalSync = await requestMatrixJson<Record<string, unknown>>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
endpoint: "/_matrix/client/v3/sync",
fetchImpl,
method: "GET",
query: { since: initialToken, timeout: 0 },
});
const incrementalToken = nextBatch(incrementalSync.body);
if (!incrementalToken) {
throw new Error("Matrix differential probe incremental sync did not return next_batch");
}
const missingStateEndpoint = `/_matrix/client/v3/rooms/${encodeURIComponent(params.roomId)}/state/org.openclaw.qa.missing/${encodeURIComponent(params.userId)}`;
const missingState = await requestMatrixJson<Record<string, unknown>>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
endpoint: missingStateEndpoint,
fetchImpl,
method: "GET",
okStatuses: [404],
});
if (errcode(missingState.body) !== "M_NOT_FOUND") {
throw new Error("Matrix differential probe missing state did not return M_NOT_FOUND");
}
return {
profile: MATRIX_QA_DIFFERENTIAL_PROFILE,
steps: [
buildStep({
body: versions.body,
endpoint: "/_matrix/client/versions",
id: "versions",
status: versions.status,
}),
buildStep({
body: whoami.body,
endpoint: "/_matrix/client/v3/account/whoami",
id: "whoami",
status: whoami.status,
}),
buildStep({
body: initialSync.body,
endpoint: "/_matrix/client/v3/sync",
id: "sync-initial",
status: initialSync.status,
}),
buildStep({
body: incrementalSync.body,
endpoint: "/_matrix/client/v3/sync",
id: "sync-incremental",
status: incrementalSync.status,
}),
buildStep({
body: missingState.body,
endpoint: missingStateEndpoint,
id: "missing-state",
status: missingState.status,
}),
],
sync: {
continuity: Boolean(initialToken && incrementalToken),
incrementalStatus: incrementalSync.status,
initialStatus: initialSync.status,
},
};
}

View File

@@ -0,0 +1,76 @@
// Qa Matrix tests cover e2ee client plugin behavior.
import path from "node:path";
import { describe, expect, it } from "vitest";
import { testing } from "./e2ee-client.js";
describe("matrix qa e2ee client storage", () => {
it("filters receipt noise without suppressing room state or timeline events", () => {
expect(testing.MATRIX_QA_E2EE_SYNC_FILTER).toEqual({
room: {
ephemeral: { not_types: ["m.receipt"] },
},
});
});
it("shares persisted crypto and sync state by actor account", () => {
const first = testing.buildMatrixQaE2eeStoragePaths({
actorId: "driver",
outputDir: "/tmp/openclaw/.artifacts/qa-e2e/matrix-run",
scenarioId: "matrix-e2ee-basic-reply",
});
const second = testing.buildMatrixQaE2eeStoragePaths({
actorId: "driver",
outputDir: "/tmp/openclaw/.artifacts/qa-e2e/matrix-run",
scenarioId: "matrix-e2ee-qr-verification",
});
expect(first.accountDir).toBe(
path.join(
"/tmp/openclaw/.artifacts/qa-e2e/matrix-run",
"matrix-e2ee",
"accounts",
"driver",
"account",
),
);
expect(first.cryptoDatabasePrefix).toBe(second.cryptoDatabasePrefix);
expect(first.recoveryKeyPath).toBe(path.join(first.accountDir, "recovery-key.json"));
expect(first.storagePath).toBe(path.join(first.accountDir, "sync-store.json"));
expect(second.storagePath).toBe(first.storagePath);
});
it("records late-decrypted payload updates for an existing event id", () => {
const previous = {
eventId: "$reply",
kind: "message" as const,
roomId: "!room:matrix-qa.test",
sender: "@bot:matrix-qa.test",
type: "m.room.message",
};
expect(
testing.shouldRecordMatrixQaObservedEventUpdate({
previous,
next: {
...previous,
body: "MATRIX_QA_E2EE_CLI_GATEWAY_OK",
msgtype: "m.text",
},
}),
).toBe(true);
expect(
testing.shouldRecordMatrixQaObservedEventUpdate({
previous: {
...previous,
body: "MATRIX_QA_E2EE_CLI_GATEWAY_OK",
msgtype: "m.text",
},
next: {
...previous,
body: "MATRIX_QA_E2EE_CLI_GATEWAY_OK",
msgtype: "m.text",
},
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,588 @@
// Qa Matrix plugin module implements e2ee client behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import type {
EncryptedFile,
MatrixDeviceVerificationStatus,
MatrixClient,
MatrixOwnDeviceDeleteResult,
MatrixOwnDeviceInfo,
MatrixRawEvent,
MatrixRecoveryKeyVerificationResult,
MatrixRoomKeyBackupResetResult,
MatrixRoomKeyBackupRestoreResult,
MatrixVerificationBootstrapResult,
MatrixVerificationMethod,
MatrixVerificationSummary,
MessageEventContent,
} from "@openclaw/matrix/test-api.js";
import type {
OpenKeyedStoreOptions,
PluginStateEntry,
PluginStateKeyedStore,
PluginStateSyncKeyedStore,
} from "openclaw/plugin-sdk/plugin-state-runtime";
import { buildMatrixQaMessageContent } from "./client.js";
import { findMatrixQaObservedEventMatch, normalizeMatrixQaObservedEvent } from "./events.js";
import type { MatrixQaObservedEvent } from "./events.js";
import type { MatrixQaRoomEventWaitResult } from "./sync.js";
type MatrixQaE2eeActorId = "driver" | "observer" | `driver-${string}` | `cli-${string}`;
type MatrixQaE2eeRuntime = typeof import("@openclaw/matrix/test-api.js");
type MatrixQaE2eeClientParams = {
accessToken: string;
actorId: MatrixQaE2eeActorId;
baseUrl: string;
deviceId?: string;
outputDir: string;
password?: string;
scenarioId: string;
timeoutMs: number;
userId: string;
};
const MATRIX_QA_E2EE_SYNC_FILTER = {
room: {
ephemeral: { not_types: ["m.receipt"] },
},
};
type MatrixQaPluginStateValue = {
createdAt: number;
expiresAt?: number;
value: unknown;
};
const matrixQaPluginStateNamespaces = new Map<string, Map<string, MatrixQaPluginStateValue>>();
function resolveMatrixQaPluginStateNamespaceKey(options: OpenKeyedStoreOptions): string {
return `${options.env?.OPENCLAW_STATE_DIR ?? ""}\0${options.namespace}`;
}
function resolveMatrixQaPluginStateRows(
options: OpenKeyedStoreOptions,
): Map<string, MatrixQaPluginStateValue> {
const namespaceKey = resolveMatrixQaPluginStateNamespaceKey(options);
let rows = matrixQaPluginStateNamespaces.get(namespaceKey);
if (!rows) {
rows = new Map();
matrixQaPluginStateNamespaces.set(namespaceKey, rows);
}
return rows;
}
function pruneMatrixQaExpiredPluginState(rows: Map<string, MatrixQaPluginStateValue>): void {
const now = Date.now();
for (const [key, row] of rows) {
if (row.expiresAt !== undefined && row.expiresAt <= now) {
rows.delete(key);
}
}
}
function enforceMatrixQaPluginStateLimit(
rows: Map<string, MatrixQaPluginStateValue>,
maxEntries: number,
nextKey: string,
): void {
if (rows.has(nextKey)) {
return;
}
while (rows.size >= maxEntries) {
const oldest = [...rows.entries()].toSorted(
(a, b) => a[1].createdAt - b[1].createdAt || a[0].localeCompare(b[0]),
)[0]?.[0];
if (!oldest) {
return;
}
rows.delete(oldest);
}
}
function createMatrixQaPluginStateSyncKeyedStore<T>(
options: OpenKeyedStoreOptions,
): PluginStateSyncKeyedStore<T> {
const rows = resolveMatrixQaPluginStateRows(options);
const resolveExpiresAt = (ttlMs?: number) => {
const effectiveTtlMs = ttlMs ?? options.defaultTtlMs;
return effectiveTtlMs === undefined ? undefined : Date.now() + effectiveTtlMs;
};
const register = (key: string, value: T, opts?: { ttlMs?: number }) => {
pruneMatrixQaExpiredPluginState(rows);
enforceMatrixQaPluginStateLimit(rows, options.maxEntries, key);
rows.set(key, {
createdAt: rows.get(key)?.createdAt ?? Date.now(),
expiresAt: resolveExpiresAt(opts?.ttlMs),
value,
});
};
return {
register,
registerIfAbsent(key, value, opts) {
pruneMatrixQaExpiredPluginState(rows);
if (rows.has(key)) {
return false;
}
register(key, value, opts);
return true;
},
update(key, updateValue, opts) {
pruneMatrixQaExpiredPluginState(rows);
const next = updateValue(rows.get(key)?.value as T | undefined);
if (next === undefined) {
return false;
}
register(key, next, opts);
return true;
},
lookup(key) {
pruneMatrixQaExpiredPluginState(rows);
return rows.get(key)?.value as T | undefined;
},
consume(key) {
pruneMatrixQaExpiredPluginState(rows);
const value = rows.get(key)?.value as T | undefined;
rows.delete(key);
return value;
},
delete(key) {
pruneMatrixQaExpiredPluginState(rows);
return rows.delete(key);
},
entries() {
pruneMatrixQaExpiredPluginState(rows);
return [...rows.entries()].map(([key, row]): PluginStateEntry<T> => {
const entry: PluginStateEntry<T> = {
key,
value: row.value as T,
createdAt: row.createdAt,
};
if (row.expiresAt !== undefined) {
entry.expiresAt = row.expiresAt;
}
return entry;
});
},
clear() {
rows.clear();
},
};
}
function createMatrixQaPluginStateKeyedStore<T>(
options: OpenKeyedStoreOptions,
): PluginStateKeyedStore<T> {
const syncStore = createMatrixQaPluginStateSyncKeyedStore<T>(options);
return {
register: async (...args) => syncStore.register(...args),
registerIfAbsent: async (...args) => syncStore.registerIfAbsent(...args),
update: async (...args) => syncStore.update?.(...args) ?? false,
lookup: async (...args) => syncStore.lookup(...args),
consume: async (...args) => syncStore.consume(...args),
delete: async (...args) => syncStore.delete(...args),
entries: async () => syncStore.entries(),
clear: async () => syncStore.clear(),
};
}
function shouldRecordMatrixQaObservedEventUpdate(params: {
next: MatrixQaObservedEvent;
previous: MatrixQaObservedEvent | undefined;
}) {
const previous = params.previous;
if (!previous) {
return true;
}
const next = params.next;
return (
(previous.body === undefined && next.body !== undefined) ||
(previous.formattedBody === undefined && next.formattedBody !== undefined) ||
(previous.msgtype === undefined && next.msgtype !== undefined) ||
(previous.mentions === undefined && next.mentions !== undefined) ||
(previous.attachment === undefined && next.attachment !== undefined)
);
}
export type MatrixQaE2eeScenarioClient = {
acceptVerification(id: string): Promise<MatrixVerificationSummary>;
bootstrapOwnDeviceVerification(params?: {
allowAutomaticCrossSigningReset?: boolean;
forceResetCrossSigning?: boolean;
recoveryKey?: string;
verifyOwnIdentity?: boolean;
}): Promise<MatrixVerificationBootstrapResult>;
confirmVerificationReciprocateQr(id: string): Promise<MatrixVerificationSummary>;
confirmVerificationSas(id: string): Promise<MatrixVerificationSummary>;
deleteOwnDevices(deviceIds: string[]): Promise<MatrixOwnDeviceDeleteResult>;
generateVerificationQr(id: string): Promise<{ qrDataBase64: string }>;
getDeviceVerificationStatus(
userId: string,
deviceId: string,
): Promise<MatrixDeviceVerificationStatus>;
getRecoveryKey(): Promise<{
encodedPrivateKey?: string;
keyId?: string | null;
createdAt?: string;
} | null>;
listOwnDevices(): Promise<MatrixOwnDeviceInfo[]>;
listVerifications(): Promise<MatrixVerificationSummary[]>;
prime(): Promise<string | undefined>;
requestVerification(params: {
deviceId?: string;
ownUser?: boolean;
roomId?: string;
userId?: string;
}): Promise<MatrixVerificationSummary>;
resetRoomKeyBackup(params?: {
rotateRecoveryKey?: boolean;
}): Promise<MatrixRoomKeyBackupResetResult>;
restoreRoomKeyBackup(params?: {
recoveryKey?: string;
}): Promise<MatrixRoomKeyBackupRestoreResult>;
scanVerificationQr(id: string, qrDataBase64: string): Promise<MatrixVerificationSummary>;
verifyWithRecoveryKey(rawRecoveryKey: string): Promise<MatrixRecoveryKeyVerificationResult>;
sendTextMessage(opts: {
body: string;
mentionUserIds?: string[];
replyToEventId?: string;
roomId: string;
threadRootEventId?: string;
}): Promise<string>;
sendNoticeMessage(opts: {
body: string;
mentionUserIds?: string[];
roomId: string;
}): Promise<string>;
sendImageMessage(opts: {
body: string;
buffer: Buffer;
contentType: string;
fileName: string;
mentionUserIds?: string[];
roomId: string;
}): Promise<string>;
startVerification(
id: string,
method?: MatrixVerificationMethod,
): Promise<MatrixVerificationSummary>;
stop(): Promise<void>;
waitForOptionalRoomEvent(params: {
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
timeoutMs: number;
}): Promise<MatrixQaRoomEventWaitResult>;
waitForJoinedMember(params: { roomId: string; timeoutMs: number; userId: string }): Promise<void>;
waitForRoomEvent(params: {
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
timeoutMs: number;
}): Promise<{
event: MatrixQaObservedEvent;
since?: string;
}>;
};
export async function loadMatrixQaE2eeRuntime(): Promise<MatrixQaE2eeRuntime> {
const { loadQaRunnerBundledPluginTestApi } =
await import("openclaw/plugin-sdk/qa-runner-runtime");
return loadQaRunnerBundledPluginTestApi<MatrixQaE2eeRuntime>("matrix");
}
function buildMatrixQaE2eeStoragePaths(params: {
actorId: MatrixQaE2eeActorId;
outputDir: string;
scenarioId: string;
}) {
const rootDir = path.join(params.outputDir, "matrix-e2ee", "accounts", params.actorId);
const accountDir = path.join(rootDir, "account");
const runKey = path
.basename(params.outputDir)
.replace(/[^A-Za-z0-9_-]/g, "-")
.slice(-80);
const actorKey = params.actorId.replace(/[^A-Za-z0-9_-]/g, "-").slice(-40);
return {
accountDir,
cryptoDatabasePrefix: `qa-matrix-${runKey || "run"}-${actorKey || "actor"}`,
idbSnapshotPath: path.join(accountDir, "crypto-idb-snapshot.json"),
recoveryKeyPath: path.join(accountDir, "recovery-key.json"),
rootDir,
storagePath: path.join(accountDir, "sync-store.json"),
};
}
async function prepareMatrixQaE2eeStorage(params: {
actorId: MatrixQaE2eeActorId;
outputDir: string;
scenarioId: string;
}) {
const storage = buildMatrixQaE2eeStoragePaths(params);
await fs.mkdir(storage.rootDir, { recursive: true });
await fs.mkdir(storage.accountDir, { recursive: true });
await fs.mkdir(path.dirname(storage.storagePath), { recursive: true });
await fs.writeFile(storage.idbSnapshotPath, "[]\n", { flag: "wx" }).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
throw error;
}
});
return storage;
}
async function createMatrixQaE2eeMatrixClient(params: MatrixQaE2eeClientParams) {
const runtime = await loadMatrixQaE2eeRuntime();
const storage = await prepareMatrixQaE2eeStorage({
actorId: params.actorId,
outputDir: params.outputDir,
scenarioId: params.scenarioId,
});
runtime.setMatrixRuntime({
config: {
current: () => ({}),
mutateConfigFile: async () => ({}),
replaceConfigFile: async () => ({}),
},
state: {
resolveStateDir: () => params.outputDir,
openKeyedStore: <T>(options: OpenKeyedStoreOptions) =>
createMatrixQaPluginStateKeyedStore<T>(options),
openSyncKeyedStore: <T>(options: OpenKeyedStoreOptions) =>
createMatrixQaPluginStateSyncKeyedStore<T>(options),
},
} as never);
return new runtime.MatrixClient(params.baseUrl, params.accessToken, {
autoBootstrapCrypto: false,
cryptoDatabasePrefix: storage.cryptoDatabasePrefix,
deviceId: params.deviceId,
encryption: true,
idbSnapshotPath: storage.idbSnapshotPath,
localTimeoutMs: Math.max(10_000, params.timeoutMs),
password: params.password,
recoveryKeyPath: storage.recoveryKeyPath,
ssrfPolicy: { allowPrivateNetwork: true },
storageRootDir: path.dirname(storage.storagePath),
syncFilter: MATRIX_QA_E2EE_SYNC_FILTER,
userId: params.userId,
});
}
export async function createMatrixQaE2eeScenarioClient(
params: MatrixQaE2eeClientParams & {
observedEvents: MatrixQaObservedEvent[];
},
): Promise<MatrixQaE2eeScenarioClient> {
const client: MatrixClient = await createMatrixQaE2eeMatrixClient(params);
const localEvents: MatrixQaObservedEvent[] = [];
const verificationSummaries: MatrixVerificationSummary[] = [];
const observedEventsById = new Map<string, MatrixQaObservedEvent>();
let cursorIndex = 0;
const recordEvent = (roomId: string, event: MatrixRawEvent) => {
const normalized = normalizeMatrixQaObservedEvent(roomId, event);
if (
!normalized ||
!shouldRecordMatrixQaObservedEventUpdate({
next: normalized,
previous: observedEventsById.get(normalized.eventId),
})
) {
return;
}
observedEventsById.set(normalized.eventId, normalized);
localEvents.push(normalized);
params.observedEvents.push(normalized);
};
client.on("room.message", recordEvent);
const recordVerificationSummary = (summary: MatrixVerificationSummary) => {
verificationSummaries.push(summary);
};
client.on("verification.summary", recordVerificationSummary);
try {
await client.start({ readyTimeoutMs: Math.min(45_000, Math.max(15_000, params.timeoutMs)) });
} catch (error) {
await client.stopAndPersist().catch(() => undefined);
throw error;
}
const prime = async () => {
cursorIndex = Math.max(cursorIndex, localEvents.length);
return `e2ee:${cursorIndex}`;
};
const waitForOptionalRoomEvent: MatrixQaE2eeScenarioClient["waitForOptionalRoomEvent"] = async (
waitParams,
) => {
const startSince = `e2ee:${cursorIndex}`;
const startedAt = Date.now();
let scanIndex = cursorIndex;
while (Date.now() - startedAt < waitParams.timeoutMs) {
const matched = findMatrixQaObservedEventMatch({
cursorIndex: scanIndex,
events: localEvents,
predicate: waitParams.predicate,
roomId: waitParams.roomId,
});
if (matched) {
cursorIndex = Math.max(cursorIndex, matched.nextCursorIndex);
return {
event: matched.event,
matched: true,
since: `e2ee:${cursorIndex}`,
};
}
scanIndex = localEvents.length;
await sleep(Math.min(250, Math.max(25, waitParams.timeoutMs - (Date.now() - startedAt))));
}
cursorIndex = Math.max(cursorIndex, scanIndex);
return {
matched: false,
since: startSince,
};
};
const requireCrypto = () => {
if (!client.crypto) {
throw new Error("Matrix E2EE scenario requires Matrix crypto");
}
return client.crypto;
};
return {
async acceptVerification(id) {
return await requireCrypto().acceptVerification(id);
},
async bootstrapOwnDeviceVerification(opts) {
return await client.bootstrapOwnDeviceVerification(opts);
},
async confirmVerificationReciprocateQr(id) {
return await requireCrypto().confirmVerificationReciprocateQr(id);
},
async confirmVerificationSas(id) {
return await requireCrypto().confirmVerificationSas(id);
},
async deleteOwnDevices(deviceIds) {
return await client.deleteOwnDevices(deviceIds);
},
async generateVerificationQr(id) {
return await requireCrypto().generateVerificationQr(id);
},
async getDeviceVerificationStatus(userId, deviceId) {
return await client.getDeviceVerificationStatus(userId, deviceId);
},
async getRecoveryKey() {
return await requireCrypto().getRecoveryKey();
},
async listOwnDevices() {
return await client.listOwnDevices();
},
async listVerifications() {
const current = await requireCrypto().listVerifications();
return [...verificationSummaries, ...current].toSorted((a, b) =>
b.updatedAt.localeCompare(a.updatedAt),
);
},
prime,
async waitForJoinedMember(opts) {
const startedAt = Date.now();
while (Date.now() - startedAt < opts.timeoutMs) {
if (client.hasSyncedJoinedRoomMember(opts.roomId, opts.userId)) {
return;
}
await sleep(Math.min(250, Math.max(25, opts.timeoutMs - (Date.now() - startedAt))));
}
throw new Error(
`Matrix E2EE client did not sync joined membership for ${opts.userId} in ${opts.roomId}`,
);
},
async requestVerification(opts) {
return await requireCrypto().requestVerification(opts);
},
async resetRoomKeyBackup(paramsLocal) {
return await client.resetRoomKeyBackup(paramsLocal);
},
async restoreRoomKeyBackup(opts) {
return await client.restoreRoomKeyBackup(opts);
},
async scanVerificationQr(id, qrDataBase64) {
return await requireCrypto().scanVerificationQr(id, qrDataBase64);
},
async sendTextMessage(opts) {
return await client.sendMessage(
opts.roomId,
buildMatrixQaMessageContent(opts) as MessageEventContent,
);
},
async sendNoticeMessage(opts) {
return await client.sendMessage(opts.roomId, {
...buildMatrixQaMessageContent(opts),
msgtype: "m.notice",
} as MessageEventContent);
},
async sendImageMessage(opts) {
const encrypted = await requireCrypto().encryptMedia(opts.buffer);
const contentUri = await client.uploadContent(
encrypted.buffer,
opts.contentType,
opts.fileName,
);
const file: EncryptedFile = { url: contentUri, ...encrypted.file };
return await client.sendMessage(opts.roomId, {
...buildMatrixQaMessageContent({
body: opts.body,
mentionUserIds: opts.mentionUserIds,
}),
file,
filename: opts.fileName,
info: {
mimetype: opts.contentType,
size: opts.buffer.byteLength,
},
msgtype: "m.image",
} as MessageEventContent);
},
async startVerification(id, method) {
return await requireCrypto().startVerification(id, method);
},
async stop() {
client.off("room.message", recordEvent);
client.off("verification.summary", recordVerificationSummary);
await client.drainPendingDecryptions().catch(() => undefined);
await client.stopAndPersist();
},
waitForOptionalRoomEvent,
async waitForRoomEvent(waitParams) {
const result = await waitForOptionalRoomEvent(waitParams);
if (result.matched) {
return {
event: result.event,
since: result.since,
};
}
throw new Error(`timed out after ${waitParams.timeoutMs}ms waiting for Matrix E2EE event`);
},
async verifyWithRecoveryKey(rawRecoveryKey) {
return await client.verifyWithRecoveryKey(rawRecoveryKey);
},
};
}
export async function runMatrixQaE2eeBootstrap(
params: MatrixQaE2eeClientParams,
): Promise<MatrixVerificationBootstrapResult> {
const client: MatrixClient = await createMatrixQaE2eeMatrixClient(params);
try {
return await client.bootstrapOwnDeviceVerification();
} finally {
await client.stopAndPersist().catch(() => undefined);
}
}
export const testing = {
MATRIX_QA_E2EE_SYNC_FILTER,
buildMatrixQaE2eeStoragePaths,
findMatrixQaObservedEventMatch,
shouldRecordMatrixQaObservedEventUpdate,
};
export { testing as __testing };

View File

@@ -0,0 +1,355 @@
// Qa Matrix tests cover events plugin behavior.
import { describe, expect, it } from "vitest";
import { normalizeMatrixQaObservedEvent } from "./events.js";
describe("matrix observed event normalization", () => {
it("normalizes message events with thread metadata", () => {
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
event_id: "$event",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
origin_server_ts: 1_700_000_000_000,
content: {
body: "hello",
msgtype: "m.text",
"m.mentions": {
user_ids: ["@sut:matrix-qa.test"],
},
"m.relates_to": {
rel_type: "m.thread",
event_id: "$root",
is_falling_back: true,
"m.in_reply_to": {
event_id: "$driver",
},
},
},
}),
).toEqual({
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$event",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
originServerTs: 1_700_000_000_000,
body: "hello",
msgtype: "m.text",
relatesTo: {
relType: "m.thread",
eventId: "$root",
inReplyToId: "$driver",
isFallingBack: true,
},
mentions: {
userIds: ["@sut:matrix-qa.test"],
},
});
});
it("classifies Matrix notices separately from regular messages", () => {
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
event_id: "$notice",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: {
body: "notice",
msgtype: "m.notice",
},
}),
).toEqual({
kind: "notice",
roomId: "!room:matrix-qa.test",
eventId: "$notice",
sender: "@sut:matrix-qa.test",
stateKey: undefined,
type: "m.room.message",
originServerTs: undefined,
body: "notice",
formattedBody: undefined,
msgtype: "m.notice",
membership: undefined,
});
});
it("prefers m.new_content text for Matrix replacement events", () => {
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
event_id: "$replace",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: {
body: "* finalized",
msgtype: "m.text",
"m.new_content": {
body: "finalized",
msgtype: "m.text",
},
"m.relates_to": {
rel_type: "m.replace",
event_id: "$draft",
},
},
}),
).toEqual({
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$replace",
sender: "@sut:matrix-qa.test",
stateKey: undefined,
type: "m.room.message",
originServerTs: undefined,
body: "finalized",
formattedBody: undefined,
msgtype: "m.text",
membership: undefined,
relatesTo: {
eventId: "$draft",
inReplyToId: undefined,
isFallingBack: undefined,
relType: "m.replace",
},
});
});
it("normalizes Matrix reaction events with target metadata", () => {
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
event_id: "$reaction",
sender: "@driver:matrix-qa.test",
type: "m.reaction",
origin_server_ts: 1_700_000_000_000,
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$msg",
key: "👍",
},
},
}),
).toEqual({
kind: "reaction",
roomId: "!room:matrix-qa.test",
eventId: "$reaction",
sender: "@driver:matrix-qa.test",
type: "m.reaction",
originServerTs: 1_700_000_000_000,
relatesTo: {
eventId: "$msg",
relType: "m.annotation",
},
reaction: {
eventId: "$msg",
key: "👍",
},
});
});
it("summarizes Matrix approval metadata without dumping full command text", () => {
const commandText = `printf ${"A".repeat(300)}`;
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
event_id: "$approval",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: {
body: "React here: ✅ Allow once, ❌ Deny",
msgtype: "m.text",
"com.openclaw.approval": {
allowedDecisions: ["allow-once", "deny"],
commandText,
id: "approval-1",
kind: "exec",
state: "pending",
type: "approval.request",
version: 1,
},
},
}),
).toEqual({
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$approval",
sender: "@sut:matrix-qa.test",
stateKey: undefined,
type: "m.room.message",
originServerTs: undefined,
body: "React here: ✅ Allow once, ❌ Deny",
formattedBody: undefined,
msgtype: "m.text",
membership: undefined,
approval: {
allowedDecisions: ["allow-once", "deny"],
commandTextPreview: commandText.slice(0, 160),
hasCommandText: true,
id: "approval-1",
kind: "exec",
state: "pending",
type: "approval.request",
version: 1,
},
});
});
it("summarizes Matrix plugin approval metadata fields", () => {
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
event_id: "$plugin-approval",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: {
body: "Plugin approval required",
msgtype: "m.text",
"com.openclaw.approval": {
agentId: "qa",
allowedDecisions: ["allow-once", "deny"],
id: "plugin:approval-1",
kind: "plugin",
pluginId: "qa-plugin",
severity: "medium",
state: "pending",
toolName: "qa_tool",
type: "approval.request",
version: 1,
},
},
}),
).toEqual({
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$plugin-approval",
sender: "@sut:matrix-qa.test",
stateKey: undefined,
type: "m.room.message",
originServerTs: undefined,
body: "Plugin approval required",
formattedBody: undefined,
msgtype: "m.text",
membership: undefined,
approval: {
agentId: "qa",
allowedDecisions: ["allow-once", "deny"],
id: "plugin:approval-1",
kind: "plugin",
pluginId: "qa-plugin",
severity: "medium",
state: "pending",
toolName: "qa_tool",
type: "approval.request",
version: 1,
},
});
});
it("normalizes Matrix image messages with attachment metadata", () => {
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
event_id: "$image",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: {
body: "Protocol note: generated the QA lighthouse image successfully.",
filename: "qa-lighthouse.png",
msgtype: "m.image",
},
}),
).toEqual({
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$image",
sender: "@sut:matrix-qa.test",
stateKey: undefined,
type: "m.room.message",
originServerTs: undefined,
body: "Protocol note: generated the QA lighthouse image successfully.",
formattedBody: undefined,
msgtype: "m.image",
membership: undefined,
attachment: {
kind: "image",
caption: "Protocol note: generated the QA lighthouse image successfully.",
filename: "qa-lighthouse.png",
},
});
});
it("treats filename-like Matrix media bodies as attachment filenames", () => {
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
event_id: "$image",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: {
body: "qa-lighthouse.png",
msgtype: "m.image",
},
}),
).toEqual({
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$image",
sender: "@sut:matrix-qa.test",
stateKey: undefined,
type: "m.room.message",
originServerTs: undefined,
body: "qa-lighthouse.png",
formattedBody: undefined,
msgtype: "m.image",
membership: undefined,
attachment: {
kind: "image",
filename: "qa-lighthouse.png",
},
});
});
it("normalizes membership events with explicit membership kind", () => {
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
event_id: "$membership",
sender: "@driver:matrix-qa.test",
state_key: "@sut:matrix-qa.test",
type: "m.room.member",
content: {
membership: "leave",
},
}),
).toEqual({
kind: "membership",
roomId: "!room:matrix-qa.test",
eventId: "$membership",
sender: "@driver:matrix-qa.test",
stateKey: "@sut:matrix-qa.test",
type: "m.room.member",
originServerTs: undefined,
body: undefined,
formattedBody: undefined,
msgtype: undefined,
membership: "leave",
});
});
it("classifies Matrix redactions without needing raw event inspection", () => {
expect(
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
event_id: "$redaction",
sender: "@driver:matrix-qa.test",
type: "m.room.redaction",
content: {},
}),
).toEqual({
kind: "redaction",
roomId: "!room:matrix-qa.test",
eventId: "$redaction",
sender: "@driver:matrix-qa.test",
stateKey: undefined,
type: "m.room.redaction",
originServerTs: undefined,
body: undefined,
formattedBody: undefined,
msgtype: undefined,
membership: undefined,
});
});
});

View File

@@ -0,0 +1,319 @@
// Qa Matrix plugin module implements events behavior.
export type MatrixQaRoomEvent = {
content?: Record<string, unknown>;
event_id?: string;
origin_server_ts?: number;
sender?: string;
state_key?: string;
type?: string;
};
type MatrixQaObservedEventKind =
| "membership"
| "message"
| "notice"
| "redaction"
| "reaction"
| "room-event";
type MatrixQaObservedEventAttachment = {
caption?: string;
filename?: string;
kind: "audio" | "file" | "image" | "sticker" | "video";
};
type MatrixQaObservedApproval = {
agentId?: string;
allowedDecisions?: string[];
commandTextPreview?: string;
hasCommandText?: boolean;
id: string;
kind: "exec" | "plugin";
pluginId?: string;
severity?: string;
state?: string;
toolName?: string;
type?: string;
version?: number;
};
export type MatrixQaObservedEvent = {
kind: MatrixQaObservedEventKind;
roomId: string;
eventId: string;
sender?: string;
stateKey?: string;
type: string;
originServerTs?: number;
body?: string;
formattedBody?: string;
msgtype?: string;
membership?: string;
relatesTo?: {
eventId?: string;
inReplyToId?: string;
isFallingBack?: boolean;
relType?: string;
};
mentions?: {
room?: boolean;
userIds?: string[];
};
reaction?: {
eventId?: string;
key?: string;
};
attachment?: MatrixQaObservedEventAttachment;
approval?: MatrixQaObservedApproval;
};
const MATRIX_QA_APPROVAL_METADATA_KEY = "com.openclaw.approval";
const MATRIX_QA_APPROVAL_COMMAND_PREVIEW_CHARS = 160;
function normalizeMentionUserIds(value: unknown) {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
: undefined;
}
function resolveMatrixQaMessageContent(
content: Record<string, unknown>,
relatesTo: Record<string, unknown> | null,
) {
const newContentRaw = content["m.new_content"];
const newContent =
typeof newContentRaw === "object" && newContentRaw !== null
? (newContentRaw as Record<string, unknown>)
: null;
if (relatesTo?.rel_type === "m.replace" && newContent) {
return newContent;
}
return content;
}
function resolveMatrixQaObservedEventKind(params: { msgtype?: string; type: string }) {
if (params.type === "m.reaction") {
return "reaction" as const;
}
if (params.type === "m.room.redaction") {
return "redaction" as const;
}
if (params.type === "m.room.member") {
return "membership" as const;
}
if (params.type === "m.room.message") {
return params.msgtype === "m.notice" ? ("notice" as const) : ("message" as const);
}
return "room-event" as const;
}
function resolveMatrixQaAttachmentKind(msgtype: string | undefined) {
switch (msgtype) {
case "m.audio":
return "audio" as const;
case "m.file":
return "file" as const;
case "m.image":
return "image" as const;
case "m.sticker":
return "sticker" as const;
case "m.video":
return "video" as const;
default:
return undefined;
}
}
function isLikelyMatrixQaFilenameBody(value: string) {
return !value.includes("\n") && /\.[a-z0-9][a-z0-9._-]{0,24}$/i.test(value);
}
function resolveMatrixQaAttachmentSummary(params: {
body?: string;
filename?: string;
msgtype?: string;
}): MatrixQaObservedEventAttachment | undefined {
const kind = resolveMatrixQaAttachmentKind(params.msgtype);
if (!kind) {
return undefined;
}
const body = params.body?.trim() ?? "";
const explicitFilename = params.filename?.trim() ?? "";
const inferredFilename =
!explicitFilename && body && isLikelyMatrixQaFilenameBody(body) ? body : "";
const filename = explicitFilename || inferredFilename;
const caption = body && body !== filename ? body : "";
return {
kind,
...(caption ? { caption } : {}),
...(filename ? { filename } : {}),
};
}
function normalizeMatrixQaApprovalAllowedDecisions(value: unknown) {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
: undefined;
}
function normalizeMatrixQaApprovalMetadata(value: unknown): MatrixQaObservedApproval | undefined {
if (typeof value !== "object" || value === null) {
return undefined;
}
const metadata = value as Record<string, unknown>;
const id = typeof metadata.id === "string" ? metadata.id.trim() : "";
const kind = metadata.kind;
if (!id || (kind !== "exec" && kind !== "plugin")) {
return undefined;
}
const commandText =
typeof metadata.commandText === "string" ? metadata.commandText.trim() : undefined;
const commandPreview =
typeof metadata.commandPreview === "string" ? metadata.commandPreview.trim() : undefined;
const commandTextPreview = (commandPreview || commandText)?.slice(
0,
MATRIX_QA_APPROVAL_COMMAND_PREVIEW_CHARS,
);
return {
id,
kind,
...(typeof metadata.agentId === "string" ? { agentId: metadata.agentId } : {}),
...(typeof metadata.state === "string" ? { state: metadata.state } : {}),
...(typeof metadata.type === "string" ? { type: metadata.type } : {}),
...(typeof metadata.version === "number" ? { version: metadata.version } : {}),
...(metadata.allowedDecisions
? { allowedDecisions: normalizeMatrixQaApprovalAllowedDecisions(metadata.allowedDecisions) }
: {}),
...(commandText ? { hasCommandText: true } : {}),
...(commandTextPreview ? { commandTextPreview } : {}),
...(kind === "plugin" && typeof metadata.pluginId === "string"
? { pluginId: metadata.pluginId }
: {}),
...(kind === "plugin" && typeof metadata.severity === "string"
? { severity: metadata.severity }
: {}),
...(kind === "plugin" && typeof metadata.toolName === "string"
? { toolName: metadata.toolName }
: {}),
};
}
export function normalizeMatrixQaObservedEvent(
roomId: string,
event: MatrixQaRoomEvent,
): MatrixQaObservedEvent | null {
const eventId = event.event_id?.trim();
const type = event.type?.trim();
if (!eventId || !type) {
return null;
}
const content = event.content ?? {};
const msgtype = typeof content.msgtype === "string" ? content.msgtype : undefined;
const relatesToRaw = content["m.relates_to"];
const relatesTo =
typeof relatesToRaw === "object" && relatesToRaw !== null
? (relatesToRaw as Record<string, unknown>)
: null;
const inReplyToRaw = relatesTo?.["m.in_reply_to"];
const inReplyTo =
typeof inReplyToRaw === "object" && inReplyToRaw !== null
? (inReplyToRaw as Record<string, unknown>)
: null;
const messageContent = resolveMatrixQaMessageContent(content, relatesTo);
const normalizedMsgtype =
typeof messageContent.msgtype === "string" ? messageContent.msgtype : msgtype;
const normalizedFilename =
typeof messageContent.filename === "string"
? messageContent.filename
: typeof content.filename === "string"
? content.filename
: undefined;
const mentionsRaw = messageContent["m.mentions"] ?? content["m.mentions"];
const mentions =
typeof mentionsRaw === "object" && mentionsRaw !== null
? (mentionsRaw as Record<string, unknown>)
: null;
const mentionUserIds = normalizeMentionUserIds(mentions?.user_ids);
const reactionKey =
type === "m.reaction" && typeof relatesTo?.key === "string" ? relatesTo.key : undefined;
const reactionEventId =
type === "m.reaction" && typeof relatesTo?.event_id === "string"
? relatesTo.event_id
: undefined;
const attachment = resolveMatrixQaAttachmentSummary({
body: typeof messageContent.body === "string" ? messageContent.body : undefined,
filename: normalizedFilename,
msgtype: normalizedMsgtype,
});
const approval = normalizeMatrixQaApprovalMetadata(
messageContent[MATRIX_QA_APPROVAL_METADATA_KEY] ?? content[MATRIX_QA_APPROVAL_METADATA_KEY],
);
return {
kind: resolveMatrixQaObservedEventKind({ msgtype: normalizedMsgtype, type }),
roomId,
eventId,
sender: typeof event.sender === "string" ? event.sender : undefined,
stateKey: typeof event.state_key === "string" ? event.state_key : undefined,
type,
originServerTs:
typeof event.origin_server_ts === "number" ? Math.floor(event.origin_server_ts) : undefined,
body: typeof messageContent.body === "string" ? messageContent.body : undefined,
formattedBody:
typeof messageContent.formatted_body === "string" ? messageContent.formatted_body : undefined,
msgtype: normalizedMsgtype,
membership: typeof content.membership === "string" ? content.membership : undefined,
...(relatesTo
? {
relatesTo: {
eventId: typeof relatesTo.event_id === "string" ? relatesTo.event_id : undefined,
inReplyToId: typeof inReplyTo?.event_id === "string" ? inReplyTo.event_id : undefined,
isFallingBack:
typeof relatesTo.is_falling_back === "boolean"
? relatesTo.is_falling_back
: undefined,
relType: typeof relatesTo.rel_type === "string" ? relatesTo.rel_type : undefined,
},
}
: {}),
...(mentions
? {
mentions: {
...(mentions.room === true ? { room: true } : {}),
...(mentionUserIds ? { userIds: mentionUserIds } : {}),
},
}
: {}),
...(reactionEventId || reactionKey
? {
reaction: {
...(reactionEventId ? { eventId: reactionEventId } : {}),
...(reactionKey ? { key: reactionKey } : {}),
},
}
: {}),
...(attachment ? { attachment } : {}),
...(approval ? { approval } : {}),
};
}
export function findMatrixQaObservedEventMatch(params: {
cursorIndex: number;
events: MatrixQaObservedEvent[];
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
}) {
for (let index = params.cursorIndex; index < params.events.length; index += 1) {
const event = params.events[index];
if (event?.roomId !== params.roomId) {
continue;
}
if (params.predicate(event)) {
return {
event,
nextCursorIndex: index + 1,
};
}
}
return undefined;
}

View File

@@ -0,0 +1,241 @@
// Qa Matrix tests cover fault proxy plugin behavior.
import { createServer } from "node:http";
import { gzipSync } from "node:zlib";
import { afterEach, describe, expect, it } from "vitest";
import { startMatrixQaFaultProxy, type MatrixQaFaultProxy } from "./fault-proxy.js";
const servers: Array<{ close(): Promise<void> }> = [];
async function startTargetServer(params?: {
responseBody?: Buffer | string;
responseHeaders?: Record<string, string>;
}) {
const requests: Array<{
authorization?: string;
body: string;
method: string;
url: string;
}> = [];
const server = createServer((req, res) => {
void (async () => {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
requests.push({
...(req.headers.authorization ? { authorization: req.headers.authorization } : {}),
body: Buffer.concat(chunks).toString("utf8"),
method: req.method ?? "GET",
url: req.url ?? "/",
});
res.writeHead(200, { "content-type": "application/json", ...params?.responseHeaders });
res.end(params?.responseBody ?? JSON.stringify({ forwarded: true }));
})();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("target server did not bind to a TCP port");
}
const handle = {
baseUrl: `http://127.0.0.1:${address.port}`,
close: async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
},
requests,
};
servers.push(handle);
return handle;
}
describe("Matrix QA fault proxy", () => {
let proxy: MatrixQaFaultProxy | undefined;
afterEach(async () => {
await proxy?.stop();
proxy = undefined;
while (servers.length > 0) {
await servers.pop()?.close();
}
});
it("faults matching Matrix requests and forwards everything else", async () => {
const target = await startTargetServer();
proxy = await startMatrixQaFaultProxy({
targetBaseUrl: target.baseUrl,
rules: [
{
id: "room-key-backup-version-unavailable",
match: (request) =>
request.method === "GET" &&
request.path === "/_matrix/client/v3/room_keys/version" &&
request.bearerToken === "driver-token",
response: () => ({
body: {
errcode: "M_NOT_FOUND",
error: "No current key backup",
},
status: 404,
}),
},
],
});
const faulted = await fetch(`${proxy.baseUrl}/_matrix/client/v3/room_keys/version`, {
headers: { authorization: "Bearer driver-token" },
});
expect(faulted.status).toBe(404);
await expect(faulted.json()).resolves.toEqual({
errcode: "M_NOT_FOUND",
error: "No current key backup",
});
const forwarded = await fetch(`${proxy.baseUrl}/_matrix/client/v3/sync?timeout=0`, {
body: JSON.stringify({ ok: true }),
headers: {
authorization: "Bearer driver-token",
"content-type": "application/json",
},
method: "POST",
});
expect(forwarded.status).toBe(200);
await expect(forwarded.json()).resolves.toEqual({ forwarded: true });
expect(proxy.hits()).toEqual([
{
method: "GET",
path: "/_matrix/client/v3/room_keys/version",
ruleId: "room-key-backup-version-unavailable",
},
]);
expect(target.requests).toEqual([
{
authorization: "Bearer driver-token",
body: '{"ok":true}',
method: "POST",
url: "/_matrix/client/v3/sync?timeout=0",
},
]);
});
it("strips stale content-encoding after buffering decoded bodies", async () => {
const body = Buffer.from(JSON.stringify({ forwarded: true }));
const target = await startTargetServer({
responseBody: gzipSync(body),
responseHeaders: {
"content-encoding": "gzip",
"content-length": String(gzipSync(body).byteLength),
},
});
proxy = await startMatrixQaFaultProxy({ targetBaseUrl: target.baseUrl, rules: [] });
const response = await fetch(`${proxy.baseUrl}/encoded`);
expect(response.headers.get("content-encoding")).toBeNull();
expect(response.headers.get("content-length")).toBeNull();
await expect(response.json()).resolves.toEqual({ forwarded: true });
});
it("mutates matching forwarded Matrix responses", async () => {
const target = await startTargetServer();
proxy = await startMatrixQaFaultProxy({
targetBaseUrl: target.baseUrl,
rules: [
{
id: "sync-state-after",
match: (request) =>
request.method === "GET" &&
request.path === "/_matrix/client/v3/sync" &&
request.search.includes("org.matrix.msc4222.use_state_after=true"),
mutateResponse: ({ response }) => ({
...response,
body: Buffer.from(JSON.stringify({ forwarded: true, mutated: true })),
}),
},
],
});
const mutated = await fetch(
`${proxy.baseUrl}/_matrix/client/v3/sync?timeout=0&org.matrix.msc4222.use_state_after=true`,
{
headers: { authorization: "Bearer driver-token" },
},
);
expect(mutated.status).toBe(200);
await expect(mutated.json()).resolves.toEqual({ forwarded: true, mutated: true });
expect(proxy.hits()).toEqual([
{
method: "GET",
path: "/_matrix/client/v3/sync",
ruleId: "sync-state-after",
},
]);
expect(target.requests).toEqual([
{
authorization: "Bearer driver-token",
body: "",
method: "GET",
url: "/_matrix/client/v3/sync?timeout=0&org.matrix.msc4222.use_state_after=true",
},
]);
});
it("rejects oversized forwarded request bodies before contacting the target", async () => {
const target = await startTargetServer();
proxy = await startMatrixQaFaultProxy({
maxRequestBytes: 4,
targetBaseUrl: target.baseUrl,
rules: [],
});
const rejected = await fetch(`${proxy.baseUrl}/_matrix/client/v3/send`, {
body: "12345",
method: "POST",
});
expect(rejected.status).toBe(413);
expect(rejected.headers.get("connection")).toBe("close");
await expect(rejected.json()).resolves.toMatchObject({
errcode: "MATRIX_QA_FAULT_PROXY_REQUEST_TOO_LARGE",
});
expect(target.requests).toEqual([]);
});
it("rejects oversized forwarded Matrix responses without buffering the full body", async () => {
const target = await startTargetServer({ responseBody: JSON.stringify({ payload: "large" }) });
proxy = await startMatrixQaFaultProxy({
maxResponseBytes: 8,
targetBaseUrl: target.baseUrl,
rules: [],
});
const rejected = await fetch(`${proxy.baseUrl}/_matrix/client/v3/sync`);
expect(rejected.status).toBe(502);
await expect(rejected.json()).resolves.toMatchObject({
errcode: "MATRIX_QA_FAULT_PROXY_RESPONSE_TOO_LARGE",
});
expect(target.requests).toEqual([
{
body: "",
method: "GET",
url: "/_matrix/client/v3/sync",
},
]);
});
});

View File

@@ -0,0 +1,451 @@
// Qa Matrix plugin module implements fault proxy behavior.
import {
createServer,
type IncomingHttpHeaders,
type IncomingMessage,
type ServerResponse,
} from "node:http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
const DEFAULT_FAULT_PROXY_REQUEST_MAX_BYTES = 20 * 1024 * 1024;
const DEFAULT_FAULT_PROXY_RESPONSE_MAX_BYTES = 20 * 1024 * 1024;
const HOP_BY_HOP_HEADERS = new Set([
"connection",
"content-length",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]);
export type MatrixQaFaultProxyRequest = {
bearerToken?: string;
body: Buffer;
headers: IncomingHttpHeaders;
method: string;
path: string;
search: string;
};
type MatrixQaFaultProxyResponse = {
body?: unknown;
headers?: Record<string, string>;
status: number;
};
export type MatrixQaFaultProxyForwardedResponse = {
body: Buffer;
headers: Headers;
status: number;
};
export type MatrixQaFaultProxyExchange = {
context?: unknown;
request: MatrixQaFaultProxyRequest;
response: MatrixQaFaultProxyForwardedResponse;
};
export type MatrixQaFaultProxyObserver = {
createExchangeContext?: (request: MatrixQaFaultProxyRequest) => unknown;
onExchange?: (exchange: MatrixQaFaultProxyExchange) => Promise<void> | void;
};
class MatrixQaFaultProxyHttpError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
this.name = "MatrixQaFaultProxyHttpError";
}
}
export type MatrixQaFaultProxyRule = {
id: string;
match(request: MatrixQaFaultProxyRequest): boolean;
mutateResponse?(params: {
request: MatrixQaFaultProxyRequest;
response: MatrixQaFaultProxyForwardedResponse;
}): MatrixQaFaultProxyForwardedResponse | Promise<MatrixQaFaultProxyForwardedResponse>;
response?(request: MatrixQaFaultProxyRequest): MatrixQaFaultProxyResponse;
};
export type MatrixQaFaultProxyHit = {
method: string;
path: string;
ruleId: string;
};
export type MatrixQaFaultProxy = {
baseUrl: string;
hits(): MatrixQaFaultProxyHit[];
stop(): Promise<void>;
};
function normalizeHeaderValue(value: string | string[] | undefined) {
if (Array.isArray(value)) {
return value.join(", ");
}
return value;
}
function extractBearerToken(headers: IncomingHttpHeaders) {
const value = normalizeHeaderValue(headers.authorization)?.trim();
const match = /^Bearer\s+(.+)$/i.exec(value ?? "");
return match?.[1];
}
function buildFetchHeaders(headers: IncomingHttpHeaders) {
const result = new Headers();
for (const [key, rawValue] of Object.entries(headers)) {
if (HOP_BY_HOP_HEADERS.has(key.toLowerCase()) || key.toLowerCase() === "host") {
continue;
}
const value = normalizeHeaderValue(rawValue);
if (value !== undefined) {
result.set(key, value);
}
}
return result;
}
function normalizeByteChunk(chunk: string | Buffer): Buffer {
return typeof chunk === "string" ? Buffer.from(chunk) : chunk;
}
function rejectOversizedRequestBody(maxBytes: number, size: number) {
return new MatrixQaFaultProxyHttpError(
413,
"MATRIX_QA_FAULT_PROXY_REQUEST_TOO_LARGE",
`Matrix QA fault proxy request body exceeds ${maxBytes} bytes (got at least ${size})`,
);
}
function rejectAbortedRequestBody() {
return new MatrixQaFaultProxyHttpError(
400,
"MATRIX_QA_FAULT_PROXY_REQUEST_ABORTED",
"Matrix QA fault proxy request body ended before upload completed",
);
}
function drainRejectedRequestBody(req: IncomingMessage) {
const onError = () => undefined;
const onClose = () => {
req.off("error", onError);
};
req.on("error", onError);
req.once("close", onClose);
req.resume();
}
async function readRequestBody(req: IncomingMessage, maxBytes: number) {
const contentLength = normalizeHeaderValue(req.headers["content-length"]);
if (contentLength !== undefined) {
const size = Number(contentLength);
if (Number.isFinite(size) && size > maxBytes) {
drainRejectedRequestBody(req);
throw rejectOversizedRequestBody(maxBytes, size);
}
}
return await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
let total = 0;
let settled = false;
const cleanup = () => {
req.off("data", onData);
req.off("end", onEnd);
req.off("error", onError);
req.off("aborted", onAborted);
req.off("close", onClose);
};
const stopReading = () => {
req.off("data", onData);
req.off("end", onEnd);
req.off("aborted", onAborted);
};
const settleReject = (error: Error, options?: { drain?: boolean }) => {
if (settled) {
return;
}
settled = true;
if (options?.drain) {
stopReading();
req.resume();
} else {
cleanup();
}
reject(error);
};
const onData = (chunk: string | Buffer) => {
const buffer = normalizeByteChunk(chunk);
const nextTotal = total + buffer.byteLength;
if (nextTotal > maxBytes) {
settleReject(rejectOversizedRequestBody(maxBytes, nextTotal), { drain: true });
return;
}
chunks.push(buffer);
total = nextTotal;
};
const onEnd = () => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve(Buffer.concat(chunks, total));
};
const onError = (error: Error) => {
if (settled) {
cleanup();
return;
}
settleReject(error);
};
const onAborted = () => {
settleReject(rejectAbortedRequestBody());
};
const onClose = () => {
if (settled) {
cleanup();
return;
}
if (!req.complete) {
settleReject(rejectAbortedRequestBody());
return;
}
cleanup();
};
req.on("data", onData);
req.once("end", onEnd);
req.once("error", onError);
req.once("aborted", onAborted);
req.once("close", onClose);
});
}
function bufferToArrayBuffer(buffer: Buffer) {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
) as ArrayBuffer;
}
function normalizeJsonResponse(
response: MatrixQaFaultProxyResponse,
): MatrixQaFaultProxyForwardedResponse {
const body =
response.body === undefined ? Buffer.alloc(0) : Buffer.from(JSON.stringify(response.body));
return {
body,
headers: new Headers({
"content-type": "application/json",
...response.headers,
}),
status: response.status,
};
}
async function forwardMatrixQaFaultProxyRequest(params: {
body: Buffer;
maxResponseBytes: number;
req: IncomingMessage;
targetUrl: URL;
}): Promise<MatrixQaFaultProxyForwardedResponse> {
const method = params.req.method ?? "GET";
const init: RequestInit = {
headers: buildFetchHeaders(params.req.headers),
method,
redirect: "manual",
};
if (method !== "GET" && method !== "HEAD") {
init.body = bufferToArrayBuffer(params.body);
}
const { response, release } = await fetchWithSsrFGuard({
url: params.targetUrl.toString(),
init,
policy: { allowPrivateNetwork: true },
auditContext: "qa-matrix-fault-proxy-forward",
});
try {
return {
body: await readResponseWithLimit(response, params.maxResponseBytes, {
onOverflow: ({ size }) =>
new MatrixQaFaultProxyHttpError(
502,
"MATRIX_QA_FAULT_PROXY_RESPONSE_TOO_LARGE",
`Matrix QA fault proxy upstream response exceeds ${params.maxResponseBytes} bytes (got at least ${size})`,
),
}),
headers: response.headers,
status: response.status,
};
} finally {
await release();
}
}
function writeForwardedResponse(
res: ServerResponse,
response: MatrixQaFaultProxyForwardedResponse,
options: { preserveConnectionClose?: boolean } = {},
) {
const headers: Record<string, string> = {};
for (const [key, value] of response.headers) {
const normalizedKey = key.toLowerCase();
const isIntentionalConnectionClose =
options.preserveConnectionClose && normalizedKey === "connection" && value === "close";
if (
(!HOP_BY_HOP_HEADERS.has(normalizedKey) || isIntentionalConnectionClose) &&
normalizedKey !== "content-encoding" &&
normalizedKey !== "content-length"
) {
headers[key] = value;
}
}
res.writeHead(response.status, headers);
res.end(response.body);
}
export async function startMatrixQaFaultProxy(
params: MatrixQaFaultProxyObserver & {
maxRequestBytes?: number;
maxResponseBytes?: number;
rules: MatrixQaFaultProxyRule[];
targetBaseUrl: string;
},
): Promise<MatrixQaFaultProxy> {
const targetBaseUrl = new URL(params.targetBaseUrl);
const maxRequestBytes = params.maxRequestBytes ?? DEFAULT_FAULT_PROXY_REQUEST_MAX_BYTES;
const maxResponseBytes = params.maxResponseBytes ?? DEFAULT_FAULT_PROXY_RESPONSE_MAX_BYTES;
const hits: MatrixQaFaultProxyHit[] = [];
const server = createServer((req, res) => {
void (async () => {
let observedRequest: MatrixQaFaultProxyRequest | undefined;
let observedContext: unknown;
try {
const requestUrl = new URL(req.url ?? "/", targetBaseUrl);
const path = requestUrl.pathname;
const bearerToken = extractBearerToken(req.headers);
const body = await readRequestBody(req, maxRequestBytes);
const request: MatrixQaFaultProxyRequest = {
...(bearerToken ? { bearerToken } : {}),
body,
headers: req.headers,
method: req.method ?? "GET",
path,
search: requestUrl.search,
};
observedRequest = request;
const context = params.createExchangeContext?.(request);
observedContext = context;
const rule = params.rules.find((candidate) => candidate.match(request));
if (rule) {
hits.push({
method: request.method,
path: request.path,
ruleId: rule.id,
});
if (rule.response) {
const response = normalizeJsonResponse(rule.response(request));
await params.onExchange?.({
...(context !== undefined ? { context } : {}),
request,
response,
});
writeForwardedResponse(res, response);
return;
}
}
const forwarded = await forwardMatrixQaFaultProxyRequest({
body,
maxResponseBytes,
req,
targetUrl: requestUrl,
});
const response =
rule?.mutateResponse !== undefined
? await rule.mutateResponse({
request,
response: forwarded,
})
: forwarded;
await params.onExchange?.({
...(context !== undefined ? { context } : {}),
request,
response,
});
writeForwardedResponse(res, response);
} catch (error) {
const failure =
error instanceof MatrixQaFaultProxyHttpError
? {
body: {
errcode: error.code,
error: error.message,
},
...(error.status === 413 ? { headers: { connection: "close" } } : {}),
status: error.status,
}
: {
body: {
errcode: "MATRIX_QA_FAULT_PROXY_ERROR",
error: error instanceof Error ? error.message : String(error),
},
status: 502,
};
const response = normalizeJsonResponse(failure);
if (observedRequest) {
await params.onExchange?.({
...(observedContext !== undefined ? { context: observedContext } : {}),
request: observedRequest,
response,
});
}
writeForwardedResponse(res, response, {
preserveConnectionClose:
error instanceof MatrixQaFaultProxyHttpError && error.status === 413,
});
}
})();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") {
server.close();
throw new Error("Matrix QA fault proxy did not bind to a TCP port");
}
return {
baseUrl: `http://127.0.0.1:${address.port}`,
hits: () => [...hits],
stop: async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
},
};
}

View File

@@ -0,0 +1,292 @@
// Qa Matrix tests cover harness plugin behavior.
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { withTempDir } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it, vi } from "vitest";
import { testing, startMatrixQaHarness, writeMatrixQaHarnessFiles } from "./harness.runtime.js";
import type { MatrixQaRecordingProxy } from "./recording-proxy.js";
type MatrixQaHarnessDeps = Parameters<typeof startMatrixQaHarness>[1];
type MatrixQaHarnessResult = Awaited<ReturnType<typeof startMatrixQaHarness>>;
async function withStartedMatrixHarness(
deps: MatrixQaHarnessDeps,
verify: (params: { outputDir: string; result: MatrixQaHarnessResult }) => Promise<void> | void,
) {
const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-harness-"));
try {
const startRecordingProxyImpl =
deps?.startRecordingProxyImpl ??
(async ({ targetBaseUrl }: { targetBaseUrl: string }) =>
({
baseUrl: targetBaseUrl,
buildManifest: vi.fn(),
records: () => [],
setScenarioId: vi.fn(),
stop: vi.fn(async () => {}),
}) as unknown as MatrixQaRecordingProxy);
const result = await startMatrixQaHarness(
{
outputDir,
repoRoot: "/repo/openclaw",
homeserverPort: 28008,
},
{ ...deps, startRecordingProxyImpl },
);
await verify({ outputDir, result });
} finally {
await rm(outputDir, { recursive: true, force: true });
}
}
function createContainerNetworkRunCommand(calls?: string[]) {
return async function runCommand(command: string, args: string[], cwd?: string) {
calls?.push([command, ...args, `@${cwd}`].join(" "));
const rendered = args.join(" ");
if (rendered.includes("ps --format json")) {
return { stdout: '{"State":"running"}\n', stderr: "" };
}
if (rendered.includes("ps -q")) {
return { stdout: "container-123\n", stderr: "" };
}
if (rendered.includes("inspect --format")) {
return { stdout: "172.18.0.10\n", stderr: "" };
}
return { stdout: "", stderr: "" };
};
}
function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean): number {
let count = 0;
for (const item of items) {
if (predicate(item)) {
count += 1;
}
}
return count;
}
describe("matrix harness runtime", () => {
it("writes a pinned Tuwunel compose file and redacted manifest", async () => {
const outputDir = await mkdtemp(path.join(os.tmpdir(), "matrix-qa-harness-"));
try {
const result = await writeMatrixQaHarnessFiles({
outputDir,
homeserverPort: 28008,
registrationToken: "secret-token",
serverName: "matrix-qa.test",
});
const compose = await readFile(result.composeFile, "utf8");
const manifest = JSON.parse(await readFile(result.manifestPath, "utf8")) as {
image: string;
serverName: string;
homeserverPort: number;
composeFile: string;
};
expect(compose).toContain(`image: ${testing.MATRIX_QA_DEFAULT_IMAGE}`);
expect(compose).toContain(' - "127.0.0.1:28008:8008"');
expect(compose).toContain('TUWUNEL_ALLOW_ENCRYPTION: "true"');
expect(compose).toContain('TUWUNEL_ALLOW_REGISTRATION: "true"');
expect(compose).toContain('TUWUNEL_REGISTRATION_TOKEN: "secret-token"');
expect(compose).toContain('TUWUNEL_SERVER_NAME: "matrix-qa.test"');
expect(manifest).toEqual({
image: testing.MATRIX_QA_DEFAULT_IMAGE,
serverName: "matrix-qa.test",
homeserverPort: 28008,
composeFile: path.join(outputDir, "docker-compose.matrix-qa.yml"),
dataDir: path.join(outputDir, "data"),
});
expect(result.registrationToken).toBe("secret-token");
} finally {
await rm(outputDir, { recursive: true, force: true });
}
});
it("starts the harness, waits for versions, and exposes a stop command", async () => {
const calls: string[] = [];
const fetchCalls: string[] = [];
await withStartedMatrixHarness(
{
async runCommand(command, args, cwd) {
calls.push([command, ...args, `@${cwd}`].join(" "));
if (args.join(" ").includes("ps --format json")) {
return { stdout: '[{"State":"running"}]\n', stderr: "" };
}
return { stdout: "", stderr: "" };
},
fetchImpl: vi.fn(async (input: string) => {
fetchCalls.push(input);
return { ok: true };
}),
sleepImpl: vi.fn(async () => {}),
resolveHostPortImpl: vi.fn(async (port: number) => port),
},
async ({ outputDir, result }) => {
expect(calls).toEqual([
`docker compose -f ${outputDir}/docker-compose.matrix-qa.yml down --remove-orphans @/repo/openclaw`,
`docker compose -f ${outputDir}/docker-compose.matrix-qa.yml up -d @/repo/openclaw`,
`docker compose -f ${outputDir}/docker-compose.matrix-qa.yml ps --format json matrix-qa-homeserver @/repo/openclaw`,
]);
expect(fetchCalls).toEqual([
"http://127.0.0.1:28008/_matrix/client/versions",
"http://127.0.0.1:28008/_matrix/client/versions",
]);
expect(result.baseUrl).toBe("http://127.0.0.1:28008/");
expect(result.stopCommand).toBe(
`docker compose -f ${outputDir}/docker-compose.matrix-qa.yml down --remove-orphans`,
);
await result.restartService();
expect(calls).toContain(
`docker compose -f ${outputDir}/docker-compose.matrix-qa.yml restart matrix-qa-homeserver @/repo/openclaw`,
);
},
);
});
it("stops Tuwunel when recorder startup fails", async () => {
const calls: string[] = [];
await withTempDir("matrix-qa-harness-", async (outputDir) => {
await expect(
startMatrixQaHarness(
{ outputDir, repoRoot: "/repo/openclaw" },
{
async runCommand(command, args, cwd) {
calls.push([command, ...args, `@${cwd}`].join(" "));
if (args.join(" ").includes("ps --format json")) {
return { stdout: '[{"State":"running"}]\n', stderr: "" };
}
return { stdout: "", stderr: "" };
},
fetchImpl: vi.fn(async () => ({ ok: true })),
sleepImpl: vi.fn(async () => {}),
resolveHostPortImpl: vi.fn(async (port: number) => port),
startRecordingProxyImpl: vi.fn(async () => {
throw new Error("recorder startup failed");
}),
},
),
).rejects.toThrow("recorder startup failed");
expect(calls.filter((call) => call.includes("down --remove-orphans"))).toHaveLength(2);
});
});
it("treats empty Docker health fields as a fallback to running state", async () => {
await withStartedMatrixHarness(
{
async runCommand(_command, args) {
if (args.join(" ").includes("ps --format json")) {
return { stdout: '{"Health":"","State":"running"}\n', stderr: "" };
}
return { stdout: "", stderr: "" };
},
fetchImpl: vi.fn(async () => ({ ok: true })),
sleepImpl: vi.fn(async () => {}),
resolveHostPortImpl: vi.fn(async (port: number) => port),
},
({ result }) => {
expect(result.baseUrl).toBe("http://127.0.0.1:28008/");
},
);
});
it("cancels Matrix versions probe response bodies", async () => {
const cancel = vi.fn(async () => {});
const fetchImpl = vi.fn(async () => ({ ok: true, body: { cancel } }));
await expect(
testing.isMatrixVersionsReachable("http://127.0.0.1:28008/", fetchImpl),
).resolves.toBe(true);
expect(fetchImpl).toHaveBeenCalledWith("http://127.0.0.1:28008/_matrix/client/versions");
expect(cancel).toHaveBeenCalledTimes(1);
});
it("falls back to the container IP when the host port is unreachable", async () => {
const calls: string[] = [];
await withStartedMatrixHarness(
{
runCommand: createContainerNetworkRunCommand(calls),
fetchImpl: vi.fn(async (input: string) => ({
ok: input.startsWith("http://172.18.0.10:8008/"),
})),
sleepImpl: vi.fn(async () => {}),
resolveHostPortImpl: vi.fn(async (port: number) => port),
},
({ outputDir, result }) => {
expect(result.baseUrl).toBe("http://172.18.0.10:8008/");
expect(calls).toContain(
`docker compose -f ${outputDir}/docker-compose.matrix-qa.yml ps -q matrix-qa-homeserver @/repo/openclaw`,
);
expect(calls).toContain(
"docker inspect --format {{range .NetworkSettings.Networks}}{{println .IPAddress}}{{end}} container-123 @/repo/openclaw",
);
},
);
});
it("keeps the host URL when the container IP is also unreachable", async () => {
const fetchCalls: string[] = [];
await withStartedMatrixHarness(
{
runCommand: createContainerNetworkRunCommand(),
fetchImpl: vi.fn(async (input: string) => {
fetchCalls.push(input);
return {
ok:
input === "http://127.0.0.1:28008/_matrix/client/versions" &&
countMatching(fetchCalls, (url) => url === input) > 1,
};
}),
sleepImpl: vi.fn(async () => {}),
resolveHostPortImpl: vi.fn(async (port: number) => port),
},
({ result }) => {
expect(result.baseUrl).toBe("http://127.0.0.1:28008/");
expect(fetchCalls).toEqual([
"http://127.0.0.1:28008/_matrix/client/versions",
"http://127.0.0.1:28008/_matrix/client/versions",
"http://127.0.0.1:28008/_matrix/client/versions",
]);
},
);
});
it("keeps probing the container URL until it becomes reachable", async () => {
const fetchCalls: string[] = [];
await withStartedMatrixHarness(
{
runCommand: createContainerNetworkRunCommand(),
fetchImpl: vi.fn(async (input: string) => {
fetchCalls.push(input);
return {
ok:
input === "http://172.18.0.10:8008/_matrix/client/versions" &&
countMatching(fetchCalls, (url) => url === input) > 1,
};
}),
sleepImpl: vi.fn(async () => {}),
resolveHostPortImpl: vi.fn(async (port: number) => port),
},
({ result }) => {
expect(result.baseUrl).toBe("http://172.18.0.10:8008/");
expect(fetchCalls).toEqual([
"http://127.0.0.1:28008/_matrix/client/versions",
"http://127.0.0.1:28008/_matrix/client/versions",
"http://172.18.0.10:8008/_matrix/client/versions",
"http://127.0.0.1:28008/_matrix/client/versions",
"http://172.18.0.10:8008/_matrix/client/versions",
"http://172.18.0.10:8008/_matrix/client/versions",
]);
},
);
});
});

View File

@@ -0,0 +1,370 @@
// Qa Matrix plugin module implements harness behavior.
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
import {
execCommand,
fetchHealthUrl,
resolveComposeServiceUrl,
resolveHostPort,
waitForDockerServiceHealth,
waitForHealth,
type FetchLike,
type RunCommand,
} from "../docker-runtime.js";
import { startMatrixQaRecordingProxy, type MatrixQaRecordingProxy } from "./recording-proxy.js";
const MATRIX_QA_DEFAULT_IMAGE = "ghcr.io/matrix-construct/tuwunel:v1.5.1";
const MATRIX_QA_DEFAULT_SERVER_NAME = "matrix-qa.test";
const MATRIX_QA_DEFAULT_PORT = 28008;
const MATRIX_QA_INTERNAL_PORT = 8008;
const MATRIX_QA_SERVICE = "matrix-qa-homeserver";
const MATRIX_QA_CLEANUP_TIMEOUT_MS = 90_000;
type MatrixQaHarnessManifest = {
image: string;
serverName: string;
homeserverPort: number;
composeFile: string;
dataDir: string;
};
type MatrixQaHarnessFiles = {
outputDir: string;
composeFile: string;
manifestPath: string;
image: string;
serverName: string;
homeserverPort: number;
registrationToken: string;
};
type MatrixQaHarness = MatrixQaHarnessFiles & {
baseUrl: string;
recording: MatrixQaRecordingProxy;
restartService(): Promise<void>;
stopCommand: string;
stop(): Promise<void>;
upstreamBaseUrl: string;
};
function buildVersionsUrl(baseUrl: string) {
return `${baseUrl}_matrix/client/versions`;
}
async function isMatrixVersionsReachable(baseUrl: string, fetchImpl: FetchLike) {
let response: Awaited<ReturnType<FetchLike>> | undefined;
try {
response = await fetchImpl(buildVersionsUrl(baseUrl));
return response.ok;
} catch {
return false;
} finally {
try {
await response?.body?.cancel?.();
} catch {}
}
}
async function withMatrixQaHarnessTimeout<T>(
label: string,
timeoutMs: number,
task: Promise<T>,
): Promise<T> {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
task,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
async function waitForReachableMatrixBaseUrl(params: {
composeFile: string;
containerBaseUrl: string | null;
fetchImpl: FetchLike;
hostBaseUrl: string;
sleepImpl: (ms: number) => Promise<unknown>;
timeoutMs?: number;
pollMs?: number;
}) {
const timeoutMs = params.timeoutMs ?? 60_000;
const pollMs = params.pollMs ?? 1_000;
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await isMatrixVersionsReachable(params.hostBaseUrl, params.fetchImpl)) {
return params.hostBaseUrl;
}
if (
params.containerBaseUrl &&
(await isMatrixVersionsReachable(params.containerBaseUrl, params.fetchImpl))
) {
return params.containerBaseUrl;
}
await params.sleepImpl(pollMs);
}
const candidateLabel = params.containerBaseUrl
? `${params.hostBaseUrl} or ${params.containerBaseUrl}`
: params.hostBaseUrl;
throw new Error(
[
`Matrix homeserver did not become healthy within ${Math.round(timeoutMs / 1000)}s.`,
`Last checked: ${candidateLabel}`,
`Hint: check container logs with \`docker compose -f ${params.composeFile} logs ${MATRIX_QA_SERVICE}\`.`,
].join("\n"),
);
}
function resolveMatrixQaHarnessImage(image?: string) {
return (
image?.trim() || process.env.OPENCLAW_QA_MATRIX_TUWUNEL_IMAGE?.trim() || MATRIX_QA_DEFAULT_IMAGE
);
}
function renderMatrixQaCompose(params: {
homeserverPort: number;
image: string;
registrationToken: string;
serverName: string;
}) {
return `services:
${MATRIX_QA_SERVICE}:
image: ${params.image}
ports:
- "127.0.0.1:${params.homeserverPort}:${MATRIX_QA_INTERNAL_PORT}"
environment:
TUWUNEL_ADDRESS: "0.0.0.0"
TUWUNEL_ALLOW_ENCRYPTION: "true"
TUWUNEL_ALLOW_FEDERATION: "false"
TUWUNEL_ALLOW_REGISTRATION: "true"
TUWUNEL_DATABASE_PATH: "/var/lib/tuwunel"
TUWUNEL_PORT: "${MATRIX_QA_INTERNAL_PORT}"
TUWUNEL_REGISTRATION_TOKEN: "${params.registrationToken}"
TUWUNEL_SERVER_NAME: "${params.serverName}"
volumes:
- ./data:/var/lib/tuwunel
`;
}
export async function writeMatrixQaHarnessFiles(params: {
outputDir: string;
image?: string;
homeserverPort: number;
registrationToken?: string;
serverName?: string;
}): Promise<MatrixQaHarnessFiles> {
const image = resolveMatrixQaHarnessImage(params.image);
const registrationToken = params.registrationToken?.trim() || `matrix-qa-${randomUUID()}`;
const serverName = params.serverName?.trim() || MATRIX_QA_DEFAULT_SERVER_NAME;
const composeFile = path.join(params.outputDir, "docker-compose.matrix-qa.yml");
const dataDir = path.join(params.outputDir, "data");
const manifestPath = path.join(params.outputDir, "matrix-qa-harness.json");
await fs.mkdir(dataDir, { recursive: true });
await fs.writeFile(
composeFile,
`${renderMatrixQaCompose({
homeserverPort: params.homeserverPort,
image,
registrationToken,
serverName,
})}\n`,
{ encoding: "utf8", mode: 0o600 },
);
const manifest: MatrixQaHarnessManifest = {
image,
serverName,
homeserverPort: params.homeserverPort,
composeFile,
dataDir,
};
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
return {
outputDir: params.outputDir,
composeFile,
manifestPath,
image,
serverName,
homeserverPort: params.homeserverPort,
registrationToken,
};
}
export async function startMatrixQaHarness(
params: {
outputDir: string;
repoRoot?: string;
image?: string;
homeserverPort?: number;
serverName?: string;
},
deps?: {
fetchImpl?: FetchLike;
runCommand?: RunCommand;
sleepImpl?: (ms: number) => Promise<unknown>;
resolveHostPortImpl?: typeof resolveHostPort;
startRecordingProxyImpl?: typeof startMatrixQaRecordingProxy;
},
): Promise<MatrixQaHarness> {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const resolveHostPortImpl = deps?.resolveHostPortImpl ?? resolveHostPort;
const runCommand = deps?.runCommand ?? execCommand;
const fetchImpl = deps?.fetchImpl ?? fetchHealthUrl;
const sleepImpl = deps?.sleepImpl ?? sleep;
const startRecordingProxyImpl = deps?.startRecordingProxyImpl ?? startMatrixQaRecordingProxy;
const homeserverPort = await resolveHostPortImpl(
params.homeserverPort ?? MATRIX_QA_DEFAULT_PORT,
params.homeserverPort != null,
);
const files = await writeMatrixQaHarnessFiles({
outputDir: path.resolve(params.outputDir),
image: params.image,
homeserverPort,
serverName: params.serverName,
});
try {
await runCommand(
"docker",
["compose", "-f", files.composeFile, "down", "--remove-orphans"],
repoRoot,
);
} catch {
// First run or already stopped.
}
await runCommand("docker", ["compose", "-f", files.composeFile, "up", "-d"], repoRoot);
await sleepImpl(1_000);
await waitForDockerServiceHealth(
MATRIX_QA_SERVICE,
files.composeFile,
repoRoot,
runCommand,
sleepImpl,
);
const hostBaseUrl = `http://127.0.0.1:${homeserverPort}/`;
let upstreamBaseUrl = hostBaseUrl;
const hostReachable = await isMatrixVersionsReachable(hostBaseUrl, fetchImpl);
if (!hostReachable) {
const containerBaseUrl = await resolveComposeServiceUrl(
MATRIX_QA_SERVICE,
MATRIX_QA_INTERNAL_PORT,
files.composeFile,
repoRoot,
runCommand,
);
upstreamBaseUrl = await waitForReachableMatrixBaseUrl({
composeFile: files.composeFile,
containerBaseUrl,
fetchImpl,
hostBaseUrl,
sleepImpl,
});
}
await waitForHealth(buildVersionsUrl(upstreamBaseUrl), {
label: "Matrix homeserver",
composeFile: files.composeFile,
fetchImpl,
sleepImpl,
});
let recording: MatrixQaRecordingProxy;
try {
recording = await startRecordingProxyImpl({ targetBaseUrl: upstreamBaseUrl });
} catch (error) {
await withMatrixQaHarnessTimeout(
"Matrix homeserver cleanup after recorder startup failure",
MATRIX_QA_CLEANUP_TIMEOUT_MS,
runCommand(
"docker",
["compose", "-f", files.composeFile, "down", "--remove-orphans"],
repoRoot,
),
).catch(() => {});
throw error;
}
const waitForReady = async () => {
await sleepImpl(1_000);
await waitForDockerServiceHealth(
MATRIX_QA_SERVICE,
files.composeFile,
repoRoot,
runCommand,
sleepImpl,
);
await waitForHealth(buildVersionsUrl(upstreamBaseUrl), {
label: "Matrix homeserver",
composeFile: files.composeFile,
fetchImpl,
sleepImpl,
});
};
return {
...files,
baseUrl: recording.baseUrl,
recording,
async restartService() {
await runCommand(
"docker",
["compose", "-f", files.composeFile, "restart", MATRIX_QA_SERVICE],
repoRoot,
);
await waitForReady();
},
stopCommand: `docker compose -f ${files.composeFile} down --remove-orphans`,
async stop() {
const results = await Promise.allSettled([
recording.stop(),
withMatrixQaHarnessTimeout(
"Matrix homeserver cleanup",
MATRIX_QA_CLEANUP_TIMEOUT_MS,
runCommand(
"docker",
["compose", "-f", files.composeFile, "down", "--remove-orphans"],
repoRoot,
),
),
]);
const failures = results.flatMap((result) =>
result.status === "rejected" ? [result.reason] : [],
);
if (failures.length > 0) {
throw new AggregateError(failures, "Matrix QA harness cleanup failed");
}
},
upstreamBaseUrl,
};
}
export const testing = {
MATRIX_QA_DEFAULT_IMAGE,
MATRIX_QA_DEFAULT_PORT,
MATRIX_QA_DEFAULT_SERVER_NAME,
MATRIX_QA_SERVICE,
MATRIX_QA_CLEANUP_TIMEOUT_MS,
buildVersionsUrl,
isMatrixVersionsReachable,
renderMatrixQaCompose,
resolveMatrixQaHarnessImage,
waitForReachableMatrixBaseUrl,
};
export { testing as __testing };

View File

@@ -0,0 +1,714 @@
// Qa Matrix tests cover redacted protocol recording and manifest derivation.
import { createServer } from "node:http";
import { afterEach, describe, expect, it } from "vitest";
import { startMatrixQaFaultProxy } from "./fault-proxy.js";
import { normalizeMatrixQaRoute, startMatrixQaRecordingProxy } from "./recording-proxy.js";
const closeCallbacks: Array<() => Promise<void>> = [];
afterEach(async () => {
while (closeCallbacks.length > 0) {
await closeCallbacks.pop()?.();
}
});
async function startRecordingTarget(options?: { alwaysFailState?: boolean }) {
let syncCount = 0;
let stateCount = 0;
const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", "http://matrix.test");
if (url.pathname.endsWith("/sync")) {
syncCount += 1;
const since = url.searchParams.get("since");
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
account_data: {
events: [
{
content: {
encrypted: {
SECRET_NESTED_STORAGE_KEY: { ciphertext: "secret-nested-ciphertext" },
},
unsigned: {
"secret-custom-field-name": "secret-custom-field-value",
},
url: "mxc://matrix.test/secret-media",
info: { mimetype: "image/png" },
},
type: "m.cross_signing.master",
},
],
},
device_keys: {
"@secret-user:matrix.test": {
SECRET_DEVICE: {
keys: {
"ed25519:SECRET_DEVICE": "secret-signing-key",
},
},
},
},
device_one_time_keys_count: { signed_curve25519: 12 },
device_unused_fallback_key_types: ["signed_curve25519"],
next_batch: since === "echoed-unknown" ? since : `secret-sync-${syncCount}`,
device_lists: { changed: ["secret-device"] },
rooms: {
join: {
"!secret-room:matrix.test": {
timeline: {
events: [
{ type: "m.room.message" },
{ sender: "@secret-sender:matrix.test", type: "m.room.encrypted" },
],
},
},
},
},
}),
);
return;
}
if (url.pathname.includes("/room_keys/keys")) {
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
rooms: {
"!secret-backup-room:matrix.test": {
sessions: {
"secret-backup-session": { first_message_index: 0 },
},
},
},
}),
);
return;
}
if (url.pathname.endsWith("/keys/upload")) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ one_time_key_counts: {} }));
return;
}
if (url.pathname.endsWith("/keys/query")) {
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
failures: {
"secret-server.example": { errcode: "M_UNAVAILABLE" },
},
}),
);
return;
}
if (url.pathname.includes("/account_data/")) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({}));
return;
}
if (url.pathname.includes("/sendToDevice/")) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({}));
return;
}
if (url.pathname.includes("/state/")) {
stateCount += 1;
const status = options?.alwaysFailState || stateCount === 1 ? 401 : 200;
res.writeHead(status, { "content-type": "application/json" });
res.end(
status === 401
? JSON.stringify({ errcode: "M_UNKNOWN_TOKEN", error: "secret-response" })
: JSON.stringify({ name: "secret-state-name" }),
);
return;
}
res.writeHead(401, { "content-type": "application/json" });
res.end(JSON.stringify({ errcode: "M_UNKNOWN_TOKEN", error: "secret-response" }));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("recording target did not bind");
}
closeCallbacks.push(
async () =>
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
);
return `http://127.0.0.1:${address.port}`;
}
describe("Matrix QA recording proxy", () => {
it("records only redacted shapes and derives scenario expectations", async () => {
const targetBaseUrl = await startRecordingTarget();
const proxy = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => proxy.stop());
proxy.setScenarioId("matrix-recording-test");
const firstSync = await fetch(
`${proxy.baseUrl}/_matrix/client/v3/sync?timeout=0&access_token=secret-query`,
{ headers: { authorization: "Bearer secret-header" } },
);
const firstBody = (await firstSync.json()) as { next_batch: string };
await fetch(
`${proxy.baseUrl}/_matrix/client/v3/sync?timeout=0&since=${encodeURIComponent(firstBody.next_batch)}`,
{ headers: { authorization: "Bearer secret-header" } },
);
const postState = async () => {
await fetch(
`${proxy.baseUrl}/_matrix/client/v3/rooms/!secret:matrix.test/state/m.room.name`,
{
body: JSON.stringify({ password: "secret-password", body: "secret-message" }),
headers: {
authorization: "Bearer secret-header",
"content-type": "application/json",
},
method: "POST",
},
);
};
await postState();
await fetch(`${proxy.baseUrl}/_matrix/client/v3/room_keys/keys`, {
headers: {
authorization: "Bearer secret-header",
},
});
await fetch(`${proxy.baseUrl}/_matrix/client/v3/keys/upload`, {
body: JSON.stringify({
device_keys: {
algorithms: ["m.olm.v1.curve25519-aes-sha2"],
device_id: "SECRET_UPLOAD_DEVICE",
keys: { "ed25519:SECRET_UPLOAD_DEVICE": "secret-upload-key" },
user_id: "@secret-upload:matrix.test",
},
one_time_keys: {
"signed_curve25519:SECRET_ONE_TIME": { key: "secret-one-time-key" },
},
}),
headers: {
authorization: "Bearer secret-header",
"content-type": "application/json",
},
method: "POST",
});
await fetch(`${proxy.baseUrl}/_matrix/client/v3/keys/query`, {
body: JSON.stringify({ device_keys: {} }),
headers: { "content-type": "application/json" },
method: "POST",
});
await fetch(
`${proxy.baseUrl}/_matrix/client/v3/user/@secret-upload%3Amatrix.test/account_data/m.cross_signing.master`,
{
body: JSON.stringify({
encrypted: {
SECRET_STORAGE_KEY: { ciphertext: "secret-ciphertext" },
},
}),
headers: { "content-type": "application/json" },
method: "PUT",
},
);
await fetch(
`${proxy.baseUrl}/_matrix/client/v3/user/@secret-upload%3Amatrix.test/account_data/m.megolm_backup.v1`,
{
body: JSON.stringify({ version: "1" }),
headers: { "content-type": "application/json" },
method: "PUT",
},
);
await fetch(`${proxy.baseUrl}/_matrix/client/v3/sendToDevice/m.room.encrypted/transaction-42`, {
body: JSON.stringify({
messages: {
"@secret-recipient:matrix.test": {
SECRET_RECIPIENT_DEVICE: { content: "secret-device-message" },
},
},
}),
headers: { "content-type": "application/json" },
method: "PUT",
});
await postState();
const records = proxy.records();
const serialized = JSON.stringify(records);
expect(serialized).not.toContain("secret-query");
expect(serialized).not.toContain("secret-header");
expect(serialized).not.toContain("secret-password");
expect(serialized).not.toContain("secret-message");
expect(serialized).not.toContain("secret-sync");
expect(serialized).not.toContain("secret-response");
expect(serialized).not.toContain("secret-room");
expect(serialized).not.toContain("secret-backup-room");
expect(serialized).not.toContain("secret-backup-session");
expect(serialized).not.toContain("secret-state-name");
expect(serialized).not.toContain("secret-user");
expect(serialized).not.toContain("SECRET_DEVICE");
expect(serialized).not.toContain("SECRET_UPLOAD_DEVICE");
expect(serialized).not.toContain("SECRET_ONE_TIME");
expect(serialized).not.toContain("SECRET_RECIPIENT_DEVICE");
expect(serialized).not.toContain("secret-server.example");
expect(serialized).not.toContain("SECRET_STORAGE_KEY");
expect(serialized).not.toContain("SECRET_NESTED_STORAGE_KEY");
expect(serialized).not.toContain("secret-custom-field-name");
const keyUpload = records.find((record) => record.request.route.endsWith("/keys/upload"));
expect(keyUpload?.request.body).toEqual({
kind: "json",
fields: [
"device_keys.algorithms",
"device_keys.device_id",
"device_keys.keys.{keyId}",
"device_keys.user_id",
"one_time_keys.{keyId}.key",
],
});
const sendToDevice = records.find((record) => record.request.route.includes("sendToDevice"));
expect(sendToDevice?.request.body).toEqual({
kind: "json",
fields: ["messages.{userId}.{deviceId}.content"],
});
const keyQuery = records.find((record) => record.request.route.endsWith("/keys/query"));
expect(keyQuery?.response.body).toEqual({
kind: "json",
fields: ["failures.{serverName}.errcode"],
});
const secretStorage = records.find((record) =>
record.request.route.endsWith("/account_data/m.cross_signing.master"),
);
expect(secretStorage?.request.body).toEqual({
kind: "json",
fields: ["encrypted.{keyId}.ciphertext"],
});
expect(records[1]?.sync).toMatchObject({ continuity: true, since: "sync-1" });
const manifest = proxy.buildManifest({
generatedAt: "2026-07-03T00:00:00.000Z",
requestedProfile: "all",
scenarioIds: ["matrix-recording-test"],
substrate: { id: "tuwunel", version: "v1.5.1" },
});
const expectation = manifest.scenarios["matrix-recording-test"];
expect(manifest.profile).toEqual({
derivedFrom: "observed-request-response-traffic",
id: "matrix-qa-v1",
});
expect(expectation?.syncTokens).toEqual({
continuityObserved: true,
incrementalRequests: 1,
initialRequests: 1,
responseTokens: 2,
});
expect(expectation?.state.device).toEqual([
"/_matrix/client/v3/keys/upload",
"/_matrix/client/v3/sync",
]);
expect(expectation?.state.key).toContain(
"/_matrix/client/v3/user/{userId}/account_data/m.cross_signing.master",
);
expect(expectation?.state.key).toContain("/_matrix/client/v3/sync");
expect(expectation?.state.backup).toContain(
"/_matrix/client/v3/user/{userId}/account_data/m.megolm_backup.v1",
);
expect(expectation?.state.media).toContain("/_matrix/client/v3/sync");
expect(expectation?.ordering[0]).toMatchObject({
requestBody: { kind: "empty" },
responseBody: {
kind: "json",
fields: expect.arrayContaining([
"rooms.join.{roomId}.timeline.events[].sender",
"rooms.join.{roomId}.timeline.events[].type",
]),
},
});
expect(expectation?.retries).toEqual([]);
expect(expectation?.errors).toEqual([
{
errcode: "M_UNKNOWN_TOKEN",
method: "POST",
route: "/_matrix/client/v3/rooms/{roomId}/state/m.room.name",
status: 401,
},
]);
});
it("normalizes substrate-specific Matrix identifiers from routes", () => {
expect(
normalizeMatrixQaRoute(
"/_matrix/client/v3/rooms/!room%3Amatrix.test/send/m.room.message/txn-123",
),
).toBe("/_matrix/client/v3/rooms/{roomId}/send/m.room.message/{transactionId}");
expect(
normalizeMatrixQaRoute(
"/_matrix/client/v3/rooms/!room%3Amatrix.test/redact/$event%3Amatrix.test/txn-123",
),
).toBe("/_matrix/client/v3/rooms/{roomId}/redact/{eventId}/{transactionId}");
expect(normalizeMatrixQaRoute("/_matrix/media/v3/download/matrix.test/secret-media-id")).toBe(
"/_matrix/media/v3/download/{serverName}/{mediaId}",
);
expect(
normalizeMatrixQaRoute(
"/_matrix/media/v3/download/matrix.test/secret-media-id/private-report.pdf",
),
).toBe("/_matrix/media/v3/download/{serverName}/{mediaId}/{filename}");
expect(
normalizeMatrixQaRoute("/_matrix/client/v1/media/thumbnail/matrix.test/secret-media-id"),
).toBe("/_matrix/client/v1/media/thumbnail/{serverName}/{mediaId}");
expect(
normalizeMatrixQaRoute("/_matrix/client/v3/user/@alice%3Amatrix.test/filter/filter-42"),
).toBe("/_matrix/client/v3/user/{userId}/filter/{filterId}");
expect(
normalizeMatrixQaRoute(
"/_matrix/client/v3/user/@alice%3Amatrix.test/account_data/m.secret_storage.key.secret-key-id",
),
).toBe("/_matrix/client/v3/user/{userId}/account_data/m.secret_storage.key.{keyId}");
expect(
normalizeMatrixQaRoute("/_matrix/client/v3/room_keys/keys/!room%3Amatrix.test/session-42"),
).toBe("/_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}");
});
it("records the response observed after scenario-local fault injection", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-fault-recording-test");
const faultProxy = await startMatrixQaFaultProxy({
targetBaseUrl,
...recording,
rules: [
{
id: "backup-unavailable",
match: (request) => request.path.endsWith("/room_keys/version"),
response: () => ({
body: { errcode: "M_NOT_FOUND", error: "secret-fault-message" },
status: 404,
}),
},
],
});
closeCallbacks.push(() => faultProxy.stop());
await fetch(`${faultProxy.baseUrl}/_matrix/client/v3/room_keys/version`);
const record = recording
.records()
.find((entry) => entry.scenarioId === "matrix-fault-recording-test");
expect(record?.response).toMatchObject({ errcode: "M_NOT_FOUND", status: 404 });
expect(JSON.stringify(record)).not.toContain("secret-fault-message");
});
it("records proxy-generated upstream failures", async () => {
const recording = await startMatrixQaRecordingProxy({
targetBaseUrl: "http://127.0.0.1:1",
});
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-upstream-failure-test");
const response = await fetch(`${recording.baseUrl}/_matrix/client/v3/sync?timeout=0`);
expect(response.status).toBe(502);
expect(recording.records().at(-1)?.response).toMatchObject({
errcode: "MATRIX_QA_FAULT_PROXY_ERROR",
status: 502,
});
});
it("redacts signature upload device and cross-signing key identifiers", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-signatures-recording-test");
await fetch(`${recording.baseUrl}/_matrix/client/v3/keys/signatures/upload`, {
body: JSON.stringify({
"@secret-signing-user:matrix.test": {
SECRET_CROSS_SIGNING_KEY: { signatures: {} },
},
}),
headers: { "content-type": "application/json" },
method: "POST",
});
const record = recording.records().at(-1);
expect(record?.request.body).toEqual({
kind: "json",
fields: ["{userId}.{deviceOrKeyId}.signatures"],
});
expect(JSON.stringify(record)).not.toContain("secret-signing-user");
expect(JSON.stringify(record)).not.toContain("SECRET_CROSS_SIGNING_KEY");
});
it("reports sync continuity only when every incremental token is recognized", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-sync-discontinuity-test");
await fetch(`${recording.baseUrl}/_matrix/client/v3/sync?timeout=0&since=unknown-token`);
const manifest = recording.buildManifest({
requestedProfile: "test",
scenarioIds: ["matrix-sync-discontinuity-test"],
substrate: { id: "tuwunel", version: "test" },
});
expect(manifest.scenarios["matrix-sync-discontinuity-test"]?.syncTokens).toMatchObject({
continuityObserved: false,
incrementalRequests: 1,
});
});
it("does not learn an unknown request token from the same sync response", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-echoed-sync-token-test");
await fetch(`${recording.baseUrl}/_matrix/client/v3/sync?timeout=0&since=echoed-unknown`);
expect(recording.records().at(-1)?.sync).toMatchObject({
continuity: false,
since: "sync-unknown",
});
});
it("does not conflate distinct same-shape operations as retries", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-distinct-operation-test");
await fetch(
`${recording.baseUrl}/_matrix/client/v3/rooms/!first:matrix.test/state/m.room.name`,
{
body: JSON.stringify({ name: "first" }),
headers: { "content-type": "application/json" },
method: "POST",
},
);
await fetch(
`${recording.baseUrl}/_matrix/client/v3/rooms/!second:matrix.test/state/m.room.name`,
{
body: JSON.stringify({ name: "second" }),
headers: { "content-type": "application/json" },
method: "POST",
},
);
const manifest = recording.buildManifest({
requestedProfile: "test",
scenarioIds: ["matrix-distinct-operation-test"],
substrate: { id: "tuwunel", version: "test" },
});
expect(manifest.scenarios["matrix-distinct-operation-test"]?.retries).toEqual([]);
});
it("does not conflate identical operations from different principals", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-principal-retry-test");
const endpoint = `${recording.baseUrl}/_matrix/client/v3/rooms/!same:matrix.test/state/m.room.name`;
await fetch(endpoint, {
body: JSON.stringify({ name: "same" }),
headers: { authorization: "Bearer first", "content-type": "application/json" },
method: "POST",
});
await fetch(endpoint, {
body: JSON.stringify({ name: "same" }),
headers: { authorization: "Bearer second", "content-type": "application/json" },
method: "POST",
});
const manifest = recording.buildManifest({
requestedProfile: "test",
scenarioIds: ["matrix-principal-retry-test"],
substrate: { id: "tuwunel", version: "test" },
});
expect(manifest.scenarios["matrix-principal-retry-test"]?.retries).toEqual([]);
});
it("attributes sync completion to the active scenario", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("previous-scenario");
const context = recording.createExchangeContext?.({
body: Buffer.alloc(0),
headers: {},
method: "GET",
path: "/_matrix/client/v3/sync",
search: "?timeout=0",
});
recording.setScenarioId("active-scenario");
await recording.onExchange?.({
context,
request: {
body: Buffer.alloc(0),
headers: {},
method: "GET",
path: "/_matrix/client/v3/sync",
search: "?timeout=0",
},
response: {
body: Buffer.from(JSON.stringify({ next_batch: "sync-complete" })),
headers: new Headers({ "content-type": "application/json" }),
status: 200,
},
});
expect(recording.records().at(-1)?.scenarioId).toBe("active-scenario");
});
it("does not share sync-token continuity across principals", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-principal-sync-test");
const exchange = async (bearerToken: string, search: string, nextBatch: string) => {
const request = {
bearerToken,
body: Buffer.alloc(0),
headers: {},
method: "GET",
path: "/_matrix/client/v3/sync",
search,
};
await recording.onExchange?.({
context: recording.createExchangeContext?.(request),
request,
response: {
body: Buffer.from(JSON.stringify({ next_batch: nextBatch })),
headers: new Headers({ "content-type": "application/json" }),
status: 200,
},
});
};
await exchange("first", "?timeout=0", "shared-token");
await exchange("second", "?timeout=0&since=shared-token", "second-token");
expect(recording.records().at(-1)?.sync).toMatchObject({
continuity: false,
since: "sync-unknown",
});
});
it("ends a retry chain at the first successful recovery", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-retry-boundary-test");
const endpoint = `${recording.baseUrl}/_matrix/client/v3/rooms/!same:matrix.test/state/m.room.name`;
const request = () =>
fetch(endpoint, {
body: JSON.stringify({ name: "same" }),
headers: { "content-type": "application/json" },
method: "POST",
});
await request();
await request();
await request();
const manifest = recording.buildManifest({
requestedProfile: "test",
scenarioIds: ["matrix-retry-boundary-test"],
substrate: { id: "tuwunel", version: "test" },
});
expect(manifest.scenarios["matrix-retry-boundary-test"]?.retries).toEqual([
expect.objectContaining({ attempts: 2, statuses: [401, 200] }),
]);
});
it("records exhausted retry chains without recovery", async () => {
const targetBaseUrl = await startRecordingTarget({ alwaysFailState: true });
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-exhausted-retry-test");
const endpoint = `${recording.baseUrl}/_matrix/client/v3/rooms/!exhausted:matrix.test/state/m.room.name`;
await fetch(endpoint, { method: "POST" });
await fetch(endpoint, { method: "POST" });
const manifest = recording.buildManifest({
requestedProfile: "test",
scenarioIds: ["matrix-exhausted-retry-test"],
substrate: { id: "tuwunel", version: "test" },
});
expect(manifest.scenarios["matrix-exhausted-retry-test"]?.retries).toEqual([
expect.objectContaining({ attempts: 2, statuses: [401, 401] }),
]);
});
it("does not infer retries across intervening operations", async () => {
const targetBaseUrl = await startRecordingTarget();
const recording = await startMatrixQaRecordingProxy({ targetBaseUrl });
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-independent-operation-test");
const stateEndpoint = `${recording.baseUrl}/_matrix/client/v3/rooms/!same:matrix.test/state/m.room.name`;
await fetch(stateEndpoint, { method: "POST" });
await fetch(`${recording.baseUrl}/_matrix/client/versions`);
await fetch(stateEndpoint, { method: "POST" });
const manifest = recording.buildManifest({
requestedProfile: "test",
scenarioIds: ["matrix-independent-operation-test"],
substrate: { id: "tuwunel", version: "test" },
});
expect(manifest.scenarios["matrix-independent-operation-test"]?.retries).toEqual([]);
});
it("records repeated retry chains for the same operation", async () => {
let requestCount = 0;
const server = createServer((_req, res) => {
requestCount += 1;
const status = requestCount % 2 === 1 ? 503 : 200;
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(status === 200 ? {} : { errcode: "M_UNAVAILABLE" }));
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
closeCallbacks.push(
() =>
new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
}),
);
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("retry target did not bind");
}
const recording = await startMatrixQaRecordingProxy({
targetBaseUrl: `http://127.0.0.1:${address.port}`,
});
closeCallbacks.push(() => recording.stop());
recording.setScenarioId("matrix-repeated-retry-test");
const endpoint = `${recording.baseUrl}/_matrix/client/v3/rooms/!same:matrix.test/state/m.room.name`;
for (let attempt = 0; attempt < 4; attempt += 1) {
await fetch(endpoint, { method: "POST" });
}
const manifest = recording.buildManifest({
requestedProfile: "test",
scenarioIds: ["matrix-repeated-retry-test"],
substrate: { id: "tuwunel", version: "test" },
});
expect(manifest.scenarios["matrix-repeated-retry-test"]?.retries).toEqual([
expect.objectContaining({ attempts: 2, statuses: [503, 200] }),
expect.objectContaining({ attempts: 2, statuses: [503, 200] }),
]);
});
});

View File

@@ -0,0 +1,732 @@
// Qa Matrix plugin module records redacted Matrix protocol behavior.
import { createHash } from "node:crypto";
import type { IncomingHttpHeaders } from "node:http";
import {
startMatrixQaFaultProxy,
type MatrixQaFaultProxyExchange,
type MatrixQaFaultProxyObserver,
} from "./fault-proxy.js";
const MATRIX_QA_RECORDING_PROFILE = "matrix-qa-v1";
const REDACTED_QUERY_VALUE = "[redacted]";
type MatrixQaStateFamily = "backup" | "device" | "key" | "media" | "sync-token";
type MatrixQaBodyShape =
| { kind: "binary" }
| { kind: "empty" }
| { kind: "json"; fields: string[] }
| { kind: "text" };
export type MatrixQaRecordedExchange = {
categories: MatrixQaStateFamily[];
request: {
body: MatrixQaBodyShape;
method: string;
query: Record<string, string>;
route: string;
};
response: {
body: MatrixQaBodyShape;
errcode?: string;
status: number;
};
scenarioId: string;
sequence: number;
sync?: {
continuity?: boolean;
nextBatch?: string;
since?: string;
};
};
type MatrixQaInternalRecordedExchange = MatrixQaRecordedExchange & {
operationFingerprint: string;
};
type MatrixQaRouteExpectation = {
count: number;
method: string;
route: string;
statuses: number[];
};
type MatrixQaOrderingExpectation = {
categories: MatrixQaStateFamily[];
count: number;
method: string;
requestBody: MatrixQaBodyShape;
responseBody: MatrixQaBodyShape;
route: string;
status: number;
};
type MatrixQaScenarioRouteStateExpectation = {
errors: Array<{
errcode?: string;
method: string;
route: string;
status: number;
}>;
ordering: MatrixQaOrderingExpectation[];
retries: Array<{
attempts: number;
kind: "retry";
method: string;
route: string;
statuses: number[];
}>;
routes: MatrixQaRouteExpectation[];
state: Record<MatrixQaStateFamily, string[]>;
syncTokens: {
continuityObserved: boolean;
incrementalRequests: number;
initialRequests: number;
responseTokens: number;
};
};
export type MatrixQaRouteStateManifest = {
generatedAt: string;
phases: Record<string, MatrixQaScenarioRouteStateExpectation>;
profile: {
derivedFrom: "observed-request-response-traffic";
id: typeof MATRIX_QA_RECORDING_PROFILE;
};
requestedProfile: string;
scenarios: Record<string, MatrixQaScenarioRouteStateExpectation>;
substrate: {
id: string;
version: string;
};
};
export type MatrixQaRecordingProxy = MatrixQaFaultProxyObserver & {
baseUrl: string;
buildManifest(params: {
generatedAt?: string;
requestedProfile: string;
scenarioIds: string[];
substrate: MatrixQaRouteStateManifest["substrate"];
}): MatrixQaRouteStateManifest;
records(): MatrixQaRecordedExchange[];
setScenarioId(scenarioId: string): void;
stop(): Promise<void>;
};
function normalizeHeaderValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value.join(", ") : value;
}
function hasJsonContentType(headers: Headers | IncomingHttpHeaders) {
const raw = headers instanceof Headers ? headers.get("content-type") : headers["content-type"];
return (
normalizeHeaderValue(raw ?? undefined)
?.toLowerCase()
.includes("json") === true
);
}
function normalizeJsonMapKey(key: string, prefix: string, route: string) {
if (route.endsWith("/sync") && prefix === "rooms") {
return key;
}
if (key.startsWith("!")) {
return "{roomId}";
}
if (key.startsWith("@")) {
return "{userId}";
}
if (key.startsWith("$")) {
return "{eventId}";
}
if (route.endsWith("/keys/signatures/upload") && prefix === "") {
return "{userId}";
}
if (route.endsWith("/keys/signatures/upload") && prefix === "{userId}") {
return "{deviceOrKeyId}";
}
if (route.endsWith("/keys/upload") && prefix === "one_time_keys") {
return "{keyId}";
}
if (/(?:^|\.)encrypted$/u.test(prefix)) {
return "{keyId}";
}
if (prefix === "failures" && /\/keys\/(?:claim|query)$/u.test(route)) {
return "{serverName}";
}
if (
!route.endsWith("/keys/upload") &&
/^(?:device_keys|master_keys|self_signing_keys|user_signing_keys)$/u.test(prefix)
) {
return "{userId}";
}
if (/^(?:device_keys|devices|messages|one_time_keys)\.\{userId\}$/u.test(prefix)) {
return "{deviceId}";
}
if (!route.endsWith("/keys/upload") && prefix === "one_time_keys") {
return "{userId}";
}
if (
/^(?:device_keys\.)?\{userId\}\.\{deviceId\}\.(?:fallback_keys|keys|one_time_keys)$/u.test(
prefix,
) ||
/^one_time_keys\.\{userId\}\.\{deviceId\}$/u.test(prefix) ||
/^(?:ed25519|curve25519|signed_curve25519):/u.test(key)
) {
return "{keyId}";
}
if (/(?:^|\.)rooms$/u.test(prefix)) {
return "{roomId}";
}
if (/(?:^|\.)rooms\.\{roomId\}\.sessions$/u.test(prefix)) {
return "{sessionId}";
}
return key;
}
function collectJsonFields(value: unknown, route: string, prefix = "", depth = 0): string[] {
if (depth >= 8 || value === null || typeof value !== "object") {
return [];
}
if (Array.isArray(value)) {
if (value.length === 0) {
return prefix ? [`${prefix}[]`] : [];
}
const arrayPrefix = prefix ? `${prefix}[]` : "[]";
return [
...new Set(value.flatMap((entry) => collectJsonFields(entry, route, arrayPrefix, depth + 1))),
].toSorted();
}
const fixedDeviceKeysObject =
prefix === "device_keys" &&
["algorithms", "device_id", "keys", "signatures", "user_id"].some((key) => key in value);
return Object.entries(value)
.flatMap(([key, child]) => {
const safeKey = fixedDeviceKeysObject ? key : normalizeJsonMapKey(key, prefix, route);
const field = prefix ? `${prefix}.${safeKey}` : safeKey;
if (
/^(?:access_token|auth|body|ciphertext|content|file|formatted_body|password|recovery_key|session_data|token|unsigned)$/iu.test(
key,
)
) {
return [field];
}
const nested = collectJsonFields(child, route, field, depth + 1);
return nested.length > 0 ? nested : [field];
})
.toSorted();
}
function parseJsonBody(body: Buffer, headers: Headers | IncomingHttpHeaders): unknown {
if (body.byteLength === 0) {
return undefined;
}
const text = body.toString("utf8");
const firstNonWhitespace = text.trimStart()[0];
if (!hasJsonContentType(headers) && firstNonWhitespace !== "[" && firstNonWhitespace !== "{") {
return undefined;
}
try {
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
}
const MATRIX_QA_STATE_FIELD_MARKERS = new Set([
"backup",
"content_uri",
"device_id",
"device_keys",
"device_lists",
"device_one_time_keys_count",
"device_unused_fallback_key_types",
"mimetype",
"one_time_key",
"session_data",
]);
function collectStateFieldMarkers(value: unknown, depth = 0): string[] {
if (depth >= 8 || value === null || typeof value !== "object") {
return [];
}
if (Array.isArray(value)) {
return [...new Set(value.flatMap((entry) => collectStateFieldMarkers(entry, depth + 1)))];
}
const markers = new Set<string>();
for (const [key, child] of Object.entries(value)) {
const normalizedKey = key.toLowerCase();
if (MATRIX_QA_STATE_FIELD_MARKERS.has(normalizedKey)) {
markers.add(normalizedKey);
}
for (const marker of collectStateFieldMarkers(child, depth + 1)) {
markers.add(marker);
}
}
return [...markers];
}
function extractStateFieldMarkers(body: Buffer, headers: Headers | IncomingHttpHeaders) {
const parsed = parseJsonBody(body, headers);
return parsed === undefined ? [] : collectStateFieldMarkers(parsed);
}
function buildBodyShape(
body: Buffer,
headers: Headers | IncomingHttpHeaders,
route: string,
): MatrixQaBodyShape {
if (body.byteLength === 0) {
return { kind: "empty" };
}
const parsed = parseJsonBody(body, headers);
if (parsed !== undefined) {
return { kind: "json", fields: collectJsonFields(parsed, route) };
}
const contentType =
headers instanceof Headers
? headers.get("content-type")
: normalizeHeaderValue(headers["content-type"]);
return contentType?.toLowerCase().startsWith("text/") ? { kind: "text" } : { kind: "binary" };
}
function normalizeMatrixIdSegment(segment: string) {
let decoded: string;
try {
decoded = decodeURIComponent(segment);
} catch {
return segment;
}
if (decoded.startsWith("!")) {
return "{roomId}";
}
if (decoded.startsWith("@")) {
return "{userId}";
}
if (decoded.startsWith("$")) {
return "{eventId}";
}
if (decoded.startsWith("#")) {
return "{roomAlias}";
}
return segment;
}
export function normalizeMatrixQaRoute(pathname: string) {
const segments = pathname.split("/");
for (let index = 0; index < segments.length; index += 1) {
const previous = segments[index - 1];
const beforePrevious = segments[index - 2];
if (previous === "rooms") {
segments[index] = "{roomId}";
continue;
}
if (previous === "profile" || previous === "user") {
segments[index] = "{userId}";
continue;
}
if (previous === "filter") {
segments[index] = "{filterId}";
continue;
}
if (previous === "join") {
segments[index] = normalizeMatrixIdSegment(segments[index] ?? "");
continue;
}
if (previous === "devices") {
segments[index] = "{deviceId}";
continue;
}
if (previous === "redact") {
segments[index] = "{eventId}";
continue;
}
if (beforePrevious === "send" || beforePrevious === "redact") {
segments[index] = "{transactionId}";
continue;
}
if (beforePrevious === "sendToDevice") {
segments[index] = "{transactionId}";
continue;
}
if (previous === "version" && beforePrevious === "room_keys") {
segments[index] = "{backupVersion}";
continue;
}
if (previous === "keys" && beforePrevious === "room_keys") {
segments[index] = "{roomId}";
continue;
}
if (segments[index - 2] === "keys" && segments[index - 3] === "room_keys") {
segments[index] = "{sessionId}";
continue;
}
if (segments[index - 2] === "state" && segments[index] !== "") {
segments[index] = "{stateKey}";
continue;
}
if (previous === "account_data" && segments[index]?.startsWith("m.secret_storage.key.")) {
segments[index] = "m.secret_storage.key.{keyId}";
continue;
}
const mediaActionIndex = segments.findIndex(
(segment) => segment === "download" || segment === "thumbnail",
);
if (mediaActionIndex >= 0 && index === mediaActionIndex + 2) {
segments[index] = "{mediaId}";
segments[index - 1] = "{serverName}";
continue;
}
if (mediaActionIndex >= 0 && index === mediaActionIndex + 3) {
segments[index] = "{filename}";
continue;
}
segments[index] = normalizeMatrixIdSegment(segments[index] ?? "");
}
return segments.join("/");
}
function buildRedactedQuery(search: string, syncTokens: Map<string, string>) {
const result: Record<string, string> = {};
for (const [key, value] of new URLSearchParams(search)) {
if (key === "since") {
result[key] = syncTokens.get(value) ?? "sync-unknown";
} else if (key === "timeout" || key === "full_state" || key === "set_presence") {
result[key] = value;
} else {
result[key] = REDACTED_QUERY_VALUE;
}
}
return result;
}
function resolveStateFamilies(params: {
requestFields: string[];
responseFields: string[];
route: string;
}) {
const fields = [...params.requestFields, ...params.responseFields].join(" ").toLowerCase();
const route = params.route.toLowerCase();
const families = new Set<MatrixQaStateFamily>();
if (route.includes("/sync")) {
families.add("sync-token");
}
if (route.includes("/room_keys/") || fields.includes("backup")) {
families.add("backup");
}
if (route.includes("/account_data/m.megolm_backup.")) {
families.add("backup");
}
if (
route.includes("/keys/") ||
route.includes("/sendtodevice/") ||
route.includes("/account_data/m.cross_signing.") ||
route.includes("/account_data/m.secret_storage.") ||
fields.includes("one_time_key") ||
fields.includes("device_one_time_keys_count") ||
fields.includes("device_unused_fallback_key_types") ||
fields.includes("device_keys") ||
fields.includes("session_data")
) {
families.add("key");
}
if (
route.includes("/devices") ||
fields.includes("device_id") ||
fields.includes("device_lists")
) {
families.add("device");
}
if (route.includes("/media/") || fields.includes("content_uri") || fields.includes("mimetype")) {
families.add("media");
}
return [...families].toSorted();
}
function buildExpectation(
records: MatrixQaInternalRecordedExchange[],
): MatrixQaScenarioRouteStateExpectation {
const orderedRecords = records.toSorted((left, right) => left.sequence - right.sequence);
const routeGroups = new Map<string, MatrixQaRouteExpectation>();
const ordering: MatrixQaOrderingExpectation[] = [];
const state = {
backup: new Set<string>(),
device: new Set<string>(),
key: new Set<string>(),
media: new Set<string>(),
"sync-token": new Set<string>(),
} satisfies Record<MatrixQaStateFamily, Set<string>>;
for (const record of orderedRecords) {
const routeKey = `${record.request.method} ${record.request.route}`;
const route = routeGroups.get(routeKey) ?? {
count: 0,
method: record.request.method,
route: record.request.route,
statuses: [],
};
route.count += 1;
if (!route.statuses.includes(record.response.status)) {
route.statuses.push(record.response.status);
route.statuses.sort((left, right) => left - right);
}
routeGroups.set(routeKey, route);
for (const category of record.categories) {
state[category].add(record.request.route);
}
const previous = ordering.at(-1);
if (
previous?.method === record.request.method &&
previous.route === record.request.route &&
previous.status === record.response.status &&
JSON.stringify(previous.requestBody) === JSON.stringify(record.request.body) &&
JSON.stringify(previous.responseBody) === JSON.stringify(record.response.body) &&
previous.categories.join("\0") === record.categories.join("\0")
) {
previous.count += 1;
} else {
ordering.push({
categories: record.categories,
count: 1,
method: record.request.method,
requestBody: record.request.body,
responseBody: record.response.body,
route: record.request.route,
status: record.response.status,
});
}
}
const routes = [...routeGroups.values()].toSorted((left, right) =>
`${left.method} ${left.route}`.localeCompare(`${right.method} ${right.route}`),
);
const retries: MatrixQaScenarioRouteStateExpectation["retries"] = [];
const adjacentOperationRuns: MatrixQaInternalRecordedExchange[][] = [];
for (const record of orderedRecords) {
const currentRun = adjacentOperationRuns.at(-1);
if (currentRun?.[0]?.operationFingerprint === record.operationFingerprint) {
currentRun.push(record);
} else {
adjacentOperationRuns.push([record]);
}
}
for (const attempts of adjacentOperationRuns) {
if (attempts[0]?.request.route.endsWith("/sync")) {
continue;
}
for (let index = 0; index < attempts.length; index += 1) {
const first = attempts[index];
if (!first || first.response.status < 400) {
continue;
}
const recoveryOffset = attempts
.slice(index + 1)
.findIndex((attempt) => attempt.response.status < 400);
const retryEndIndex = recoveryOffset < 0 ? attempts.length : index + recoveryOffset + 2;
const retryAttempts = attempts.slice(index, retryEndIndex);
if (retryAttempts.length < 2) {
continue;
}
retries.push({
attempts: retryAttempts.length,
kind: "retry",
method: first.request.method,
route: first.request.route,
statuses: retryAttempts.map((attempt) => attempt.response.status),
});
index = retryEndIndex - 1;
}
}
const incrementalSyncRecords = orderedRecords.filter(
(record) => record.sync?.since !== undefined,
);
return {
errors: orderedRecords
.filter((record) => record.response.status >= 400)
.map((record) => {
const error: MatrixQaScenarioRouteStateExpectation["errors"][number] = {
method: record.request.method,
route: record.request.route,
status: record.response.status,
};
if (record.response.errcode) {
error.errcode = record.response.errcode;
}
return error;
}),
ordering,
retries,
routes,
state: Object.fromEntries(
Object.entries(state).map(([key, values]) => [key, [...values].toSorted()]),
) as Record<MatrixQaStateFamily, string[]>,
syncTokens: {
continuityObserved:
incrementalSyncRecords.length > 0 &&
incrementalSyncRecords.every((record) => record.sync?.continuity === true),
incrementalRequests: incrementalSyncRecords.length,
initialRequests: orderedRecords.filter(
(record) => record.categories.includes("sync-token") && record.sync?.since === undefined,
).length,
responseTokens: orderedRecords.filter((record) => record.sync?.nextBatch !== undefined)
.length,
},
};
}
function extractErrcode(body: Buffer, headers: Headers) {
const parsed = parseJsonBody(body, headers);
if (typeof parsed !== "object" || parsed === null) {
return undefined;
}
const errcode = (parsed as { errcode?: unknown }).errcode;
return typeof errcode === "string" ? errcode : undefined;
}
function extractNextBatch(body: Buffer, headers: Headers) {
const parsed = parseJsonBody(body, headers);
if (typeof parsed !== "object" || parsed === null) {
return undefined;
}
const nextBatch = (parsed as { next_batch?: unknown }).next_batch;
return typeof nextBatch === "string" ? nextBatch : undefined;
}
export async function startMatrixQaRecordingProxy(params: {
targetBaseUrl: string;
}): Promise<MatrixQaRecordingProxy> {
let scenarioId = "setup";
let sequence = 0;
const records: MatrixQaInternalRecordedExchange[] = [];
const syncTokensByPrincipal = new Map<string, Map<string, string>>();
const observer: Required<MatrixQaFaultProxyObserver> = {
createExchangeContext: () => ({ scenarioId, sequence: ++sequence }),
onExchange(exchange: MatrixQaFaultProxyExchange) {
recordExchange(exchange);
},
};
const recordExchange = (exchange: MatrixQaFaultProxyExchange) => {
const context =
typeof exchange.context === "object" && exchange.context !== null
? (exchange.context as { scenarioId?: unknown; sequence?: unknown })
: undefined;
const exchangeSequence = typeof context?.sequence === "number" ? context.sequence : ++sequence;
const route = normalizeMatrixQaRoute(exchange.request.path);
const exchangeScenarioId = route.endsWith("/sync")
? scenarioId
: typeof context?.scenarioId === "string"
? context.scenarioId
: "unattributed";
const requestBody = buildBodyShape(exchange.request.body, exchange.request.headers, route);
const responseBody = buildBodyShape(exchange.response.body, exchange.response.headers, route);
const requestFields = extractStateFieldMarkers(exchange.request.body, exchange.request.headers);
const responseFields = extractStateFieldMarkers(
exchange.response.body,
exchange.response.headers,
);
const syncPrincipal = exchange.request.bearerToken ?? "anonymous";
const syncTokens = syncTokensByPrincipal.get(syncPrincipal) ?? new Map<string, string>();
syncTokensByPrincipal.set(syncPrincipal, syncTokens);
const sinceRaw = new URLSearchParams(exchange.request.search).get("since") ?? undefined;
const since = sinceRaw ? (syncTokens.get(sinceRaw) ?? "sync-unknown") : undefined;
const nextBatch = extractNextBatch(exchange.response.body, exchange.response.headers);
if (nextBatch && !syncTokens.has(nextBatch)) {
syncTokens.set(nextBatch, `sync-${syncTokens.size + 1}`);
}
const nextBatchAlias = nextBatch ? syncTokens.get(nextBatch) : undefined;
const responseErrcode = extractErrcode(exchange.response.body, exchange.response.headers);
const operationFingerprint = createHash("sha256")
.update(exchange.request.method)
.update("\0")
.update(exchange.request.path)
.update("\0")
.update(exchange.request.search)
.update("\0")
.update(exchange.request.body)
.update("\0")
.update(exchange.request.bearerToken ?? "anonymous")
.digest("hex");
records.push({
categories: resolveStateFamilies({ requestFields, responseFields, route }),
request: {
body: requestBody,
method: exchange.request.method,
query: buildRedactedQuery(exchange.request.search, syncTokens),
route,
},
response: {
body: responseBody,
...(responseErrcode ? { errcode: responseErrcode } : {}),
status: exchange.response.status,
},
scenarioId: exchangeScenarioId,
sequence: exchangeSequence,
operationFingerprint,
...(route.endsWith("/sync")
? {
sync: {
...(since ? { since } : {}),
...(nextBatchAlias ? { nextBatch: nextBatchAlias } : {}),
...(since && nextBatchAlias ? { continuity: since !== "sync-unknown" } : {}),
},
}
: {}),
});
};
const proxy = await startMatrixQaFaultProxy({
targetBaseUrl: params.targetBaseUrl,
rules: [],
...observer,
});
return {
baseUrl: proxy.baseUrl,
...observer,
buildManifest({ generatedAt, requestedProfile, scenarioIds, substrate }) {
const selectedIds = new Set(scenarioIds);
const byScenario = new Map<string, MatrixQaInternalRecordedExchange[]>();
for (const record of records) {
const entries = byScenario.get(record.scenarioId) ?? [];
entries.push(record);
byScenario.set(record.scenarioId, entries);
}
const scenarios = Object.fromEntries(
scenarioIds.map((id) => [id, buildExpectation(byScenario.get(id) ?? [])]),
);
const phases = Object.fromEntries(
[...byScenario.entries()]
.filter(([id]) => !selectedIds.has(id))
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([id, entries]) => [id, buildExpectation(entries)]),
);
return {
generatedAt: generatedAt ?? new Date().toISOString(),
phases,
profile: {
derivedFrom: "observed-request-response-traffic",
id: MATRIX_QA_RECORDING_PROFILE,
},
requestedProfile,
scenarios,
substrate,
};
},
records: () =>
structuredClone(
records
.toSorted((left, right) => left.sequence - right.sequence)
.map(({ operationFingerprint: _operationFingerprint, ...record }) => record),
),
setScenarioId(nextScenarioId) {
scenarioId = nextScenarioId;
},
stop: () => proxy.stop(),
};
}
export const testing = {
buildExpectation,
normalizeMatrixQaRoute,
};
export { testing as __testing };

View File

@@ -0,0 +1,191 @@
// Qa Matrix tests cover request plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { requestMatrixJson, type MatrixQaFetchLike } from "./request.js";
describe("requestMatrixJson", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("caps oversized request timeouts before creating the abort signal", async () => {
const signal = AbortSignal.abort();
const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(signal);
const fetchImpl = vi.fn<MatrixQaFetchLike>(async () => Response.json({ ok: true }));
await requestMatrixJson({
baseUrl: "https://matrix.example.test",
endpoint: "/_matrix/client/v3/account/whoami",
fetchImpl,
method: "GET",
timeoutMs: MAX_TIMER_TIMEOUT_MS + 1_000_000,
});
expect(timeoutSpy).toHaveBeenCalledWith(MAX_TIMER_TIMEOUT_MS);
expect(fetchImpl).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ signal }));
});
it("fails closed when the homeserver streams an over-cap response body", async () => {
// Stream past the 16 MiB cap one chunk at a time so the fixture proves the
// bound trips on the prefix and cancels the body instead of buffering it
// all. `cancel()` flipping `canceled` is what real-behavior fail-closed
// looks like: the stream is torn down, not drained.
const chunkSize = 1024 * 1024;
const chunkCount = 32; // 32 MiB total, well past the 16 MiB limit
let reads = 0;
let canceled = false;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
reads += 1;
controller.enqueue(encoder.encode("a".repeat(chunkSize)));
if (reads >= chunkCount) {
controller.close();
}
},
cancel() {
canceled = true;
},
});
const fetchImpl = vi.fn<MatrixQaFetchLike>(
async () =>
new Response(stream, {
status: 200,
headers: { "content-type": "application/json" },
}),
);
await expect(
requestMatrixJson({
baseUrl: "https://matrix.example.test",
endpoint: "/_matrix/client/v3/sync",
fetchImpl,
method: "GET",
}),
).rejects.toThrow(/Matrix homeserver response exceeds 16777216 bytes/);
// Fail-closed proof: the read stopped before draining all 32 chunks and the
// stream was canceled rather than fully buffered.
expect(canceled).toBe(true);
expect(reads).toBeLessThan(chunkCount);
});
it("rejects an oversized error-status body instead of buffering it whole", async () => {
// Even on a non-2xx status the cap must trip first: an attacker controlling
// the homeserver could otherwise return a 500 with a multi-GiB body knowing
// the helper only inspects `body.error` after fully reading it.
const chunkSize = 1024 * 1024;
const chunkCount = 32;
let reads = 0;
let canceled = false;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
reads += 1;
controller.enqueue(encoder.encode("b".repeat(chunkSize)));
if (reads >= chunkCount) {
controller.close();
}
},
cancel() {
canceled = true;
},
});
const fetchImpl = vi.fn<MatrixQaFetchLike>(
async () =>
new Response(stream, {
status: 500,
headers: { "content-type": "application/json" },
}),
);
await expect(
requestMatrixJson({
baseUrl: "https://matrix.example.test",
endpoint: "/_matrix/client/v3/sync",
fetchImpl,
method: "GET",
}),
).rejects.toThrow(/Matrix homeserver response exceeds 16777216 bytes/);
expect(canceled).toBe(true);
expect(reads).toBeLessThan(chunkCount);
});
it("still falls back to an empty body for malformed in-bounds JSON", async () => {
const fetchImpl = vi.fn<MatrixQaFetchLike>(
async () =>
new Response("{ not valid json", {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const result = await requestMatrixJson<{ ok?: boolean }>({
baseUrl: "https://matrix.example.test",
endpoint: "/_matrix/client/v3/account/whoami",
fetchImpl,
method: "GET",
});
expect(result.status).toBe(200);
expect(result.body).toEqual({});
});
it("treats an empty in-bounds body as an empty object", async () => {
const fetchImpl = vi.fn<MatrixQaFetchLike>(
async () =>
new Response("", {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const result = await requestMatrixJson<Record<string, unknown>>({
baseUrl: "https://matrix.example.test",
endpoint: "/_matrix/client/v3/account/whoami",
fetchImpl,
method: "GET",
});
expect(result.status).toBe(200);
expect(result.body).toEqual({});
});
it("reads a normal in-bounds JSON body unchanged", async () => {
const fetchImpl = vi.fn<MatrixQaFetchLike>(async () => Response.json({ user_id: "@qa:test" }));
const result = await requestMatrixJson<{ user_id: string }>({
baseUrl: "https://matrix.example.test",
endpoint: "/_matrix/client/v3/account/whoami",
fetchImpl,
method: "GET",
});
expect(result.status).toBe(200);
expect(result.body).toEqual({ user_id: "@qa:test" });
});
it("reads an in-bounds body just under the cap without tripping the bound", async () => {
// Boundary guard: a body close to but under 16 MiB must parse normally so
// the cap does not regress legitimate large-but-valid Matrix responses.
const filler = "x".repeat(8 * 1024 * 1024); // 8 MiB string, well under cap
const payload = JSON.stringify({ data: filler });
const fetchImpl = vi.fn<MatrixQaFetchLike>(
async () =>
new Response(payload, {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const result = await requestMatrixJson<{ data: string }>({
baseUrl: "https://matrix.example.test",
endpoint: "/_matrix/client/v3/sync",
fetchImpl,
method: "GET",
});
expect(result.status).toBe(200);
expect(result.body.data).toHaveLength(filler.length);
});
});

View File

@@ -0,0 +1,71 @@
// Qa Matrix plugin module implements request behavior.
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
export type MatrixQaFetchLike = typeof fetch;
// Cap how much of a Matrix homeserver response we buffer so a hostile or
// misbehaving server cannot drive this process OOM with an unbounded body.
// Shared across the QA substrate (also reused for media-upload reads in client.ts).
export const MATRIX_QA_JSON_MAX_BYTES = 16 * 1024 * 1024;
type MatrixQaRequestResult<T> = {
status: number;
body: T;
};
export async function requestMatrixJson<T>(params: {
accessToken?: string;
baseUrl: string;
body?: unknown;
endpoint: string;
fetchImpl: MatrixQaFetchLike;
method: "DELETE" | "GET" | "POST" | "PUT";
okStatuses?: number[];
query?: Record<string, string | number | undefined>;
timeoutMs?: number;
}): Promise<MatrixQaRequestResult<T>> {
const url = new URL(params.endpoint, params.baseUrl);
for (const [key, value] of Object.entries(params.query ?? {})) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
const response = await params.fetchImpl(url, {
method: params.method,
headers: {
accept: "application/json",
...(params.body !== undefined ? { "content-type": "application/json" } : {}),
...(params.accessToken ? { authorization: `Bearer ${params.accessToken}` } : {}),
},
...(params.body !== undefined ? { body: JSON.stringify(params.body) } : {}),
signal: AbortSignal.timeout(resolveTimerTimeoutMs(params.timeoutMs, 20_000)),
});
// Read under a byte cap *before* the parse try/catch. The overflow error must
// escape uncaught (fail-closed): swallowing it into `body = {}` would defeat
// the bound and silently accept an oversized payload. Malformed but
// in-bounds JSON still falls back to `{}` exactly as before.
const bytes = await readResponseWithLimit(response, MATRIX_QA_JSON_MAX_BYTES, {
onOverflow: ({ maxBytes }) => new Error(`Matrix homeserver response exceeds ${maxBytes} bytes`),
});
let body: unknown;
try {
body = JSON.parse(new TextDecoder().decode(bytes)) as unknown;
} catch {
body = {};
}
const okStatuses = params.okStatuses ?? [200];
if (!okStatuses.includes(response.status)) {
const details =
typeof body === "object" &&
body !== null &&
typeof (body as { error?: unknown }).error === "string"
? (body as { error: string }).error
: `${params.method} ${params.endpoint} failed with status ${response.status}`;
throw new Error(details);
}
return {
status: response.status,
body: body as T,
};
}

View File

@@ -0,0 +1,338 @@
// Qa Matrix tests cover sync plugin behavior.
import { describe, expect, it, vi } from "vitest";
import type { MatrixQaObservedEvent } from "./events.js";
import {
createMatrixQaRoomObserver,
primeMatrixQaRoom,
waitForOptionalMatrixQaRoomEvent,
} from "./sync.js";
describe("matrix sync helpers", () => {
it("primes the Matrix sync cursor without recording observed events", async () => {
const fetchImpl: typeof fetch = async () =>
new Response(JSON.stringify({ next_batch: "primed-sync-cursor" }), {
status: 200,
headers: { "content-type": "application/json" },
});
await expect(
primeMatrixQaRoom({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
}),
).resolves.toBe("primed-sync-cursor");
});
it("returns a typed no-match result while preserving the latest sync token", async () => {
const fetchImpl: typeof fetch = async () =>
new Response(
JSON.stringify({
next_batch: "next-batch-2",
rooms: {
join: {
"!room:matrix-qa.test": {
timeline: {
events: [
{
event_id: "$driver",
sender: "@driver:matrix-qa.test",
type: "m.room.message",
content: { body: "hello", msgtype: "m.text" },
},
],
},
},
},
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
const observedEvents: MatrixQaObservedEvent[] = [];
const nowSpy = vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValue(1);
let result: Awaited<ReturnType<typeof waitForOptionalMatrixQaRoomEvent>>;
try {
result = await waitForOptionalMatrixQaRoomEvent({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
observedEvents,
predicate: (event) => event.sender === "@sut:matrix-qa.test",
roomId: "!room:matrix-qa.test",
since: "start-batch",
timeoutMs: 1,
});
} finally {
nowSpy.mockRestore();
}
expect(result).toEqual({
matched: false,
since: "next-batch-2",
});
expect(observedEvents).toEqual([
{
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$driver",
sender: "@driver:matrix-qa.test",
stateKey: undefined,
type: "m.room.message",
originServerTs: undefined,
body: "hello",
formattedBody: undefined,
msgtype: "m.text",
membership: undefined,
},
]);
});
it("keeps recording later same-batch events after the first match", async () => {
const fetchImpl: typeof fetch = async () =>
new Response(
JSON.stringify({
next_batch: "next-batch-2",
rooms: {
join: {
"!room:matrix-qa.test": {
timeline: {
events: [
{
event_id: "$sut",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: { body: "target", msgtype: "m.text" },
},
{
event_id: "$driver",
sender: "@driver:matrix-qa.test",
type: "m.room.message",
content: { body: "trailing event", msgtype: "m.text" },
},
],
},
},
},
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
const observedEvents: MatrixQaObservedEvent[] = [];
const result = await waitForOptionalMatrixQaRoomEvent({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
observedEvents,
predicate: (event) => event.eventId === "$sut",
roomId: "!room:matrix-qa.test",
since: "start-batch",
timeoutMs: 1,
});
expect(result).toEqual({
event: {
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$sut",
sender: "@sut:matrix-qa.test",
stateKey: undefined,
type: "m.room.message",
originServerTs: undefined,
body: "target",
formattedBody: undefined,
msgtype: "m.text",
membership: undefined,
},
matched: true,
since: "next-batch-2",
});
expect(observedEvents).toEqual([
{
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$sut",
sender: "@sut:matrix-qa.test",
stateKey: undefined,
type: "m.room.message",
originServerTs: undefined,
body: "target",
formattedBody: undefined,
msgtype: "m.text",
membership: undefined,
},
{
kind: "message",
roomId: "!room:matrix-qa.test",
eventId: "$driver",
sender: "@driver:matrix-qa.test",
stateKey: undefined,
type: "m.room.message",
originServerTs: undefined,
body: "trailing event",
formattedBody: undefined,
msgtype: "m.text",
membership: undefined,
},
]);
});
it("lets a second wait reuse later same-batch events without another /sync", async () => {
let calls = 0;
const fetchImpl: typeof fetch = async () => {
calls += 1;
return new Response(
JSON.stringify({
next_batch: "next-batch-2",
rooms: {
join: {
"!room:matrix-qa.test": {
timeline: {
events: [
{
event_id: "$preview",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: { body: "preview", msgtype: "m.notice" },
},
{
event_id: "$final",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: {
body: "final",
msgtype: "m.text",
"m.relates_to": {
rel_type: "m.replace",
event_id: "$preview",
"m.new_content": { body: "final", msgtype: "m.text" },
},
},
},
],
},
},
},
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
};
const observedEvents: MatrixQaObservedEvent[] = [];
const observer = createMatrixQaRoomObserver({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
observedEvents,
since: "start-batch",
});
const preview = await observer.waitForRoomEvent({
predicate: (event) => event.eventId === "$preview",
roomId: "!room:matrix-qa.test",
timeoutMs: 1_000,
});
const finalized = await observer.waitForRoomEvent({
predicate: (event) => event.eventId === "$final",
roomId: "!room:matrix-qa.test",
timeoutMs: 1_000,
});
expect(preview.event.eventId).toBe("$preview");
expect(finalized.event.eventId).toBe("$final");
expect(calls).toBe(1);
});
it("shares one in-flight /sync poll across concurrent waits", async () => {
let calls = 0;
let markFetchStarted: () => void = () => {};
const fetchStarted = new Promise<void>((resolve) => {
markFetchStarted = resolve;
});
let releaseFetch: () => void = () => {};
const fetchCanComplete = new Promise<void>((resolve) => {
releaseFetch = resolve;
});
const fetchImpl: typeof fetch = async () => {
calls += 1;
markFetchStarted();
await fetchCanComplete;
return new Response(
JSON.stringify({
next_batch: "next-batch-2",
rooms: {
join: {
"!room:matrix-qa.test": {
timeline: {
events: [
{
event_id: "$reply",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: { body: "reply", msgtype: "m.text" },
},
{
event_id: "$notice",
sender: "@sut:matrix-qa.test",
type: "m.room.message",
content: { body: "notice", msgtype: "m.notice" },
},
],
},
},
},
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
};
const observer = createMatrixQaRoomObserver({
accessToken: "token",
baseUrl: "http://127.0.0.1:28008/",
fetchImpl,
observedEvents: [],
since: "start-batch",
});
const waits = Promise.all([
observer.waitForRoomEvent({
predicate: (event) => event.eventId === "$reply",
roomId: "!room:matrix-qa.test",
timeoutMs: 1_000,
}),
observer.waitForOptionalRoomEvent({
predicate: (event) => event.eventId === "$notice",
roomId: "!room:matrix-qa.test",
timeoutMs: 1_000,
}),
]);
await fetchStarted;
await Promise.resolve();
releaseFetch();
const [reply, notice] = await waits;
expect(reply.event.eventId).toBe("$reply");
expect(notice).toEqual({
event: {
kind: "notice",
roomId: "!room:matrix-qa.test",
eventId: "$notice",
sender: "@sut:matrix-qa.test",
stateKey: undefined,
type: "m.room.message",
originServerTs: undefined,
body: "notice",
formattedBody: undefined,
msgtype: "m.notice",
membership: undefined,
},
matched: true,
since: "next-batch-2",
});
expect(calls).toBe(1);
});
});

View File

@@ -0,0 +1,228 @@
// Qa Matrix plugin module implements sync behavior.
import {
findMatrixQaObservedEventMatch,
normalizeMatrixQaObservedEvent,
type MatrixQaObservedEvent,
type MatrixQaRoomEvent,
} from "./events.js";
import { requestMatrixJson, type MatrixQaFetchLike } from "./request.js";
type MatrixQaSyncResponse = {
next_batch?: string;
rooms?: {
join?: Record<
string,
{
timeline?: {
events?: MatrixQaRoomEvent[];
};
}
>;
};
};
export type MatrixQaRoomEventWaitResult =
| {
event: MatrixQaObservedEvent;
matched: true;
since?: string;
}
| {
matched: false;
since?: string;
};
type MatrixQaSyncParams = {
accessToken?: string;
baseUrl: string;
fetchImpl?: MatrixQaFetchLike;
};
export type MatrixQaRoomObserver = {
prime(): Promise<string | undefined>;
waitForOptionalRoomEvent(params: {
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
timeoutMs: number;
}): Promise<MatrixQaRoomEventWaitResult>;
waitForRoomEvent(params: {
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
timeoutMs: number;
}): Promise<{
event: MatrixQaObservedEvent;
since?: string;
}>;
};
type MatrixQaRoomObserverState = {
cursorIndex: number;
events: MatrixQaObservedEvent[];
pollPromise?: Promise<void>;
since?: string;
};
export async function primeMatrixQaRoom(params: MatrixQaSyncParams) {
const fetchImpl = params.fetchImpl ?? fetch;
const response = await requestMatrixJson<MatrixQaSyncResponse>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
endpoint: "/_matrix/client/v3/sync",
fetchImpl,
method: "GET",
query: { timeout: 0 },
});
return response.body.next_batch?.trim() || undefined;
}
async function pollMatrixQaRoomObserver(
params: MatrixQaSyncParams & {
observedEvents: MatrixQaObservedEvent[];
roomObserver: MatrixQaRoomObserverState;
timeoutMs: number;
},
) {
const fetchImpl = params.fetchImpl ?? fetch;
if (params.roomObserver.pollPromise) {
await params.roomObserver.pollPromise;
return;
}
params.roomObserver.pollPromise = (async () => {
const response = await requestMatrixJson<MatrixQaSyncResponse>({
accessToken: params.accessToken,
baseUrl: params.baseUrl,
endpoint: "/_matrix/client/v3/sync",
fetchImpl,
method: "GET",
query: {
...(params.roomObserver.since ? { since: params.roomObserver.since } : {}),
timeout: Math.min(10_000, params.timeoutMs),
},
timeoutMs: Math.min(15_000, params.timeoutMs + 5_000),
});
params.roomObserver.since = response.body.next_batch?.trim() || params.roomObserver.since;
for (const [roomId, joinedRoom] of Object.entries(response.body.rooms?.join ?? {})) {
for (const event of joinedRoom.timeline?.events ?? []) {
const normalized = normalizeMatrixQaObservedEvent(roomId, event);
if (!normalized) {
continue;
}
params.observedEvents.push(normalized);
params.roomObserver.events.push(normalized);
}
}
})();
try {
await params.roomObserver.pollPromise;
} finally {
params.roomObserver.pollPromise = undefined;
}
}
export function createMatrixQaRoomObserver(
params: MatrixQaSyncParams & {
observedEvents: MatrixQaObservedEvent[];
since?: string;
},
): MatrixQaRoomObserver {
const roomObserver: MatrixQaRoomObserverState = {
cursorIndex: 0,
events: [],
since: params.since,
};
return {
async prime() {
if (roomObserver.since) {
return roomObserver.since;
}
roomObserver.since = await primeMatrixQaRoom(params);
return roomObserver.since;
},
async waitForOptionalRoomEvent(waitParams) {
const startSince = await this.prime();
const startedAt = Date.now();
let cursorIndex = roomObserver.cursorIndex;
let didPoll = false;
while (true) {
const matched = findMatrixQaObservedEventMatch({
cursorIndex,
events: roomObserver.events,
predicate: waitParams.predicate,
roomId: waitParams.roomId,
});
if (matched) {
roomObserver.cursorIndex = Math.max(roomObserver.cursorIndex, matched.nextCursorIndex);
return {
event: matched.event,
matched: true,
since: roomObserver.since ?? startSince,
};
}
const elapsedMs = Date.now() - startedAt;
if (elapsedMs >= waitParams.timeoutMs && (didPoll || waitParams.timeoutMs <= 0)) {
roomObserver.cursorIndex = Math.max(roomObserver.cursorIndex, cursorIndex);
return {
matched: false,
since: roomObserver.since ?? startSince,
};
}
cursorIndex = roomObserver.events.length;
const remainingMs = Math.max(1_000, waitParams.timeoutMs - elapsedMs);
await pollMatrixQaRoomObserver({
...params,
observedEvents: params.observedEvents,
roomObserver,
timeoutMs: remainingMs,
});
didPoll = true;
}
},
async waitForRoomEvent(waitParams) {
const result = await this.waitForOptionalRoomEvent(waitParams);
if (result.matched) {
return {
event: result.event,
since: result.since,
};
}
throw new Error(`timed out after ${waitParams.timeoutMs}ms waiting for Matrix room event`);
},
};
}
export async function waitForOptionalMatrixQaRoomEvent(
params: MatrixQaSyncParams & {
observedEvents: MatrixQaObservedEvent[];
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
since?: string;
timeoutMs: number;
},
): Promise<MatrixQaRoomEventWaitResult> {
return await createMatrixQaRoomObserver(params).waitForOptionalRoomEvent({
predicate: params.predicate,
roomId: params.roomId,
timeoutMs: params.timeoutMs,
});
}
export async function waitForMatrixQaRoomEvent(
params: MatrixQaSyncParams & {
observedEvents: MatrixQaObservedEvent[];
predicate: (event: MatrixQaObservedEvent) => boolean;
roomId: string;
since?: string;
timeoutMs: number;
},
) {
const result = await waitForOptionalMatrixQaRoomEvent(params);
if (result.matched) {
return { event: result.event, since: result.since };
}
throw new Error(`timed out after ${params.timeoutMs}ms waiting for Matrix room event`);
}

View File

@@ -0,0 +1,107 @@
// Qa Matrix plugin module implements topology behavior.
export type MatrixQaParticipantRole = "driver" | "observer" | "sut";
type MatrixQaRoomKind = "dm" | "group";
export type MatrixQaTopologyRoomSpec = {
encrypted?: boolean;
key: string;
kind: MatrixQaRoomKind;
members: MatrixQaParticipantRole[];
name: string;
requireMention?: boolean;
};
export type MatrixQaTopologySpec = {
defaultRoomKey: string;
rooms: MatrixQaTopologyRoomSpec[];
};
type MatrixQaProvisionedRoom = {
encrypted?: boolean;
key: string;
kind: MatrixQaRoomKind;
memberRoles: MatrixQaParticipantRole[];
memberUserIds: string[];
name: string;
requireMention: boolean;
roomId: string;
};
export type MatrixQaProvisionedTopology = {
defaultRoomId: string;
defaultRoomKey: string;
rooms: MatrixQaProvisionedRoom[];
};
function matrixQaRoomSpecsEqual(left: MatrixQaTopologyRoomSpec, right: MatrixQaTopologyRoomSpec) {
return (
left.key === right.key &&
(left.encrypted === true) === (right.encrypted === true) &&
left.kind === right.kind &&
left.name === right.name &&
left.requireMention === right.requireMention &&
left.members.length === right.members.length &&
left.members.every((member, index) => member === right.members[index])
);
}
export function buildDefaultMatrixQaTopologySpec(params: {
defaultRoomName: string;
}): MatrixQaTopologySpec {
return {
defaultRoomKey: "main",
rooms: [
{
encrypted: false,
key: "main",
kind: "group",
members: ["driver", "observer", "sut"],
name: params.defaultRoomName,
requireMention: true,
},
],
};
}
export function findMatrixQaProvisionedRoom(
topology: MatrixQaProvisionedTopology,
key: string,
): MatrixQaProvisionedRoom {
const room = topology.rooms.find((entry) => entry.key === key);
if (!room) {
throw new Error(`Matrix QA topology is missing room "${key}"`);
}
return room;
}
export function mergeMatrixQaTopologySpecs(specs: MatrixQaTopologySpec[]): MatrixQaTopologySpec {
const first = specs[0];
if (!first) {
throw new Error("Matrix QA topology merge requires at least one spec");
}
const roomByKey = new Map<string, MatrixQaTopologyRoomSpec>();
for (const spec of specs) {
if (spec.defaultRoomKey !== first.defaultRoomKey) {
throw new Error(
`Matrix QA topology default room mismatch: ${spec.defaultRoomKey} !== ${first.defaultRoomKey}`,
);
}
for (const room of spec.rooms) {
const existing = roomByKey.get(room.key);
if (!existing) {
roomByKey.set(room.key, room);
continue;
}
if (!matrixQaRoomSpecsEqual(existing, room)) {
throw new Error(`Matrix QA topology room "${room.key}" has conflicting definitions`);
}
}
}
return {
defaultRoomKey: first.defaultRoomKey,
rooms: [...roomByKey.values()],
};
}

View File

@@ -0,0 +1,32 @@
// Qa Matrix tests cover Windows system tool path resolution.
import { describe, expect, it } from "vitest";
import {
resolveMatrixQaWindowsSystem32ExePath,
resolveMatrixQaWindowsSystemRoot,
} from "./windows-system-tools.js";
describe("qa-matrix windows system tools", () => {
it("resolves System32 executables from a trusted SystemRoot", () => {
expect(resolveMatrixQaWindowsSystemRoot({ SystemRoot: "D:\\Windows\\" })).toBe("D:\\Windows");
expect(
resolveMatrixQaWindowsSystem32ExePath("taskkill.exe", { SystemRoot: "D:\\Windows\\" }),
).toBe("D:\\Windows\\System32\\taskkill.exe");
});
it("falls back to the default Windows root when env roots are unsafe", () => {
expect(
resolveMatrixQaWindowsSystem32ExePath("taskkill.exe", {
WINDIR: "\\\\attacker\\share",
}),
).toBe("C:\\Windows\\System32\\taskkill.exe");
});
it("rejects non-basename System32 executable names", () => {
expect(() => resolveMatrixQaWindowsSystem32ExePath("..\\taskkill.exe")).toThrow(
"Invalid Windows System32 executable name",
);
expect(() => resolveMatrixQaWindowsSystem32ExePath("taskkill")).toThrow(
"Invalid Windows System32 executable name",
);
});
});

View File

@@ -0,0 +1,62 @@
// Qa Matrix resolves Windows system tools without trusting PATH.
import path from "node:path";
const DEFAULT_WINDOWS_SYSTEM_ROOT = "C:\\Windows";
function getEnvValueCaseInsensitive(
env: Record<string, string | undefined>,
expectedKey: string,
): string | undefined {
const direct = env[expectedKey];
if (direct !== undefined) {
return direct;
}
const expected = expectedKey.toUpperCase();
const actualKey = Object.keys(env).find((key) => key.toUpperCase() === expected);
return actualKey ? env[actualKey] : undefined;
}
function normalizeWindowsSystemRoot(raw: string | undefined): string | null {
const trimmed = raw?.trim();
if (
!trimmed ||
trimmed.includes("\0") ||
trimmed.includes("\r") ||
trimmed.includes("\n") ||
trimmed.includes(";")
) {
return null;
}
const normalized = path.win32.normalize(trimmed);
if (!path.win32.isAbsolute(normalized) || normalized.startsWith("\\\\")) {
return null;
}
const parsed = path.win32.parse(normalized);
if (!/^[A-Za-z]:\\$/u.test(parsed.root) || normalized.length <= parsed.root.length) {
return null;
}
return normalized.replace(/[\\/]+$/u, "");
}
export function resolveMatrixQaWindowsSystemRoot(
env: Record<string, string | undefined> = process.env,
): string {
return (
normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "SystemRoot")) ??
normalizeWindowsSystemRoot(getEnvValueCaseInsensitive(env, "WINDIR")) ??
DEFAULT_WINDOWS_SYSTEM_ROOT
);
}
export function resolveMatrixQaWindowsSystem32ExePath(
executableName: string,
env: Record<string, string | undefined> = process.env,
): string {
if (
path.win32.basename(executableName) !== executableName ||
!/^[A-Za-z0-9_.-]+\.exe$/u.test(executableName)
) {
throw new Error(`Invalid Windows System32 executable name: ${executableName}`);
}
return path.win32.join(resolveMatrixQaWindowsSystemRoot(env), "System32", executableName);
}