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
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:
732
extensions/qa-matrix/src/substrate/recording-proxy.ts
Normal file
732
extensions/qa-matrix/src/substrate/recording-proxy.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user