mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 14:02:56 +08:00
fix(matrix): stabilize e2ee qa flows
This commit is contained in:
@@ -68,6 +68,8 @@ Key wizard behaviors:
|
||||
- Room allowlist entries accept room IDs and aliases directly. Prefer `!room:server` or `#alias:server`; unresolved names are ignored at runtime by allowlist resolution.
|
||||
- In invite auto-join allowlist mode, use only stable invite targets: `!roomId:server`, `#alias:server`, or `*`. Plain room names are rejected.
|
||||
- To resolve room names before saving, use `openclaw channels resolve --channel matrix "Project Room"`.
|
||||
- When setup enables E2EE, OpenClaw writes the encryption config and runs the
|
||||
same verification bootstrap used by `openclaw matrix encryption setup`.
|
||||
|
||||
<Warning>
|
||||
`channels.matrix.autoJoin` defaults to `off`.
|
||||
@@ -292,7 +294,32 @@ Use strict room allowlists and mention requirements when enabling bot-to-bot tra
|
||||
|
||||
In encrypted (E2EE) rooms, outbound image events use `thumbnail_file` so image previews are encrypted alongside the full attachment. Unencrypted rooms still use plain `thumbnail_url`. No configuration is needed — the plugin detects E2EE state automatically.
|
||||
|
||||
Enable encryption:
|
||||
Recommended setup flow:
|
||||
|
||||
```bash
|
||||
openclaw matrix encryption setup
|
||||
```
|
||||
|
||||
This enables `channels.matrix.encryption`, bootstraps Matrix secret storage and
|
||||
cross-signing, creates room-key backup state when needed, then prints the
|
||||
current verification and backup status with next steps.
|
||||
|
||||
For a new account, enable E2EE during account creation:
|
||||
|
||||
```bash
|
||||
openclaw matrix account add \
|
||||
--homeserver https://matrix.example.org \
|
||||
--access-token syt_xxx \
|
||||
--enable-e2ee
|
||||
```
|
||||
|
||||
Multi-account setups can target a specific account:
|
||||
|
||||
```bash
|
||||
openclaw matrix encryption setup --account assistant
|
||||
```
|
||||
|
||||
Manual config equivalent:
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -116,6 +116,7 @@ describe("matrix plugin", () => {
|
||||
|
||||
registerMatrixFullRuntime(api);
|
||||
|
||||
expect(runtimeMocks.ensureMatrixCryptoRuntime).not.toHaveBeenCalled();
|
||||
expect(on.mock.calls.map(([hookName]) => hookName)).toEqual([
|
||||
"subagent_spawning",
|
||||
"subagent_ended",
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
defineBundledChannelEntry,
|
||||
type OpenClawPluginApi,
|
||||
} from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { registerMatrixCliMetadata } from "./cli-metadata.js";
|
||||
import { registerMatrixSubagentHooks } from "./subagent-hooks-api.js";
|
||||
|
||||
@@ -16,18 +15,6 @@ function loadMatrixHandlersRuntimeModule() {
|
||||
}
|
||||
|
||||
export function registerMatrixFullRuntime(api: OpenClawPluginApi): void {
|
||||
void loadMatrixHandlersRuntimeModule()
|
||||
.then(({ ensureMatrixCryptoRuntime }) =>
|
||||
ensureMatrixCryptoRuntime({ log: api.logger.info }).catch((err: unknown) => {
|
||||
const message = formatErrorMessage(err);
|
||||
api.logger.warn?.(`matrix: crypto runtime bootstrap failed: ${message}`);
|
||||
}),
|
||||
)
|
||||
.catch((err: unknown) => {
|
||||
const message = formatErrorMessage(err);
|
||||
api.logger.warn?.(`matrix: failed loading crypto bootstrap runtime: ${message}`);
|
||||
});
|
||||
|
||||
api.registerGatewayMethod("matrix.verify.recoveryKey", async (ctx) => {
|
||||
const { handleVerifyRecoveryKey } = await loadMatrixHandlersRuntimeModule();
|
||||
await handleVerifyRecoveryKey(ctx);
|
||||
|
||||
@@ -138,6 +138,7 @@ describe("matrix setup post-write bootstrap", () => {
|
||||
|
||||
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
|
||||
accountId: "default",
|
||||
cfg: nextCfg,
|
||||
});
|
||||
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
|
||||
expect(log).toHaveBeenCalledWith('Matrix backup version for "default": 7');
|
||||
@@ -177,6 +178,44 @@ describe("matrix setup post-write bootstrap", () => {
|
||||
expect(error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bootstraps verification when setup enables encryption for an existing account", async () => {
|
||||
const previousCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@flurry:example.org",
|
||||
accessToken: "token",
|
||||
encryption: false,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
const nextCfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@flurry:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
mockBootstrapResult({ success: true, backupVersion: "8" });
|
||||
|
||||
await runAfterAccountConfigWritten({
|
||||
previousCfg,
|
||||
nextCfg,
|
||||
accountId: "default",
|
||||
input: {},
|
||||
});
|
||||
|
||||
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
|
||||
accountId: "default",
|
||||
cfg: nextCfg,
|
||||
});
|
||||
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
|
||||
expect(log).toHaveBeenCalledWith('Matrix backup version for "default": 8');
|
||||
});
|
||||
|
||||
it("logs a warning when verification bootstrap fails", async () => {
|
||||
const { previousCfg, nextCfg, accountId, input } = applyDefaultAccountConfig();
|
||||
mockBootstrapResult({
|
||||
@@ -207,6 +246,7 @@ describe("matrix setup post-write bootstrap", () => {
|
||||
|
||||
expect(verificationMocks.bootstrapMatrixVerification).toHaveBeenCalledWith({
|
||||
accountId: "default",
|
||||
cfg: nextCfg,
|
||||
});
|
||||
expect(log).toHaveBeenCalledWith('Matrix verification bootstrap: complete for "default".');
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Command } from "commander";
|
||||
import { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix-runtime-shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerMatrixCli, resetMatrixCliStateForTests } from "./cli.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const bootstrapMatrixVerificationMock = vi.fn();
|
||||
const acceptMatrixVerificationMock = vi.fn();
|
||||
@@ -133,6 +134,7 @@ function mockMatrixVerificationStatus(params: {
|
||||
},
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyCreatedAt: params.recoveryKeyCreatedAt,
|
||||
serverDeviceKnown: true,
|
||||
pendingVerifications: 0,
|
||||
verifiedAt: params.verifiedAt,
|
||||
});
|
||||
@@ -823,6 +825,20 @@ describe("matrix CLI verification commands", () => {
|
||||
expect(getMatrixVerificationStatusMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cfg: fakeCfg }),
|
||||
);
|
||||
expect(getMatrixVerificationStatusMock.mock.calls.at(-1)?.[0]).not.toHaveProperty("readiness");
|
||||
});
|
||||
|
||||
it("allows verify status to use degraded local-state diagnostics", async () => {
|
||||
mockMatrixVerificationStatus({ recoveryKeyCreatedAt: null });
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "status", "--allow-degraded-local-state"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(getMatrixVerificationStatusMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ readiness: "none" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes loaded cfg to all verify subcommands", async () => {
|
||||
@@ -1021,6 +1037,225 @@ describe("matrix CLI verification commands", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("enables E2EE and bootstraps verification from matrix account add", async () => {
|
||||
matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} });
|
||||
matrixSetupApplyAccountConfigMock.mockImplementation(
|
||||
({ cfg, accountId }: { cfg: Record<string, unknown>; accountId: string }) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...(cfg.channels as Record<string, unknown> | undefined),
|
||||
matrix: {
|
||||
accounts: {
|
||||
[accountId]: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
resolveMatrixAccountConfigMock.mockImplementation(
|
||||
({ cfg, accountId }: { cfg: CoreConfig; accountId: string }) =>
|
||||
cfg.channels?.matrix?.accounts?.[accountId] ?? {},
|
||||
);
|
||||
bootstrapMatrixVerificationMock.mockResolvedValue({
|
||||
success: true,
|
||||
verification: {
|
||||
recoveryKeyCreatedAt: "2026-03-09T06:00:00.000Z",
|
||||
backupVersion: "7",
|
||||
},
|
||||
crossSigning: {},
|
||||
pendingVerifications: 0,
|
||||
cryptoBootstrap: {},
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"matrix",
|
||||
"account",
|
||||
"add",
|
||||
"--account",
|
||||
"ops",
|
||||
"--homeserver",
|
||||
"https://matrix.example.org",
|
||||
"--access-token",
|
||||
"token",
|
||||
"--enable-e2ee",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: {
|
||||
matrix: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
ops: expect.objectContaining({
|
||||
encryption: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(bootstrapMatrixVerificationMock).toHaveBeenCalledWith({
|
||||
accountId: "ops",
|
||||
cfg: expect.objectContaining({
|
||||
channels: {
|
||||
matrix: expect.objectContaining({
|
||||
accounts: expect.objectContaining({
|
||||
ops: expect.objectContaining({
|
||||
encryption: true,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(console.log).toHaveBeenCalledWith("Encryption: enabled");
|
||||
expect(console.log).toHaveBeenCalledWith("Matrix verification bootstrap: complete");
|
||||
});
|
||||
|
||||
it("enables E2EE and prints verification status from matrix encryption setup", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
matrixRuntimeLoadConfigMock.mockReturnValue(cfg);
|
||||
resolveMatrixAccountMock.mockReturnValue({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
config: cfg.channels?.matrix?.accounts?.ops,
|
||||
});
|
||||
resolveMatrixAccountConfigMock.mockReturnValue({
|
||||
encryption: false,
|
||||
});
|
||||
bootstrapMatrixVerificationMock.mockResolvedValue({
|
||||
success: true,
|
||||
verification: {
|
||||
recoveryKeyCreatedAt: "2026-03-09T06:00:00.000Z",
|
||||
backupVersion: "7",
|
||||
},
|
||||
crossSigning: {},
|
||||
pendingVerifications: 0,
|
||||
cryptoBootstrap: {},
|
||||
});
|
||||
mockMatrixVerificationStatus({
|
||||
recoveryKeyCreatedAt: "2026-03-09T06:00:00.000Z",
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "encryption", "setup", "--account", "ops"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: {
|
||||
matrix: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
ops: expect.objectContaining({
|
||||
encryption: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(bootstrapMatrixVerificationMock).toHaveBeenCalledWith({
|
||||
accountId: "ops",
|
||||
cfg: expect.objectContaining({
|
||||
channels: expect.objectContaining({
|
||||
matrix: expect.objectContaining({
|
||||
accounts: expect.objectContaining({
|
||||
ops: expect.objectContaining({ encryption: true }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
recoveryKey: undefined,
|
||||
forceResetCrossSigning: false,
|
||||
});
|
||||
expect(getMatrixVerificationStatusMock).toHaveBeenCalledWith({
|
||||
accountId: "ops",
|
||||
cfg: expect.any(Object),
|
||||
});
|
||||
expect(console.log).toHaveBeenCalledWith("Account: ops");
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"Encryption config: enabled at channels.matrix.accounts.ops",
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith("Bootstrap success: yes");
|
||||
expect(console.log).toHaveBeenCalledWith("Verified by owner: yes");
|
||||
expect(console.log).toHaveBeenCalledWith("Backup: active and trusted on this device");
|
||||
});
|
||||
|
||||
it("skips encryption bootstrap when an encrypted account is already healthy", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
ops: {
|
||||
encryption: true,
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
matrixRuntimeLoadConfigMock.mockReturnValue(cfg);
|
||||
resolveMatrixAccountMock.mockReturnValue({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
config: cfg.channels?.matrix?.accounts?.ops,
|
||||
});
|
||||
resolveMatrixAccountConfigMock.mockReturnValue({
|
||||
encryption: true,
|
||||
});
|
||||
mockMatrixVerificationStatus({
|
||||
recoveryKeyCreatedAt: "2026-03-09T06:00:00.000Z",
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "encryption", "setup", "--account", "ops", "--json"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(bootstrapMatrixVerificationMock).not.toHaveBeenCalled();
|
||||
expect(getMatrixVerificationStatusMock).toHaveBeenCalledTimes(1);
|
||||
expect(getMatrixVerificationStatusMock).toHaveBeenCalledWith({
|
||||
accountId: "ops",
|
||||
cfg: expect.any(Object),
|
||||
readiness: "none",
|
||||
});
|
||||
const jsonOutput = stdoutWriteMock.mock.calls.at(-1)?.[0];
|
||||
expect(typeof jsonOutput).toBe("string");
|
||||
expect(JSON.parse(String(jsonOutput))).toEqual(
|
||||
expect.objectContaining({
|
||||
accountId: "ops",
|
||||
encryptionChanged: false,
|
||||
bootstrap: expect.objectContaining({
|
||||
success: true,
|
||||
cryptoBootstrap: null,
|
||||
}),
|
||||
status: expect.objectContaining({
|
||||
verified: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("bootstraps verification for newly added encrypted accounts", async () => {
|
||||
resolveMatrixAccountConfigMock.mockReturnValue({
|
||||
encryption: true,
|
||||
@@ -1072,6 +1307,7 @@ describe("matrix CLI verification commands", () => {
|
||||
|
||||
expect(bootstrapMatrixVerificationMock).toHaveBeenCalledWith({
|
||||
accountId: "ops",
|
||||
cfg: expect.any(Object),
|
||||
});
|
||||
expect(console.log).toHaveBeenCalledWith("Matrix verification bootstrap: complete");
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
@@ -1218,6 +1454,7 @@ describe("matrix CLI verification commands", () => {
|
||||
expect(console.log).toHaveBeenCalledWith("Config path: channels.matrix.accounts.main-bot");
|
||||
expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cfg: expect.any(Object),
|
||||
accountId: "main-bot",
|
||||
displayName: "Main Bot",
|
||||
}),
|
||||
@@ -1229,6 +1466,21 @@ describe("matrix CLI verification commands", () => {
|
||||
|
||||
it("forwards --avatar-url through account add setup and profile sync", async () => {
|
||||
matrixRuntimeLoadConfigMock.mockReturnValue({ channels: {} });
|
||||
matrixSetupApplyAccountConfigMock.mockImplementation(
|
||||
({ cfg, accountId }: { cfg: Record<string, unknown>; accountId: string }) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...(cfg.channels as Record<string, unknown> | undefined),
|
||||
matrix: {
|
||||
accounts: {
|
||||
[accountId]: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
@@ -1261,6 +1513,17 @@ describe("matrix CLI verification commands", () => {
|
||||
);
|
||||
expect(updateMatrixOwnProfileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cfg: expect.objectContaining({
|
||||
channels: expect.objectContaining({
|
||||
matrix: expect.objectContaining({
|
||||
accounts: expect.objectContaining({
|
||||
"ops-bot": expect.objectContaining({
|
||||
homeserver: "https://matrix.example.org",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
accountId: "ops-bot",
|
||||
displayName: "Ops Bot",
|
||||
avatarUrl: "mxc://example/ops-avatar",
|
||||
|
||||
@@ -245,6 +245,7 @@ type MatrixCliAccountAddResult = {
|
||||
accountId: string;
|
||||
configPath: string;
|
||||
useEnv: boolean;
|
||||
encryptionEnabled: boolean;
|
||||
deviceHealth: {
|
||||
currentDeviceId: string | null;
|
||||
staleOpenClawDeviceIds: string[];
|
||||
@@ -280,6 +281,7 @@ async function addMatrixAccount(params: {
|
||||
initialSyncLimit?: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
useEnv?: boolean;
|
||||
enableEncryption?: boolean;
|
||||
}): Promise<MatrixCliAccountAddResult> {
|
||||
const runtime = getMatrixRuntime();
|
||||
const cfg = runtime.config.loadConfig() as CoreConfig;
|
||||
@@ -315,11 +317,14 @@ async function addMatrixAccount(params: {
|
||||
throw new Error(validationError);
|
||||
}
|
||||
|
||||
const updated = matrixSetupAdapter.applyAccountConfig({
|
||||
let updated = matrixSetupAdapter.applyAccountConfig({
|
||||
cfg,
|
||||
accountId,
|
||||
input,
|
||||
}) as CoreConfig;
|
||||
if (params.enableEncryption === true) {
|
||||
updated = updateMatrixAccountConfig(updated, accountId, { encryption: true });
|
||||
}
|
||||
await runtime.config.writeConfigFile(updated as never);
|
||||
const accountConfig = resolveMatrixAccountConfig({ cfg: updated, accountId });
|
||||
|
||||
@@ -350,6 +355,7 @@ async function addMatrixAccount(params: {
|
||||
if (desiredDisplayName || desiredAvatarUrl) {
|
||||
try {
|
||||
const synced = await updateMatrixOwnProfile({
|
||||
cfg: updated,
|
||||
accountId,
|
||||
displayName: desiredDisplayName,
|
||||
avatarUrl: desiredAvatarUrl,
|
||||
@@ -406,6 +412,7 @@ async function addMatrixAccount(params: {
|
||||
accountId,
|
||||
configPath: resolveMatrixConfigPath(updated, accountId),
|
||||
useEnv: input.useEnv === true,
|
||||
encryptionEnabled: accountConfig.encryption === true,
|
||||
deviceHealth,
|
||||
verificationBootstrap,
|
||||
profile,
|
||||
@@ -591,6 +598,7 @@ type MatrixCliVerificationStatus = {
|
||||
serverDeviceKnown?: boolean | null;
|
||||
recoveryKeyStored: boolean;
|
||||
recoveryKeyCreatedAt: string | null;
|
||||
recoveryKeyId: string | null;
|
||||
pendingVerifications: number;
|
||||
recoveryKeyAccepted?: boolean;
|
||||
backupUsable?: boolean;
|
||||
@@ -659,6 +667,108 @@ type MatrixCliDirectRoomRepair = MatrixCliDirectRoomInspection & {
|
||||
directContentAfter: Record<string, string[]>;
|
||||
};
|
||||
|
||||
type MatrixCliVerificationBootstrap = Awaited<ReturnType<typeof bootstrapMatrixVerification>>;
|
||||
|
||||
type MatrixCliEncryptionSetupResult = {
|
||||
accountId: string;
|
||||
configPath: string;
|
||||
encryptionChanged: boolean;
|
||||
bootstrap: MatrixCliVerificationBootstrap;
|
||||
status: MatrixCliVerificationStatus;
|
||||
};
|
||||
|
||||
function isMatrixVerificationSetupComplete(status: MatrixCliVerificationStatus): boolean {
|
||||
return (
|
||||
status.encryptionEnabled &&
|
||||
status.verified &&
|
||||
status.crossSigningVerified &&
|
||||
status.signedByOwner &&
|
||||
status.serverDeviceKnown === true &&
|
||||
resolveMatrixRoomKeyBackupIssue(resolveBackupStatus(status)).code === "ok"
|
||||
);
|
||||
}
|
||||
|
||||
function buildNoopMatrixVerificationBootstrap(
|
||||
status: MatrixCliVerificationStatus,
|
||||
): MatrixCliVerificationBootstrap {
|
||||
const verification = {
|
||||
...status,
|
||||
backup: resolveBackupStatus(status),
|
||||
serverDeviceKnown: status.serverDeviceKnown ?? null,
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
verification,
|
||||
crossSigning: {
|
||||
userId: status.userId,
|
||||
masterKeyPublished: status.crossSigningVerified,
|
||||
selfSigningKeyPublished: status.signedByOwner,
|
||||
userSigningKeyPublished: status.signedByOwner,
|
||||
published: status.crossSigningVerified && status.signedByOwner,
|
||||
},
|
||||
pendingVerifications: status.pendingVerifications,
|
||||
cryptoBootstrap: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function setupMatrixEncryption(params: {
|
||||
account?: string;
|
||||
recoveryKey?: string;
|
||||
forceResetCrossSigning?: boolean;
|
||||
}): Promise<MatrixCliEncryptionSetupResult> {
|
||||
const runtime = getMatrixRuntime();
|
||||
const { accountId, cfg } = resolveMatrixCliAccountContext(params.account);
|
||||
const account = resolveMatrixAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(
|
||||
`Matrix account "${accountId}" is not configured; run ${formatMatrixCliCommand(
|
||||
"account add",
|
||||
accountId,
|
||||
)} first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const currentAccountConfig = resolveMatrixAccountConfig({ cfg, accountId });
|
||||
const encryptionChanged = currentAccountConfig.encryption !== true;
|
||||
const updated = encryptionChanged
|
||||
? updateMatrixAccountConfig(cfg, accountId, { encryption: true })
|
||||
: cfg;
|
||||
if (encryptionChanged) {
|
||||
await runtime.config.writeConfigFile(updated as never);
|
||||
}
|
||||
|
||||
const canUseExistingBootstrap =
|
||||
!encryptionChanged && !params.recoveryKey && params.forceResetCrossSigning !== true;
|
||||
const existingStatus = canUseExistingBootstrap
|
||||
? await getMatrixVerificationStatus({ accountId, cfg: updated, readiness: "none" })
|
||||
: null;
|
||||
if (existingStatus && isMatrixVerificationSetupComplete(existingStatus)) {
|
||||
return {
|
||||
accountId,
|
||||
configPath: resolveMatrixConfigPath(updated, accountId),
|
||||
encryptionChanged,
|
||||
bootstrap: buildNoopMatrixVerificationBootstrap(existingStatus),
|
||||
status: existingStatus,
|
||||
};
|
||||
}
|
||||
|
||||
const bootstrap = await bootstrapMatrixVerification({
|
||||
accountId,
|
||||
cfg: updated,
|
||||
recoveryKey: params.recoveryKey,
|
||||
forceResetCrossSigning: params.forceResetCrossSigning === true,
|
||||
});
|
||||
const status = await getMatrixVerificationStatus({ accountId, cfg: updated });
|
||||
|
||||
return {
|
||||
accountId,
|
||||
configPath: resolveMatrixConfigPath(updated, accountId),
|
||||
encryptionChanged,
|
||||
bootstrap,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
function toCliDirectRoomCandidate(room: MatrixDirectRoomCandidate): MatrixCliDirectRoomCandidate {
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
@@ -1233,6 +1343,33 @@ function printVerificationStatus(
|
||||
printVerificationGuidance(status, accountId);
|
||||
}
|
||||
|
||||
function printMatrixEncryptionSetupResult(
|
||||
result: MatrixCliEncryptionSetupResult,
|
||||
verbose = false,
|
||||
): void {
|
||||
printAccountLabel(result.accountId);
|
||||
console.log(
|
||||
`Encryption config: ${result.encryptionChanged ? "enabled" : "already enabled"} at ${formatMatrixCliText(
|
||||
result.configPath,
|
||||
)}`,
|
||||
);
|
||||
console.log(`Bootstrap success: ${result.bootstrap.success ? "yes" : "no"}`);
|
||||
if (result.bootstrap.error) {
|
||||
console.log(`Bootstrap error: ${formatMatrixCliText(result.bootstrap.error)}`);
|
||||
}
|
||||
console.log(`Verified by owner: ${result.status.verified ? "yes" : "no"}`);
|
||||
printVerificationBackupSummary(result.status);
|
||||
if (verbose) {
|
||||
printVerificationIdentity(result.status);
|
||||
printVerificationTrustDiagnostics(result.status);
|
||||
printVerificationBackupStatus(result.status);
|
||||
console.log(`Recovery key stored: ${result.status.recoveryKeyStored ? "yes" : "no"}`);
|
||||
printTimestamp("Recovery key created at", result.status.recoveryKeyCreatedAt);
|
||||
console.log(`Pending verifications: ${result.status.pendingVerifications}`);
|
||||
}
|
||||
printVerificationGuidance(result.status, result.accountId);
|
||||
}
|
||||
|
||||
export function registerMatrixCli(params: { program: Command }): void {
|
||||
const root = params.program
|
||||
.command("matrix")
|
||||
@@ -1258,6 +1395,8 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
.option("--password <password>", "Matrix password")
|
||||
.option("--device-name <name>", "Matrix device display name")
|
||||
.option("--initial-sync-limit <n>", "Matrix initial sync limit")
|
||||
.option("--enable-e2ee", "Enable Matrix end-to-end encryption and bootstrap verification")
|
||||
.option("--encryption", "Alias for --enable-e2ee")
|
||||
.option(
|
||||
"--use-env",
|
||||
"Use MATRIX_* env vars (or MATRIX_<ACCOUNT_ID>_* for non-default accounts)",
|
||||
@@ -1277,6 +1416,8 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
password?: string;
|
||||
deviceName?: string;
|
||||
initialSyncLimit?: string;
|
||||
enableE2ee?: boolean;
|
||||
encryption?: boolean;
|
||||
useEnv?: boolean;
|
||||
verbose?: boolean;
|
||||
json?: boolean;
|
||||
@@ -1297,6 +1438,7 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
password: options.password,
|
||||
deviceName: options.deviceName,
|
||||
initialSyncLimit: options.initialSyncLimit,
|
||||
enableEncryption: options.enableE2ee === true || options.encryption === true,
|
||||
useEnv: options.useEnv === true,
|
||||
}),
|
||||
onText: (result) => {
|
||||
@@ -1305,6 +1447,7 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
console.log(
|
||||
`Credentials source: ${result.useEnv ? "MATRIX_* / MATRIX_<ACCOUNT_ID>_* env vars" : "inline config"}`,
|
||||
);
|
||||
console.log(`Encryption: ${result.encryptionEnabled ? "enabled" : "disabled"}`);
|
||||
if (result.verificationBootstrap.attempted) {
|
||||
if (result.verificationBootstrap.success) {
|
||||
console.log("Matrix verification bootstrap: complete");
|
||||
@@ -1466,6 +1609,44 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
},
|
||||
);
|
||||
|
||||
const encryption = root.command("encryption").description("Set up Matrix end-to-end encryption");
|
||||
|
||||
encryption
|
||||
.command("setup")
|
||||
.description("Enable Matrix E2EE, bootstrap verification, and print next steps")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--recovery-key <key>", "Recovery key to apply before bootstrap")
|
||||
.option("--force-reset-cross-signing", "Force reset cross-signing identity before bootstrap")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
async (options: {
|
||||
account?: string;
|
||||
recoveryKey?: string;
|
||||
forceResetCrossSigning?: boolean;
|
||||
verbose?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
await runMatrixCliCommand({
|
||||
verbose: options.verbose === true,
|
||||
json: options.json === true,
|
||||
run: async () =>
|
||||
await setupMatrixEncryption({
|
||||
account: options.account,
|
||||
recoveryKey: options.recoveryKey,
|
||||
forceResetCrossSigning: options.forceResetCrossSigning === true,
|
||||
}),
|
||||
onText: (result, verbose) => {
|
||||
printMatrixEncryptionSetupResult(result, verbose);
|
||||
},
|
||||
onJson: (result) => ({ success: result.bootstrap.success, ...result }),
|
||||
shouldFail: (result) => !result.bootstrap.success,
|
||||
errorPrefix: "Encryption setup failed",
|
||||
onJsonError: (message) => ({ success: false, error: message }),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const verify = root.command("verify").description("Device verification for Matrix E2EE");
|
||||
|
||||
verify
|
||||
@@ -1721,9 +1902,14 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--include-recovery-key", "Include stored recovery key in output")
|
||||
.option(
|
||||
"--allow-degraded-local-state",
|
||||
"Return best-effort diagnostics without preparing the Matrix account",
|
||||
)
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
async (options: {
|
||||
allowDegradedLocalState?: boolean;
|
||||
account?: string;
|
||||
verbose?: boolean;
|
||||
includeRecoveryKey?: boolean;
|
||||
@@ -1738,6 +1924,7 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
accountId,
|
||||
cfg,
|
||||
includeRecoveryKey: options.includeRecoveryKey === true,
|
||||
...(options.allowDegradedLocalState === true ? { readiness: "none" as const } : {}),
|
||||
}),
|
||||
onText: (status, verbose) => {
|
||||
printAccountLabel(accountId);
|
||||
|
||||
@@ -195,6 +195,23 @@ describe("action client helpers", () => {
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "stop");
|
||||
});
|
||||
|
||||
it("can discard read-only shared action clients without persisting crypto state", async () => {
|
||||
const sharedClient = createMockMatrixClient();
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
|
||||
|
||||
const result = await withResolvedActionClient(
|
||||
{ cfg: TEST_CFG, accountId: "default" },
|
||||
async (client) => {
|
||||
expect(client).toBe(sharedClient);
|
||||
return "ok";
|
||||
},
|
||||
"discard",
|
||||
);
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(releaseSharedClientInstanceMock).toHaveBeenCalledWith(sharedClient, "discard");
|
||||
});
|
||||
|
||||
it("stops shared action clients when the wrapped call throws", async () => {
|
||||
const sharedClient = createMockMatrixClient();
|
||||
acquireSharedMatrixClientMock.mockResolvedValue(sharedClient);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { withResolvedRuntimeMatrixClient } from "../client-bootstrap.js";
|
||||
import { resolveMatrixRoomId } from "../send.js";
|
||||
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
|
||||
|
||||
type MatrixActionClientStopMode = "stop" | "persist";
|
||||
type MatrixActionClientStopMode = "stop" | "persist" | "discard";
|
||||
|
||||
export async function withResolvedActionClient<T>(
|
||||
opts: MatrixActionClientOpts,
|
||||
|
||||
@@ -175,37 +175,43 @@ describe("matrix verification actions", () => {
|
||||
expect(loadConfigMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves verification status without starting the Matrix client", async () => {
|
||||
it("prepares local crypto before resolving authoritative verification status", async () => {
|
||||
const prepareForOneOff = vi.fn(async () => undefined);
|
||||
const start = vi.fn(async () => undefined);
|
||||
const getOwnDeviceVerificationStatus = vi.fn().mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyCreatedAt: null,
|
||||
recoveryKeyId: "SSSS",
|
||||
backupVersion: "11",
|
||||
backup: {
|
||||
serverVersion: "11",
|
||||
activeVersion: "11",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
keyLoadAttempted: false,
|
||||
keyLoadError: null,
|
||||
},
|
||||
serverDeviceKnown: true,
|
||||
});
|
||||
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
prepareForOneOff,
|
||||
crypto: {
|
||||
listVerifications: vi.fn(async () => []),
|
||||
getRecoveryKey: vi.fn(async () => ({
|
||||
encodedPrivateKey: "rec-key",
|
||||
})),
|
||||
},
|
||||
getOwnDeviceVerificationStatus: vi.fn(async () => ({
|
||||
encryptionEnabled: true,
|
||||
verified: true,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyCreatedAt: null,
|
||||
recoveryKeyId: "SSSS",
|
||||
backupVersion: "11",
|
||||
backup: {
|
||||
serverVersion: "11",
|
||||
activeVersion: "11",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
keyLoadAttempted: false,
|
||||
keyLoadError: null,
|
||||
},
|
||||
})),
|
||||
getOwnDeviceVerificationStatus,
|
||||
start,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,9 +223,68 @@ describe("matrix verification actions", () => {
|
||||
recoveryKey: "rec-key",
|
||||
});
|
||||
expect(withResolvedActionClientMock).toHaveBeenCalledTimes(1);
|
||||
expect(withResolvedActionClientMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ readiness: "none" }),
|
||||
expect.any(Function),
|
||||
"discard",
|
||||
);
|
||||
expect(prepareForOneOff).toHaveBeenCalledTimes(1);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(2);
|
||||
expect(withStartedActionClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed before local Matrix prep when the current device is gone", async () => {
|
||||
const prepareForOneOff = vi.fn(async () => undefined);
|
||||
const getOwnDeviceVerificationStatus = vi.fn(async () => ({
|
||||
encryptionEnabled: true,
|
||||
verified: false,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
localVerified: false,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: false,
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyCreatedAt: null,
|
||||
recoveryKeyId: "SSSS",
|
||||
backupVersion: "11",
|
||||
backup: {
|
||||
serverVersion: "11",
|
||||
activeVersion: "11",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
keyLoadAttempted: false,
|
||||
keyLoadError: null,
|
||||
},
|
||||
serverDeviceKnown: false,
|
||||
}));
|
||||
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
crypto: {
|
||||
listVerifications: vi.fn(async () => []),
|
||||
},
|
||||
getOwnDeviceVerificationStatus,
|
||||
prepareForOneOff,
|
||||
});
|
||||
});
|
||||
|
||||
const status = await getMatrixVerificationStatus();
|
||||
|
||||
expect(status).toMatchObject({
|
||||
deviceId: "DEVICE123",
|
||||
serverDeviceKnown: false,
|
||||
pendingVerifications: 0,
|
||||
});
|
||||
expect(withResolvedActionClientMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ readiness: "none" }),
|
||||
expect.any(Function),
|
||||
"discard",
|
||||
);
|
||||
expect(prepareForOneOff).not.toHaveBeenCalled();
|
||||
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves encryption and backup status without starting the Matrix client", async () => {
|
||||
withResolvedActionClientMock
|
||||
.mockImplementationOnce(async (_opts, run) => {
|
||||
@@ -407,12 +472,9 @@ describe("matrix verification actions", () => {
|
||||
expect(crypto.startVerification).toHaveBeenCalledWith("verification-1", "sas");
|
||||
expect(confirmSas).toHaveBeenCalledWith(sas.sas, sas);
|
||||
expect(crypto.confirmVerificationSas).toHaveBeenCalledWith("verification-1");
|
||||
expect(bootstrapOwnDeviceVerification).toHaveBeenCalledWith({
|
||||
allowAutomaticCrossSigningReset: false,
|
||||
strict: false,
|
||||
});
|
||||
expect(getOwnCrossSigningPublicationStatus).not.toHaveBeenCalled();
|
||||
expect(getOwnDeviceVerificationStatus).not.toHaveBeenCalled();
|
||||
expect(bootstrapOwnDeviceVerification).not.toHaveBeenCalled();
|
||||
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(1);
|
||||
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not complete self-verification until the OpenClaw device has full Matrix identity trust", async () => {
|
||||
@@ -442,10 +504,74 @@ describe("matrix verification actions", () => {
|
||||
requestVerification: vi.fn(async () => requested),
|
||||
startVerification: vi.fn(async () => sas),
|
||||
};
|
||||
const getOwnDeviceIdentityVerificationStatus = vi
|
||||
const getOwnDeviceVerificationStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(mockUnverifiedOwnerStatus())
|
||||
.mockResolvedValueOnce(mockVerifiedOwnerStatus());
|
||||
const getOwnCrossSigningPublicationStatus = vi.fn(async () =>
|
||||
mockCrossSigningPublicationStatus(),
|
||||
);
|
||||
const bootstrapOwnDeviceVerification = vi.fn(async () => ({
|
||||
crossSigning: mockCrossSigningPublicationStatus(),
|
||||
success: true,
|
||||
verification: mockUnverifiedOwnerStatus(),
|
||||
}));
|
||||
const trustOwnIdentityAfterSelfVerification = vi.fn(async () => {});
|
||||
withStartedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({
|
||||
bootstrapOwnDeviceVerification,
|
||||
crypto,
|
||||
getOwnCrossSigningPublicationStatus,
|
||||
getOwnDeviceVerificationStatus,
|
||||
trustOwnIdentityAfterSelfVerification,
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
runMatrixSelfVerification({ confirmSas: vi.fn(async () => true), timeoutMs: 500 }),
|
||||
).resolves.toMatchObject({
|
||||
completed: true,
|
||||
deviceOwnerVerified: true,
|
||||
ownerVerification: {
|
||||
verified: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(2);
|
||||
expect(trustOwnIdentityAfterSelfVerification).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not let the SDK identity-only status read hang completed self-verification", async () => {
|
||||
const requested = {
|
||||
completed: false,
|
||||
hasSas: false,
|
||||
id: "verification-1",
|
||||
phaseName: "requested",
|
||||
transactionId: "tx-self",
|
||||
};
|
||||
const sas = {
|
||||
...requested,
|
||||
hasSas: true,
|
||||
phaseName: "started",
|
||||
sas: {
|
||||
decimal: [1, 2, 3],
|
||||
},
|
||||
};
|
||||
const completed = {
|
||||
...sas,
|
||||
completed: true,
|
||||
phaseName: "done",
|
||||
};
|
||||
const crypto = {
|
||||
confirmVerificationSas: vi.fn(async () => completed),
|
||||
listVerifications: vi.fn(async () => [sas]),
|
||||
requestVerification: vi.fn(async () => requested),
|
||||
startVerification: vi.fn(async () => sas),
|
||||
};
|
||||
const getOwnDeviceIdentityVerificationStatus = vi.fn(
|
||||
async () => await new Promise<never>(() => undefined),
|
||||
);
|
||||
const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
|
||||
const getOwnCrossSigningPublicationStatus = vi.fn(async () =>
|
||||
mockCrossSigningPublicationStatus(),
|
||||
@@ -472,15 +598,10 @@ describe("matrix verification actions", () => {
|
||||
).resolves.toMatchObject({
|
||||
completed: true,
|
||||
deviceOwnerVerified: true,
|
||||
ownerVerification: {
|
||||
verified: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getOwnDeviceIdentityVerificationStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getOwnDeviceIdentityVerificationStatus).not.toHaveBeenCalled();
|
||||
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
|
||||
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(2);
|
||||
expect(trustOwnIdentityAfterSelfVerification).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not complete self-verification until cross-signing keys are published", async () => {
|
||||
@@ -510,7 +631,6 @@ describe("matrix verification actions", () => {
|
||||
requestVerification: vi.fn(async () => requested),
|
||||
startVerification: vi.fn(async () => sas),
|
||||
};
|
||||
const getOwnDeviceIdentityVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
|
||||
const getOwnDeviceVerificationStatus = vi.fn(async () => mockVerifiedOwnerStatus());
|
||||
const getOwnCrossSigningPublicationStatus = vi
|
||||
.fn()
|
||||
@@ -527,7 +647,6 @@ describe("matrix verification actions", () => {
|
||||
bootstrapOwnDeviceVerification,
|
||||
crypto,
|
||||
getOwnCrossSigningPublicationStatus,
|
||||
getOwnDeviceIdentityVerificationStatus,
|
||||
getOwnDeviceVerificationStatus,
|
||||
trustOwnIdentityAfterSelfVerification,
|
||||
});
|
||||
@@ -543,8 +662,7 @@ describe("matrix verification actions", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(getOwnDeviceIdentityVerificationStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(1);
|
||||
expect(getOwnDeviceVerificationStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getOwnCrossSigningPublicationStatus).toHaveBeenCalledTimes(2);
|
||||
expect(trustOwnIdentityAfterSelfVerification).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -722,6 +840,7 @@ describe("matrix verification actions", () => {
|
||||
return await run({
|
||||
bootstrapOwnDeviceVerification,
|
||||
crypto,
|
||||
getOwnCrossSigningPublicationStatus: vi.fn(async () => mockCrossSigningPublicationStatus()),
|
||||
getOwnDeviceVerificationStatus: vi.fn(async () => mockVerifiedOwnerStatus()),
|
||||
});
|
||||
});
|
||||
@@ -775,7 +894,6 @@ describe("matrix verification actions", () => {
|
||||
getOwnCrossSigningPublicationStatus: vi.fn(async () =>
|
||||
mockCrossSigningPublicationStatus(false),
|
||||
),
|
||||
getOwnDeviceIdentityVerificationStatus: vi.fn(async () => mockUnverifiedOwnerStatus()),
|
||||
getOwnDeviceVerificationStatus: vi.fn(async () => mockUnverifiedOwnerStatus()),
|
||||
});
|
||||
});
|
||||
@@ -787,10 +905,7 @@ describe("matrix verification actions", () => {
|
||||
);
|
||||
|
||||
expect(crypto.cancelVerification).not.toHaveBeenCalled();
|
||||
expect(bootstrapOwnDeviceVerification).toHaveBeenCalledWith({
|
||||
allowAutomaticCrossSigningReset: false,
|
||||
strict: false,
|
||||
});
|
||||
expect(bootstrapOwnDeviceVerification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels the pending self-verification request when acceptance times out", async () => {
|
||||
|
||||
@@ -173,17 +173,17 @@ async function waitForMatrixSelfVerificationTrustStatus(params: {
|
||||
timeoutMs: number;
|
||||
}): Promise<MatrixOwnDeviceVerificationStatus> {
|
||||
const startedAt = Date.now();
|
||||
let last: MatrixDeviceVerificationStatus | undefined;
|
||||
let last: MatrixOwnDeviceVerificationStatus | undefined;
|
||||
let crossSigningPublished = false;
|
||||
while (Date.now() - startedAt < params.timeoutMs) {
|
||||
const [status, crossSigning] = await Promise.all([
|
||||
params.client.getOwnDeviceIdentityVerificationStatus(),
|
||||
params.client.getOwnDeviceVerificationStatus(),
|
||||
params.client.getOwnCrossSigningPublicationStatus(),
|
||||
]);
|
||||
last = status;
|
||||
crossSigningPublished = crossSigning.published;
|
||||
if (last.verified && crossSigningPublished) {
|
||||
return await params.client.getOwnDeviceVerificationStatus();
|
||||
if (status.verified && crossSigningPublished) {
|
||||
return status;
|
||||
}
|
||||
await sleep(Math.min(250, Math.max(25, params.timeoutMs - (Date.now() - startedAt))));
|
||||
}
|
||||
@@ -214,20 +214,20 @@ async function completeMatrixSelfVerification(params: {
|
||||
completed: MatrixVerificationSummary;
|
||||
timeoutMs: number;
|
||||
}): Promise<MatrixSelfVerificationResult> {
|
||||
const bootstrap = await params.client.bootstrapOwnDeviceVerification({
|
||||
allowAutomaticCrossSigningReset: false,
|
||||
strict: false,
|
||||
});
|
||||
if (!bootstrap.verification.verified) {
|
||||
await params.client.trustOwnIdentityAfterSelfVerification?.();
|
||||
const initial = await Promise.all([
|
||||
params.client.getOwnDeviceVerificationStatus(),
|
||||
params.client.getOwnCrossSigningPublicationStatus(),
|
||||
]);
|
||||
let ownerVerification = initial[0];
|
||||
if (!ownerVerification.verified || !initial[1].published) {
|
||||
if (!ownerVerification.verified) {
|
||||
await params.client.trustOwnIdentityAfterSelfVerification?.();
|
||||
}
|
||||
ownerVerification = await waitForMatrixSelfVerificationTrustStatus({
|
||||
client: params.client,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
}
|
||||
const ownerVerification =
|
||||
bootstrap.verification.verified && bootstrap.crossSigning.published
|
||||
? bootstrap.verification
|
||||
: await waitForMatrixSelfVerificationTrustStatus({
|
||||
client: params.client,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
return {
|
||||
...params.completed,
|
||||
deviceOwnerVerified: ownerVerification.verified,
|
||||
@@ -482,21 +482,42 @@ export async function getMatrixEncryptionStatus(
|
||||
export async function getMatrixVerificationStatus(
|
||||
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean } = {},
|
||||
) {
|
||||
return await withResolvedActionClient(opts, async (client) => {
|
||||
const status = await client.getOwnDeviceVerificationStatus();
|
||||
const payload = {
|
||||
...status,
|
||||
pendingVerifications: client.crypto ? (await client.crypto.listVerifications()).length : 0,
|
||||
};
|
||||
if (!opts.includeRecoveryKey) {
|
||||
return payload;
|
||||
}
|
||||
const recoveryKey = client.crypto ? await client.crypto.getRecoveryKey() : null;
|
||||
return {
|
||||
...payload,
|
||||
recoveryKey: recoveryKey?.encodedPrivateKey ?? null,
|
||||
};
|
||||
});
|
||||
const readiness = opts.readiness ?? "prepared";
|
||||
return await withResolvedActionClient(
|
||||
{ ...opts, readiness: "none" },
|
||||
async (client) => {
|
||||
const preflight = await readMatrixVerificationStatus(client, opts);
|
||||
if (readiness === "none" || preflight.serverDeviceKnown === false) {
|
||||
return preflight;
|
||||
}
|
||||
if (readiness === "started") {
|
||||
await client.start();
|
||||
} else {
|
||||
await client.prepareForOneOff();
|
||||
}
|
||||
return await readMatrixVerificationStatus(client, opts);
|
||||
},
|
||||
"discard",
|
||||
);
|
||||
}
|
||||
|
||||
async function readMatrixVerificationStatus(
|
||||
client: MatrixActionClient,
|
||||
opts: MatrixActionClientOpts & { includeRecoveryKey?: boolean },
|
||||
) {
|
||||
const status = await client.getOwnDeviceVerificationStatus();
|
||||
const payload = {
|
||||
...status,
|
||||
pendingVerifications: client.crypto ? (await client.crypto.listVerifications()).length : 0,
|
||||
};
|
||||
if (!opts.includeRecoveryKey) {
|
||||
return payload;
|
||||
}
|
||||
const recoveryKey = client.crypto ? await client.crypto.getRecoveryKey() : null;
|
||||
return {
|
||||
...payload,
|
||||
recoveryKey: recoveryKey?.encodedPrivateKey ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getMatrixRoomKeyBackupStatus(opts: MatrixActionClientOpts = {}) {
|
||||
|
||||
@@ -11,7 +11,7 @@ type ResolvedRuntimeMatrixClient = {
|
||||
};
|
||||
|
||||
type MatrixRuntimeClientReadiness = "none" | "prepared" | "started";
|
||||
type ResolvedRuntimeMatrixClientStopMode = "stop" | "persist";
|
||||
type ResolvedRuntimeMatrixClientStopMode = "stop" | "persist" | "discard";
|
||||
|
||||
type MatrixResolvedClientHook = (
|
||||
client: MatrixClient,
|
||||
@@ -146,6 +146,10 @@ export async function stopResolvedRuntimeMatrixClient(
|
||||
await resolved.client.stopAndPersist();
|
||||
return;
|
||||
}
|
||||
if (mode === "discard") {
|
||||
resolved.client.stopWithoutPersist();
|
||||
return;
|
||||
}
|
||||
resolved.client.stop();
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export function createMockMatrixClient(): MatrixClient {
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(() => undefined),
|
||||
stopAndPersist: vi.fn(async () => undefined),
|
||||
stopWithoutPersist: vi.fn(() => undefined),
|
||||
} as unknown as MatrixClient;
|
||||
}
|
||||
|
||||
@@ -114,7 +115,7 @@ export async function expectOneOffSharedMatrixClient(params?: {
|
||||
timeoutMs?: number;
|
||||
prepareForOneOffCalls?: number;
|
||||
startCalls?: number;
|
||||
releaseMode?: "persist" | "stop";
|
||||
releaseMode?: "persist" | "stop" | "discard";
|
||||
}) {
|
||||
const {
|
||||
getActiveMatrixClientMock,
|
||||
|
||||
@@ -294,7 +294,7 @@ export function stopSharedClientInstance(client: MatrixClient): void {
|
||||
|
||||
export async function releaseSharedClientInstance(
|
||||
client: MatrixClient,
|
||||
mode: "stop" | "persist" = "stop",
|
||||
mode: "stop" | "persist" | "discard" = "stop",
|
||||
): Promise<boolean> {
|
||||
const state = findSharedClientStateByInstance(client);
|
||||
if (!state) {
|
||||
@@ -307,6 +307,8 @@ export async function releaseSharedClientInstance(
|
||||
deleteSharedClientState(state);
|
||||
if (mode === "persist") {
|
||||
await client.stopAndPersist();
|
||||
} else if (mode === "discard") {
|
||||
client.stopWithoutPersist();
|
||||
} else {
|
||||
client.stop();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,53 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ensureMatrixCryptoRuntime } from "./deps.js";
|
||||
|
||||
const logStub = vi.fn();
|
||||
|
||||
function resolveTestNativeBindingFilename(): string | null {
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return process.arch === "arm64"
|
||||
? "matrix-sdk-crypto.darwin-arm64.node"
|
||||
: process.arch === "x64"
|
||||
? "matrix-sdk-crypto.darwin-x64.node"
|
||||
: null;
|
||||
case "linux": {
|
||||
const report = process.report?.getReport?.() as
|
||||
| { header?: { glibcVersionRuntime?: string } }
|
||||
| undefined;
|
||||
const isMusl = !report?.header?.glibcVersionRuntime;
|
||||
if (process.arch === "x64") {
|
||||
return isMusl
|
||||
? "matrix-sdk-crypto.linux-x64-musl.node"
|
||||
: "matrix-sdk-crypto.linux-x64-gnu.node";
|
||||
}
|
||||
if (process.arch === "arm64" && !isMusl) {
|
||||
return "matrix-sdk-crypto.linux-arm64-gnu.node";
|
||||
}
|
||||
if (process.arch === "arm") {
|
||||
return "matrix-sdk-crypto.linux-arm-gnueabihf.node";
|
||||
}
|
||||
if (process.arch === "s390x") {
|
||||
return "matrix-sdk-crypto.linux-s390x-gnu.node";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
case "win32":
|
||||
return process.arch === "x64"
|
||||
? "matrix-sdk-crypto.win32-x64-msvc.node"
|
||||
: process.arch === "ia32"
|
||||
? "matrix-sdk-crypto.win32-ia32-msvc.node"
|
||||
: process.arch === "arm64"
|
||||
? "matrix-sdk-crypto.win32-arm64-msvc.node"
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe("ensureMatrixCryptoRuntime", () => {
|
||||
it("returns immediately when matrix SDK loads", async () => {
|
||||
const runCommand = vi.fn();
|
||||
@@ -71,4 +116,47 @@ describe("ensureMatrixCryptoRuntime", () => {
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expect(requireFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("removes an incomplete native binding before loading the matrix SDK", async () => {
|
||||
const nativeBindingFilename = resolveTestNativeBindingFilename();
|
||||
if (!nativeBindingFilename) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-crypto-runtime-"));
|
||||
const scriptPath = path.join(tmpDir, "download-lib.js");
|
||||
const nativeBindingPath = path.join(tmpDir, nativeBindingFilename);
|
||||
fs.writeFileSync(scriptPath, "");
|
||||
fs.writeFileSync(nativeBindingPath, Buffer.alloc(16));
|
||||
|
||||
let bootstrapped = false;
|
||||
const requireFn = vi.fn(() => {
|
||||
if (!bootstrapped) {
|
||||
throw new Error(
|
||||
"Cannot find module '@matrix-org/matrix-sdk-crypto-nodejs-linux-x64-gnu' (required by matrix sdk)",
|
||||
);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const runCommand = vi.fn(async () => {
|
||||
bootstrapped = true;
|
||||
fs.writeFileSync(nativeBindingPath, Buffer.alloc(1_000_000));
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
});
|
||||
|
||||
await ensureMatrixCryptoRuntime({
|
||||
log: logStub,
|
||||
requireFn,
|
||||
runCommand,
|
||||
resolveFn: () => scriptPath,
|
||||
nodeExecutable: "/usr/bin/node",
|
||||
});
|
||||
|
||||
expect(runCommand).toHaveBeenCalledTimes(1);
|
||||
expect(requireFn).toHaveBeenCalledTimes(2);
|
||||
expect(fs.statSync(nativeBindingPath).size).toBe(1_000_000);
|
||||
expect(logStub).toHaveBeenCalledWith(
|
||||
"matrix: removed incomplete native crypto runtime (16 bytes); it will be downloaded again",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ const REQUIRED_MATRIX_PACKAGES = [
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm",
|
||||
];
|
||||
const MIN_MATRIX_CRYPTO_NATIVE_BINDING_BYTES = 1_000_000;
|
||||
|
||||
type MatrixCryptoRuntimeDeps = {
|
||||
requireFn?: (id: string) => unknown;
|
||||
@@ -85,6 +86,11 @@ async function runFixedCommandWithTimeout(params: {
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
const killChildOnExit = () => {
|
||||
if (!settled && proc.exitCode === null) {
|
||||
proc.kill("SIGTERM");
|
||||
}
|
||||
};
|
||||
|
||||
const finalize = (result: CommandResult) => {
|
||||
if (settled) {
|
||||
@@ -94,8 +100,10 @@ async function runFixedCommandWithTimeout(params: {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
process.off("exit", killChildOnExit);
|
||||
resolve(result);
|
||||
};
|
||||
process.once("exit", killChildOnExit);
|
||||
|
||||
proc.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString();
|
||||
@@ -148,6 +156,93 @@ function isMissingMatrixCryptoRuntimeError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isMuslRuntime(): boolean {
|
||||
try {
|
||||
const report = process.report?.getReport?.() as
|
||||
| { header?: { glibcVersionRuntime?: string } }
|
||||
| undefined;
|
||||
return !report?.header?.glibcVersionRuntime;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMatrixCryptoNativeBindingFilename(): string | null {
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return process.arch === "arm64"
|
||||
? "matrix-sdk-crypto.darwin-arm64.node"
|
||||
: process.arch === "x64"
|
||||
? "matrix-sdk-crypto.darwin-x64.node"
|
||||
: null;
|
||||
case "linux":
|
||||
if (process.arch === "x64") {
|
||||
return isMuslRuntime()
|
||||
? "matrix-sdk-crypto.linux-x64-musl.node"
|
||||
: "matrix-sdk-crypto.linux-x64-gnu.node";
|
||||
}
|
||||
if (process.arch === "arm64" && !isMuslRuntime()) {
|
||||
return "matrix-sdk-crypto.linux-arm64-gnu.node";
|
||||
}
|
||||
if (process.arch === "arm") {
|
||||
return "matrix-sdk-crypto.linux-arm-gnueabihf.node";
|
||||
}
|
||||
if (process.arch === "s390x") {
|
||||
return "matrix-sdk-crypto.linux-s390x-gnu.node";
|
||||
}
|
||||
return null;
|
||||
case "win32":
|
||||
return process.arch === "x64"
|
||||
? "matrix-sdk-crypto.win32-x64-msvc.node"
|
||||
: process.arch === "ia32"
|
||||
? "matrix-sdk-crypto.win32-ia32-msvc.node"
|
||||
: process.arch === "arm64"
|
||||
? "matrix-sdk-crypto.win32-arm64-msvc.node"
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMatrixCryptoNativeBindingPath(resolveFn: (id: string) => string): string | null {
|
||||
const filename = resolveMatrixCryptoNativeBindingFilename();
|
||||
if (!filename) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return path.join(
|
||||
path.dirname(resolveFn("@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js")),
|
||||
filename,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function removeIncompleteMatrixCryptoNativeBinding(params: {
|
||||
bindingPath: string | null;
|
||||
log?: (message: string) => void;
|
||||
}): void {
|
||||
const bindingPath = params.bindingPath;
|
||||
if (!bindingPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stat = fs.statSync(bindingPath);
|
||||
if (!stat.isFile() || stat.size >= MIN_MATRIX_CRYPTO_NATIVE_BINDING_BYTES) {
|
||||
return;
|
||||
}
|
||||
fs.unlinkSync(bindingPath);
|
||||
params.log?.(
|
||||
`matrix: removed incomplete native crypto runtime (${stat.size} bytes); it will be downloaded again`,
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureMatrixCryptoRuntime(
|
||||
params: MatrixCryptoRuntimeDeps = {},
|
||||
): Promise<void> {
|
||||
@@ -170,6 +265,9 @@ export async function ensureMatrixCryptoRuntime(
|
||||
}
|
||||
|
||||
async function ensureMatrixCryptoRuntimeOnce(params: MatrixCryptoRuntimeDeps): Promise<void> {
|
||||
const resolveFn = params.resolveFn ?? defaultResolveFn;
|
||||
const nativeBindingPath = resolveMatrixCryptoNativeBindingPath(resolveFn);
|
||||
removeIncompleteMatrixCryptoNativeBinding({ bindingPath: nativeBindingPath, log: params.log });
|
||||
const requireFn = params.requireFn ?? defaultRequireFn;
|
||||
try {
|
||||
requireFn("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||
@@ -180,7 +278,6 @@ async function ensureMatrixCryptoRuntimeOnce(params: MatrixCryptoRuntimeDeps): P
|
||||
}
|
||||
}
|
||||
|
||||
const resolveFn = params.resolveFn ?? defaultResolveFn;
|
||||
const scriptPath = resolveFn("@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js");
|
||||
params.log?.("matrix: bootstrapping native crypto runtime");
|
||||
const runCommand = params.runCommand ?? runFixedCommandWithTimeout;
|
||||
@@ -192,11 +289,13 @@ async function ensureMatrixCryptoRuntimeOnce(params: MatrixCryptoRuntimeDeps): P
|
||||
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
removeIncompleteMatrixCryptoNativeBinding({ bindingPath: nativeBindingPath, log: params.log });
|
||||
throw new Error(
|
||||
result.stderr.trim() || result.stdout.trim() || "Matrix crypto runtime bootstrap failed.",
|
||||
);
|
||||
}
|
||||
|
||||
removeIncompleteMatrixCryptoNativeBinding({ bindingPath: nativeBindingPath, log: params.log });
|
||||
requireFn("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||
}
|
||||
|
||||
|
||||
@@ -196,6 +196,9 @@ function createHarness(params?: {
|
||||
flushTasks,
|
||||
runDetachedTask,
|
||||
roomMessageListener: listeners.get("room.message") as RoomEventListener | undefined,
|
||||
roomDecryptedEventListener: listeners.get("room.decrypted_event") as
|
||||
| RoomEventListener
|
||||
| undefined,
|
||||
failedDecryptListener: listeners.get("room.failed_decryption") as
|
||||
| FailedDecryptListener
|
||||
| undefined,
|
||||
@@ -402,6 +405,29 @@ describe("registerMatrixMonitorEvents verification routing", () => {
|
||||
expect(body).toContain('Open "Verify by emoji"');
|
||||
});
|
||||
|
||||
it("routes late-decrypted room messages through the normal room handler", async () => {
|
||||
const { onRoomMessage, roomDecryptedEventListener, flushTasks } = createHarness();
|
||||
if (!roomDecryptedEventListener) {
|
||||
throw new Error("room.decrypted_event listener was not registered");
|
||||
}
|
||||
const event: MatrixRawEvent = {
|
||||
event_id: "$decrypted1",
|
||||
sender: "@alice:example.org",
|
||||
type: EventType.RoomMessage,
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "@bot late decrypt trigger",
|
||||
},
|
||||
};
|
||||
|
||||
roomDecryptedEventListener("!room:example.org", event);
|
||||
|
||||
await flushTasks();
|
||||
expect(onRoomMessage).toHaveBeenCalledTimes(1);
|
||||
expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
|
||||
});
|
||||
|
||||
it("blocks verification request notices when dmPolicy pairing would block the sender", async () => {
|
||||
const { onRoomMessage, sendMessage, roomMessageListener, logVerboseMessage, flushTasks } =
|
||||
createHarness({
|
||||
|
||||
@@ -256,6 +256,18 @@ export function registerMatrixMonitorEvents(params: {
|
||||
const eventId = event?.event_id ?? "unknown";
|
||||
const eventType = event?.type ?? "unknown";
|
||||
logVerboseMessage(`matrix: decrypted event room=${roomId} type=${eventType} id=${eventId}`);
|
||||
if (routeVerificationEvent(roomId, event)) {
|
||||
return;
|
||||
}
|
||||
if (eventType !== EventType.RoomMessage) {
|
||||
return;
|
||||
}
|
||||
void runMonitorTask(
|
||||
`decrypted room message handler room=${roomId} id=${event.event_id ?? "unknown"}`,
|
||||
async () => {
|
||||
await onRoomMessage(roomId, event);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
client.on(
|
||||
|
||||
@@ -51,15 +51,16 @@ class FakeMatrixEvent extends EventEmitter {
|
||||
private readonly roomId: string;
|
||||
private readonly eventId: string;
|
||||
private readonly sender: string;
|
||||
private readonly type: string;
|
||||
private type: string;
|
||||
private readonly ts: number;
|
||||
private readonly content: Record<string, unknown>;
|
||||
private content: Record<string, unknown>;
|
||||
private readonly stateKey?: string;
|
||||
private readonly unsigned?: {
|
||||
age?: number;
|
||||
redacted_because?: unknown;
|
||||
};
|
||||
private readonly decryptionFailure: boolean;
|
||||
readonly decryptionFailureReason?: string;
|
||||
private decryptionFailure: boolean;
|
||||
|
||||
constructor(params: {
|
||||
roomId: string;
|
||||
@@ -74,6 +75,7 @@ class FakeMatrixEvent extends EventEmitter {
|
||||
redacted_because?: unknown;
|
||||
};
|
||||
decryptionFailure?: boolean;
|
||||
decryptionFailureReason?: string;
|
||||
}) {
|
||||
super();
|
||||
this.roomId = params.roomId;
|
||||
@@ -84,6 +86,7 @@ class FakeMatrixEvent extends EventEmitter {
|
||||
this.content = params.content;
|
||||
this.stateKey = params.stateKey;
|
||||
this.unsigned = params.unsigned;
|
||||
this.decryptionFailureReason = params.decryptionFailureReason;
|
||||
this.decryptionFailure = params.decryptionFailure === true;
|
||||
}
|
||||
|
||||
@@ -122,6 +125,12 @@ class FakeMatrixEvent extends EventEmitter {
|
||||
isDecryptionFailure(): boolean {
|
||||
return this.decryptionFailure;
|
||||
}
|
||||
|
||||
markDecrypted(params: { type: string; content: Record<string, unknown> }): void {
|
||||
this.type = params.type;
|
||||
this.content = params.content;
|
||||
this.decryptionFailure = false;
|
||||
}
|
||||
}
|
||||
|
||||
type MatrixJsClientStub = {
|
||||
@@ -249,6 +258,7 @@ vi.mock("matrix-js-sdk/lib/matrix.js", async () => {
|
||||
});
|
||||
|
||||
const { encodeRecoveryKey } = await import("matrix-js-sdk/lib/crypto-api/recovery-key.js");
|
||||
const { DecryptionFailureCode } = await import("matrix-js-sdk/lib/crypto-api/index.js");
|
||||
const { MatrixClient } = await import("./sdk.js");
|
||||
|
||||
describe("MatrixClient request hardening", () => {
|
||||
@@ -862,6 +872,128 @@ describe("MatrixClient event bridge", () => {
|
||||
expect(delivered).toEqual(["m.room.message"]);
|
||||
});
|
||||
|
||||
it("does not keep retrying terminal historical decryption failures", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
const failed: string[] = [];
|
||||
|
||||
client.on("room.failed_decryption", (_roomId, _event, error) => {
|
||||
failed.push(error.message);
|
||||
});
|
||||
|
||||
const encrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$historical",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.encrypted",
|
||||
ts: Date.now() - 60_000,
|
||||
content: {},
|
||||
decryptionFailure: true,
|
||||
decryptionFailureReason: DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP,
|
||||
});
|
||||
|
||||
matrixJsClient.decryptEventIfNeeded = vi.fn(async () => {});
|
||||
|
||||
await client.start();
|
||||
matrixJsClient.emit("event", encrypted);
|
||||
encrypted.emit("decrypted", encrypted, new Error("historical key missing"));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
|
||||
expect(failed).toEqual(["historical key missing"]);
|
||||
expect(matrixJsClient.decryptEventIfNeeded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits a recovered message when decrypt retry succeeds without a second SDK decrypted event", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
encryption: true,
|
||||
});
|
||||
const delivered: string[] = [];
|
||||
|
||||
client.on("room.message", (_roomId, event) => {
|
||||
delivered.push(event.type);
|
||||
});
|
||||
|
||||
const encrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.encrypted",
|
||||
ts: Date.now(),
|
||||
content: {},
|
||||
decryptionFailure: true,
|
||||
});
|
||||
|
||||
matrixJsClient.decryptEventIfNeeded = vi.fn(async () => {
|
||||
encrypted.markDecrypted({
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await client.start();
|
||||
matrixJsClient.emit("event", encrypted);
|
||||
encrypted.emit("decrypted", encrypted, new Error("missing room key"));
|
||||
|
||||
expect(delivered).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_500);
|
||||
|
||||
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(delivered).toEqual(["m.room.message"]);
|
||||
});
|
||||
|
||||
it("retries encrypted events that already failed before the bridge attaches", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
encryption: true,
|
||||
});
|
||||
const failed: string[] = [];
|
||||
const delivered: string[] = [];
|
||||
|
||||
client.on("room.failed_decryption", (_roomId, _event, error) => {
|
||||
failed.push(error.message);
|
||||
});
|
||||
client.on("room.message", (_roomId, event) => {
|
||||
delivered.push(event.type);
|
||||
});
|
||||
|
||||
const encrypted = new FakeMatrixEvent({
|
||||
roomId: "!room:example.org",
|
||||
eventId: "$event",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.encrypted",
|
||||
ts: Date.now(),
|
||||
content: {},
|
||||
decryptionFailure: true,
|
||||
});
|
||||
|
||||
matrixJsClient.decryptEventIfNeeded = vi.fn(async () => {
|
||||
encrypted.markDecrypted({
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
body: "hello",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await client.start();
|
||||
matrixJsClient.emit("event", encrypted);
|
||||
|
||||
expect(failed).toHaveLength(0);
|
||||
expect(delivered).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_500);
|
||||
|
||||
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
|
||||
expect(delivered).toEqual(["m.room.message"]);
|
||||
});
|
||||
|
||||
it("stops decryption retries after hitting retry cap", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
@@ -894,8 +1026,11 @@ describe("MatrixClient event bridge", () => {
|
||||
await vi.advanceTimersByTimeAsync(200_000);
|
||||
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8);
|
||||
|
||||
encrypted.emit("decrypted", encrypted, new Error("missing room key again"));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200_000);
|
||||
expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(8);
|
||||
expect(failed).toEqual(["missing room key"]);
|
||||
});
|
||||
|
||||
it("does not start duplicate retries when crypto signals fire while retry is in-flight", async () => {
|
||||
@@ -1502,9 +1637,9 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("schedules periodic crypto snapshot persistence with fake timers", async () => {
|
||||
vi.useFakeTimers();
|
||||
it("schedules periodic crypto snapshot persistence", async () => {
|
||||
const databasesSpy = vi.spyOn(indexedDB, "databases").mockResolvedValue([]);
|
||||
const setIntervalSpy = vi.spyOn(globalThis, "setInterval");
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
encryption: true,
|
||||
@@ -1513,17 +1648,10 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
});
|
||||
|
||||
await client.start();
|
||||
const callsAfterStart = databasesSpy.mock.calls.length;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(databasesSpy.mock.calls.length).toBeGreaterThan(callsAfterStart);
|
||||
});
|
||||
|
||||
expect(databasesSpy).toHaveBeenCalled();
|
||||
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 60_000);
|
||||
client.stop();
|
||||
const callsAfterStop = databasesSpy.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
expect(databasesSpy.mock.calls.length).toBe(callsAfterStop);
|
||||
});
|
||||
|
||||
it("reports own verification status when crypto marks device as verified", async () => {
|
||||
@@ -1609,6 +1737,63 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
expect(status.serverDeviceKnown).toBeNull();
|
||||
});
|
||||
|
||||
it("reports the current Matrix device missing when the homeserver rejects the token", async () => {
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
matrixJsClient.getDevices = vi.fn(async () => {
|
||||
throw Object.assign(new Error("M_UNKNOWN_TOKEN: access token invalidated"), {
|
||||
body: { errcode: "M_UNKNOWN_TOKEN" },
|
||||
statusCode: 401,
|
||||
});
|
||||
});
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
encryption: true,
|
||||
});
|
||||
await client.start();
|
||||
|
||||
const status = await client.getOwnDeviceVerificationStatus();
|
||||
expect(status.serverDeviceKnown).toBe(false);
|
||||
});
|
||||
|
||||
it("returns degraded verification diagnostics when Matrix SDK status calls stall", async () => {
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
encryption: true,
|
||||
localTimeoutMs: 1,
|
||||
});
|
||||
vi.spyOn(client, "getRoomKeyBackupStatus").mockImplementation(
|
||||
async () => await new Promise<never>(() => undefined),
|
||||
);
|
||||
vi.spyOn(client, "getDeviceVerificationStatus").mockImplementation(
|
||||
async () => await new Promise<never>(() => undefined),
|
||||
);
|
||||
vi.spyOn(client, "listOwnDevices").mockImplementation(
|
||||
async () => await new Promise<never>(() => undefined),
|
||||
);
|
||||
|
||||
const status = await client.getOwnDeviceVerificationStatus();
|
||||
|
||||
expect(status.userId).toBe("@bot:example.org");
|
||||
expect(status.deviceId).toBe("DEVICE123");
|
||||
expect(status.verified).toBe(false);
|
||||
expect(status.crossSigningVerified).toBe(false);
|
||||
expect(status.backupVersion).toBeNull();
|
||||
expect(status.backup.keyLoadAttempted).toBe(false);
|
||||
expect(status.serverDeviceKnown).toBeNull();
|
||||
});
|
||||
|
||||
it("does not treat local-only trust as Matrix identity trust", async () => {
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
@@ -2016,6 +2201,75 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
expect(persisted.encodedPrivateKey).toBe(previousEncoded);
|
||||
});
|
||||
|
||||
it("returns recovery-key diagnostics without bootstrapping when backup is already usable", async () => {
|
||||
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
|
||||
const bootstrapCrossSigning = vi.fn(async () => {
|
||||
throw new Error("bootstrap should not run");
|
||||
});
|
||||
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning,
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
getSecretStorageStatus: vi.fn(async () => ({
|
||||
ready: true,
|
||||
defaultKeyId: "SSSSKEY",
|
||||
secretStorageKeyValidityMap: { SSSSKEY: true },
|
||||
})),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => false,
|
||||
localVerified: false,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: false,
|
||||
})),
|
||||
checkKeyBackupAndEnable: vi.fn(async () => {}),
|
||||
getActiveSessionBackupVersion: vi.fn(async () => "11"),
|
||||
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
|
||||
getKeyBackupInfo: vi.fn(async () => ({
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
auth_data: {},
|
||||
version: "11",
|
||||
})),
|
||||
isKeyBackupTrusted: vi.fn(async () => ({
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-restored-"));
|
||||
const recoveryKeyPath = path.join(recoveryDir, "recovery-key.json");
|
||||
fs.writeFileSync(
|
||||
recoveryKeyPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
keyId: "SSSSKEY",
|
||||
encodedPrivateKey: encoded,
|
||||
privateKeyBase64: Buffer.from(
|
||||
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)),
|
||||
).toString("base64"),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
encryption: true,
|
||||
recoveryKeyPath,
|
||||
});
|
||||
|
||||
const result = await client.verifyWithRecoveryKey(encoded as string);
|
||||
|
||||
expect(bootstrapCrossSigning).not.toHaveBeenCalled();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.recoveryKeyAccepted).toBe(true);
|
||||
expect(result.backupUsable).toBe(true);
|
||||
expect(result.deviceOwnerVerified).toBe(false);
|
||||
expect(result.error).toContain("full Matrix identity trust");
|
||||
});
|
||||
|
||||
it("fails recovery-key verification when backup remains untrusted after device verification", async () => {
|
||||
const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)));
|
||||
|
||||
|
||||
@@ -106,6 +106,89 @@ export type MatrixRoomKeyBackupStatus = {
|
||||
keyLoadError: string | null;
|
||||
};
|
||||
|
||||
const MATRIX_STATUS_DIAGNOSTIC_TIMEOUT_MS = 10_000;
|
||||
|
||||
function unresolvedMatrixRoomKeyBackupStatus(): MatrixRoomKeyBackupStatus {
|
||||
return {
|
||||
serverVersion: null,
|
||||
activeVersion: null,
|
||||
trusted: null,
|
||||
matchesDecryptionKey: null,
|
||||
decryptionKeyCached: null,
|
||||
keyLoadAttempted: false,
|
||||
keyLoadError: null,
|
||||
};
|
||||
}
|
||||
|
||||
function unresolvedMatrixDeviceVerificationStatus(params: {
|
||||
userId: string | null;
|
||||
deviceId: string | null;
|
||||
}): MatrixDeviceVerificationStatus {
|
||||
return {
|
||||
encryptionEnabled: true,
|
||||
userId: params.userId,
|
||||
deviceId: params.deviceId,
|
||||
verified: false,
|
||||
localVerified: false,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveMatrixDiagnostic<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
): Promise<T | null> {
|
||||
const result = await resolveMatrixDiagnosticResult(promise, timeoutMs);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
async function resolveMatrixDiagnosticResult<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
): Promise<{ error: unknown; timedOut: boolean; value: T | null }> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const guarded = promise
|
||||
.then((value) => ({ error: null, timedOut: false, value }))
|
||||
.catch((error: unknown) => ({ error, timedOut: false, value: null }));
|
||||
const timeout = new Promise<{ error: null; timedOut: true; value: null }>((resolve) => {
|
||||
timeoutId = setTimeout(
|
||||
() => resolve({ error: null, timedOut: true, value: null }),
|
||||
timeoutMs,
|
||||
);
|
||||
timeoutId.unref?.();
|
||||
});
|
||||
return await Promise.race([guarded, timeout]);
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isMatrixAccessTokenInvalidatedError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") {
|
||||
return false;
|
||||
}
|
||||
const err = error as {
|
||||
body?: { errcode?: string };
|
||||
data?: { errcode?: string };
|
||||
statusCode?: number;
|
||||
};
|
||||
const errcode = err.body?.errcode ?? err.data?.errcode;
|
||||
if (err.statusCode === 401 && errcode === "M_UNKNOWN_TOKEN") {
|
||||
return true;
|
||||
}
|
||||
const reason = formatMatrixErrorReason(error);
|
||||
return (
|
||||
reason.includes("m_unknown_token") ||
|
||||
reason.includes("unknown token") ||
|
||||
(reason.includes("access token") &&
|
||||
(reason.includes("invalid") || reason.includes("unrecognized") || reason.includes("unknown")))
|
||||
);
|
||||
}
|
||||
|
||||
export type MatrixRoomKeyBackupRestoreResult = {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@@ -615,6 +698,12 @@ export class MatrixClient {
|
||||
await this.stopPersistPromise;
|
||||
}
|
||||
|
||||
stopWithoutPersist(): void {
|
||||
this.stopSyncWithoutPersist();
|
||||
this.decryptBridge?.stop();
|
||||
this.stopPersistPromise = Promise.resolve();
|
||||
}
|
||||
|
||||
private async bootstrapCryptoIfNeeded(abortSignal?: AbortSignal): Promise<void> {
|
||||
if (!this.encryptionEnabled || !this.cryptoInitialized || this.cryptoBootstrapped) {
|
||||
return;
|
||||
@@ -731,7 +820,9 @@ export class MatrixClient {
|
||||
}
|
||||
|
||||
async getJoinedRooms(): Promise<string[]> {
|
||||
const joined = await this.client.getJoinedRooms();
|
||||
const joined = (await this.doRequest("GET", "/_matrix/client/v3/joined_rooms")) as {
|
||||
joined_rooms?: unknown;
|
||||
};
|
||||
return Array.isArray(joined.joined_rooms) ? joined.joined_rooms : [];
|
||||
}
|
||||
|
||||
@@ -744,6 +835,19 @@ export class MatrixClient {
|
||||
return Object.keys(joined);
|
||||
}
|
||||
|
||||
hasSyncedJoinedRoomMember(roomId: string, userId: string): boolean {
|
||||
const room = (
|
||||
this.client as {
|
||||
getRoom?: (roomId: string) => {
|
||||
currentState?: {
|
||||
getMember?: (userId: string) => { membership?: string | null } | null;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
).getRoom?.(roomId);
|
||||
return room?.currentState?.getMember?.(userId)?.membership === "join";
|
||||
}
|
||||
|
||||
async getRoomStateEvent(
|
||||
roomId: string,
|
||||
eventType: string,
|
||||
@@ -1127,23 +1231,34 @@ export class MatrixClient {
|
||||
const recoveryKey = this.recoveryKeyStore.getRecoveryKeySummary();
|
||||
const userId = this.client.getUserId() ?? this.selfUserId ?? null;
|
||||
const deviceId = this.client.getDeviceId()?.trim() || null;
|
||||
const diagnosticTimeoutMs = Math.min(this.localTimeoutMs, MATRIX_STATUS_DIAGNOSTIC_TIMEOUT_MS);
|
||||
const [backup, deviceVerification, ownDevices] = await Promise.all([
|
||||
this.getRoomKeyBackupStatus(),
|
||||
this.getDeviceVerificationStatus(userId, deviceId),
|
||||
this.listOwnDevices().catch(() => null),
|
||||
resolveMatrixDiagnostic(this.getRoomKeyBackupStatus(), diagnosticTimeoutMs),
|
||||
resolveMatrixDiagnostic(
|
||||
this.getDeviceVerificationStatus(userId, deviceId),
|
||||
diagnosticTimeoutMs,
|
||||
),
|
||||
resolveMatrixDiagnosticResult(this.listOwnDevices(), diagnosticTimeoutMs),
|
||||
]);
|
||||
const resolvedBackup = backup ?? unresolvedMatrixRoomKeyBackupStatus();
|
||||
const resolvedDeviceVerification =
|
||||
deviceVerification ?? unresolvedMatrixDeviceVerificationStatus({ userId, deviceId });
|
||||
const serverDeviceKnown = deviceId
|
||||
? (ownDevices?.some((device) => device.deviceId === deviceId) ?? null)
|
||||
? ownDevices.value
|
||||
? ownDevices.value.some((device) => device.deviceId === deviceId)
|
||||
: isMatrixAccessTokenInvalidatedError(ownDevices.error)
|
||||
? false
|
||||
: null
|
||||
: null;
|
||||
|
||||
return {
|
||||
...deviceVerification,
|
||||
verified: deviceVerification.crossSigningVerified,
|
||||
...resolvedDeviceVerification,
|
||||
verified: resolvedDeviceVerification.crossSigningVerified,
|
||||
recoveryKeyStored: Boolean(recoveryKey),
|
||||
recoveryKeyCreatedAt: recoveryKey?.createdAt ?? null,
|
||||
recoveryKeyId: recoveryKey?.keyId ?? null,
|
||||
backupVersion: backup.serverVersion,
|
||||
backup,
|
||||
backupVersion: resolvedBackup.serverVersion,
|
||||
backup: resolvedBackup,
|
||||
serverDeviceKnown,
|
||||
};
|
||||
}
|
||||
@@ -1241,6 +1356,61 @@ export class MatrixClient {
|
||||
return await fail(formatMatrixErrorMessage(err));
|
||||
}
|
||||
|
||||
const storedRecoveryKeyMatches =
|
||||
this.recoveryKeyStore.getRecoveryKeySummary()?.encodedPrivateKey?.trim() ===
|
||||
trimmedRecoveryKey;
|
||||
if (backupUsableBeforeStagedRecovery && storedRecoveryKeyMatches) {
|
||||
const status = await this.getOwnDeviceVerificationStatus();
|
||||
const backupUsable =
|
||||
resolveMatrixRoomKeyBackupReadinessError(status.backup, {
|
||||
requireServerBackup: true,
|
||||
}) === null;
|
||||
const backupError = resolveMatrixRoomKeyBackupReadinessError(status.backup, {
|
||||
requireServerBackup: false,
|
||||
});
|
||||
const recoveryKeyAccepted = backupUsable;
|
||||
if (!status.verified) {
|
||||
if (recoveryKeyAccepted) {
|
||||
this.recoveryKeyStore.commitStagedRecoveryKey({
|
||||
keyId: stagedKeyId,
|
||||
});
|
||||
} else {
|
||||
this.recoveryKeyStore.discardStagedRecoveryKey();
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
recoveryKeyAccepted,
|
||||
backupUsable,
|
||||
deviceOwnerVerified: false,
|
||||
error:
|
||||
"Matrix recovery key was applied, but this device still lacks full Matrix identity trust. The recovery key can unlock usable backup material only when 'Backup usable' is yes; full identity trust still requires Matrix cross-signing verification.",
|
||||
...status,
|
||||
};
|
||||
}
|
||||
if (backupError) {
|
||||
this.recoveryKeyStore.discardStagedRecoveryKey();
|
||||
return {
|
||||
success: false,
|
||||
recoveryKeyAccepted,
|
||||
backupUsable,
|
||||
deviceOwnerVerified: true,
|
||||
error: backupError,
|
||||
...status,
|
||||
};
|
||||
}
|
||||
this.recoveryKeyStore.commitStagedRecoveryKey({
|
||||
keyId: stagedKeyId,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
recoveryKeyAccepted: true,
|
||||
backupUsable,
|
||||
deviceOwnerVerified: true,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
...status,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const cryptoBootstrapper = this.cryptoBootstrapper;
|
||||
if (!cryptoBootstrapper) {
|
||||
@@ -1275,9 +1445,6 @@ export class MatrixClient {
|
||||
!stagedRecoveryKeyConfirmedBySecretStorage &&
|
||||
!backupUsableBeforeStagedRecovery &&
|
||||
backupUsable;
|
||||
const storedRecoveryKeyMatches =
|
||||
this.recoveryKeyStore.getRecoveryKeySummary()?.encodedPrivateKey?.trim() ===
|
||||
trimmedRecoveryKey;
|
||||
const stagedRecoveryKeyValidated =
|
||||
(stagedRecoveryKeyUsed &&
|
||||
(stagedRecoveryKeyConfirmedBySecretStorage || stagedRecoveryKeyUnlockedBackup)) ||
|
||||
@@ -1585,6 +1752,7 @@ export class MatrixClient {
|
||||
|
||||
let bootstrapError: string | undefined;
|
||||
let bootstrapSummary: MatrixCryptoBootstrapResult | null = null;
|
||||
let rawRecoveryKey: string | undefined;
|
||||
try {
|
||||
await this.ensureStartedForCryptoControlPlane();
|
||||
await this.ensureCryptoSupportInitialized();
|
||||
@@ -1593,7 +1761,7 @@ export class MatrixClient {
|
||||
throw new Error("Matrix crypto is not available (start client with encryption enabled)");
|
||||
}
|
||||
|
||||
const rawRecoveryKey = params?.recoveryKey?.trim();
|
||||
rawRecoveryKey = params?.recoveryKey?.trim();
|
||||
if (rawRecoveryKey) {
|
||||
this.recoveryKeyStore.stageEncodedRecoveryKey({
|
||||
encodedPrivateKey: rawRecoveryKey,
|
||||
@@ -1607,7 +1775,12 @@ export class MatrixClient {
|
||||
}
|
||||
bootstrapSummary = await cryptoBootstrapper.bootstrap(
|
||||
crypto,
|
||||
createMatrixExplicitBootstrapOptions(params),
|
||||
createMatrixExplicitBootstrapOptions({
|
||||
...params,
|
||||
allowAutomaticCrossSigningReset: rawRecoveryKey
|
||||
? false
|
||||
: params?.allowAutomaticCrossSigningReset,
|
||||
}),
|
||||
);
|
||||
await this.ensureRoomKeyBackupEnabled(crypto);
|
||||
} catch (err) {
|
||||
@@ -1625,6 +1798,7 @@ export class MatrixClient {
|
||||
const backupError =
|
||||
verificationError === null
|
||||
? resolveMatrixRoomKeyBackupReadinessError(verification.backup, {
|
||||
allowUntrustedMatchingKey: Boolean(rawRecoveryKey),
|
||||
requireServerBackup: true,
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js";
|
||||
import { DecryptionFailureCode } from "matrix-js-sdk/lib/crypto-api/index.js";
|
||||
import { MatrixEventEvent, type MatrixEvent } from "matrix-js-sdk/lib/matrix.js";
|
||||
import { LogService, noop } from "./logger.js";
|
||||
|
||||
@@ -46,11 +47,34 @@ function isDecryptionFailure(event: MatrixEvent): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function getDecryptionFailureReason(event: MatrixEvent): DecryptionFailureCode | null {
|
||||
const reason = (event as { decryptionFailureReason?: unknown }).decryptionFailureReason;
|
||||
return typeof reason === "string" && reason in DecryptionFailureCode
|
||||
? (reason as DecryptionFailureCode)
|
||||
: null;
|
||||
}
|
||||
|
||||
function shouldRetryDecryptionFailure(event: MatrixEvent): boolean {
|
||||
if (!isDecryptionFailure(event)) {
|
||||
return false;
|
||||
}
|
||||
const reason = getDecryptionFailureReason(event);
|
||||
if (!reason) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
reason === DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID ||
|
||||
reason === DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX ||
|
||||
reason === DecryptionFailureCode.UNKNOWN_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
export class MatrixDecryptBridge<TRawEvent extends DecryptBridgeRawEvent> {
|
||||
private readonly trackedEncryptedEvents = new WeakSet<object>();
|
||||
private readonly decryptedMessageDedupe = new Map<string, number>();
|
||||
private readonly decryptRetries = new Map<string, MatrixDecryptRetryState>();
|
||||
private readonly failedDecryptionsNotified = new Set<string>();
|
||||
private readonly exhaustedDecryptRetries = new Set<string>();
|
||||
private activeRetryRuns = 0;
|
||||
private readonly retryIdleResolvers = new Set<() => void>();
|
||||
private cryptoRetrySignalsBound = false;
|
||||
@@ -91,6 +115,11 @@ export class MatrixDecryptBridge<TRawEvent extends DecryptBridgeRawEvent> {
|
||||
err,
|
||||
});
|
||||
});
|
||||
if (shouldRetryDecryptionFailure(event)) {
|
||||
const raw = this.deps.toRaw(event);
|
||||
const eventId = raw.event_id || event.getId() || "";
|
||||
this.scheduleDecryptRetry({ event, roomId, eventId });
|
||||
}
|
||||
}
|
||||
|
||||
retryPendingNow(reason: string): void {
|
||||
@@ -170,11 +199,15 @@ export class MatrixDecryptBridge<TRawEvent extends DecryptBridgeRawEvent> {
|
||||
|
||||
if (params.err) {
|
||||
this.emitFailedDecryptionOnce(retryKey, decryptedRoomId, decryptedRaw, params.err);
|
||||
this.scheduleDecryptRetry({
|
||||
event: params.encryptedEvent,
|
||||
roomId: decryptedRoomId,
|
||||
eventId: retryEventId,
|
||||
});
|
||||
if (shouldRetryDecryptionFailure(params.decryptedEvent)) {
|
||||
this.scheduleDecryptRetry({
|
||||
event: params.encryptedEvent,
|
||||
roomId: decryptedRoomId,
|
||||
eventId: retryEventId,
|
||||
});
|
||||
} else if (retryKey) {
|
||||
this.clearDecryptRetry(retryKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -185,11 +218,15 @@ export class MatrixDecryptBridge<TRawEvent extends DecryptBridgeRawEvent> {
|
||||
decryptedRaw,
|
||||
new Error("Matrix event failed to decrypt"),
|
||||
);
|
||||
this.scheduleDecryptRetry({
|
||||
event: params.encryptedEvent,
|
||||
roomId: decryptedRoomId,
|
||||
eventId: retryEventId,
|
||||
});
|
||||
if (shouldRetryDecryptionFailure(params.decryptedEvent)) {
|
||||
this.scheduleDecryptRetry({
|
||||
event: params.encryptedEvent,
|
||||
roomId: decryptedRoomId,
|
||||
eventId: retryEventId,
|
||||
});
|
||||
} else if (retryKey) {
|
||||
this.clearDecryptRetry(retryKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -226,12 +263,20 @@ export class MatrixDecryptBridge<TRawEvent extends DecryptBridgeRawEvent> {
|
||||
return;
|
||||
}
|
||||
const existing = this.decryptRetries.get(retryKey);
|
||||
if (this.exhaustedDecryptRetries.has(retryKey)) {
|
||||
return;
|
||||
}
|
||||
if (existing?.timer || existing?.inFlight) {
|
||||
return;
|
||||
}
|
||||
const attempts = (existing?.attempts ?? 0) + 1;
|
||||
if (attempts > MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS) {
|
||||
this.clearDecryptRetry(retryKey);
|
||||
const retry = this.decryptRetries.get(retryKey);
|
||||
if (retry?.timer) {
|
||||
clearTimeout(retry.timer);
|
||||
}
|
||||
this.decryptRetries.delete(retryKey);
|
||||
this.exhaustedDecryptRetries.add(retryKey);
|
||||
LogService.debug(
|
||||
"MatrixClientLite",
|
||||
`Giving up decryption retry for ${params.eventId} in ${params.roomId} after ${attempts - 1} attempts`,
|
||||
@@ -289,11 +334,19 @@ export class MatrixDecryptBridge<TRawEvent extends DecryptBridgeRawEvent> {
|
||||
return;
|
||||
}
|
||||
if (isDecryptionFailure(state.event)) {
|
||||
if (!shouldRetryDecryptionFailure(state.event)) {
|
||||
this.clearDecryptRetry(retryKey);
|
||||
return;
|
||||
}
|
||||
this.scheduleDecryptRetry(state);
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearDecryptRetry(retryKey);
|
||||
const raw = this.deps.toRaw(state.event);
|
||||
this.rememberDecryptedMessage(state.roomId, raw.event_id);
|
||||
this.deps.emitDecryptedEvent(state.roomId, raw);
|
||||
this.deps.emitMessage(state.roomId, raw);
|
||||
}
|
||||
|
||||
private clearDecryptRetry(retryKey: string): void {
|
||||
@@ -302,6 +355,7 @@ export class MatrixDecryptBridge<TRawEvent extends DecryptBridgeRawEvent> {
|
||||
clearTimeout(state.timer);
|
||||
}
|
||||
this.decryptRetries.delete(retryKey);
|
||||
this.exhaustedDecryptRetries.delete(retryKey);
|
||||
this.failedDecryptionsNotified.delete(retryKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -295,6 +295,7 @@ export function createMatrixNamedAccountsConfig(params: {
|
||||
{
|
||||
homeserver: string;
|
||||
accessToken?: string;
|
||||
encryption?: boolean;
|
||||
}
|
||||
>;
|
||||
}): CoreConfig {
|
||||
|
||||
@@ -22,10 +22,15 @@ export async function maybeBootstrapNewEncryptedMatrixAccount(params: {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const previousAccountConfig = resolveMatrixAccountConfig({
|
||||
cfg: params.previousCfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
|
||||
if (
|
||||
hasExplicitMatrixAccountConfig(params.previousCfg, params.accountId) ||
|
||||
accountConfig.encryption !== true
|
||||
accountConfig.encryption !== true ||
|
||||
(hasExplicitMatrixAccountConfig(params.previousCfg, params.accountId) &&
|
||||
previousAccountConfig.encryption === true)
|
||||
) {
|
||||
return {
|
||||
attempted: false,
|
||||
@@ -36,7 +41,10 @@ export async function maybeBootstrapNewEncryptedMatrixAccount(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
const bootstrap = await bootstrapMatrixVerification({ accountId: params.accountId });
|
||||
const bootstrap = await bootstrapMatrixVerification({
|
||||
accountId: params.accountId,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
return {
|
||||
attempted: true,
|
||||
success: bootstrap.success,
|
||||
|
||||
@@ -1578,6 +1578,129 @@ describe("qa mock openai server", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses exact marker directives from request context when the latest user text is generic", async () => {
|
||||
const server = await startQaMockOpenAiServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
});
|
||||
cleanups.push(async () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
const response = await fetch(`${server.baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
stream: false,
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: "@qa-sut:matrix-qa.test reply with only this exact marker: MATRIX_QA_CANARY_TEST",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: "Continue with the QA scenario plan and report worked, failed, and blocked items.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
output: [
|
||||
{
|
||||
content: [{ text: "MATRIX_QA_CANARY_TEST" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses image generation directives from request context when the latest user text is generic", async () => {
|
||||
const server = await startQaMockOpenAiServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
});
|
||||
cleanups.push(async () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
const matrixPrompt =
|
||||
"@qa-sut:matrix-qa.test Image generation check: generate a QA lighthouse image and summarize it in one short sentence.";
|
||||
const genericPrompt =
|
||||
"Continue with the QA scenario plan and report worked, failed, and blocked items.";
|
||||
|
||||
const toolPlan = await fetch(`${server.baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
stream: false,
|
||||
input: [makeUserInput(matrixPrompt), makeUserInput(genericPrompt)],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(toolPlan.status).toBe(200);
|
||||
expect(await toolPlan.json()).toMatchObject({
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
name: "image_generate",
|
||||
arguments: expect.stringContaining("qa-lighthouse.png"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const toolResult = await fetch(`${server.baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
stream: false,
|
||||
input: [
|
||||
makeUserInput(matrixPrompt),
|
||||
makeUserInput(genericPrompt),
|
||||
{
|
||||
type: "function_call",
|
||||
name: "image_generate",
|
||||
call_id: "call_mock_image_generate_1",
|
||||
arguments: JSON.stringify({
|
||||
prompt: "A QA lighthouse",
|
||||
filename: "qa-lighthouse.png",
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "call_mock_image_generate_1",
|
||||
output: "MEDIA:/tmp/qa-lighthouse.png",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(toolResult.status).toBe(200);
|
||||
expect(await toolResult.json()).toMatchObject({
|
||||
output: [
|
||||
{
|
||||
content: [{ text: expect.stringContaining("MEDIA:/tmp/qa-lighthouse.png") }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("records image inputs and describes attached images", async () => {
|
||||
const server = await startQaMockOpenAiServer({
|
||||
host: "127.0.0.1",
|
||||
|
||||
@@ -150,6 +150,7 @@ const QA_BLOCK_STREAMING_PROMPT_RE = /block streaming qa check/i;
|
||||
const QA_SUBAGENT_DIRECT_FALLBACK_PROMPT_RE = /subagent direct fallback qa check/i;
|
||||
const QA_SUBAGENT_DIRECT_FALLBACK_WORKER_RE = /subagent direct fallback worker/i;
|
||||
const QA_SUBAGENT_DIRECT_FALLBACK_MARKER = "QA-SUBAGENT-DIRECT-FALLBACK-OK";
|
||||
const QA_IMAGE_GENERATION_PROMPT_RE = /image generation check|capability flip image check/i;
|
||||
const QA_REASONING_ONLY_RETRY_NEEDLE =
|
||||
"recorded reasoning but did not produce a user-visible answer";
|
||||
const QA_EMPTY_RESPONSE_RETRY_NEEDLE =
|
||||
@@ -671,10 +672,10 @@ function buildAssistantText(
|
||||
const mediaPath = /MEDIA:([^\n]+)/.exec(toolOutput)?.[1]?.trim();
|
||||
const exactReplyDirective =
|
||||
extractExactReplyDirective(prompt) ?? extractExactReplyDirective(allInputText);
|
||||
const finishExactlyDirective =
|
||||
extractFinishExactlyDirective(prompt) ?? extractFinishExactlyDirective(allInputText);
|
||||
const exactMarkerDirective =
|
||||
extractExactMarkerDirective(prompt) ?? extractExactMarkerDirective(allInputText);
|
||||
const finishExactlyDirective =
|
||||
extractFinishExactlyDirective(prompt) ?? extractFinishExactlyDirective(allInputText);
|
||||
const imageInputCount = countImageInputs(input);
|
||||
const activeMemorySummary = extractActiveMemorySummary(allInputText);
|
||||
const snackPreference = extractSnackPreference(activeMemorySummary ?? memorySnippet);
|
||||
@@ -703,10 +704,10 @@ function buildAssistantText(
|
||||
if (isHeartbeatPrompt(prompt)) {
|
||||
return "HEARTBEAT_OK";
|
||||
}
|
||||
if (/\bmarker\b/i.test(prompt) && exactReplyDirective) {
|
||||
if (/\bmarker\b/i.test(allInputText) && exactReplyDirective) {
|
||||
return exactReplyDirective;
|
||||
}
|
||||
if (/\bmarker\b/i.test(prompt) && exactMarkerDirective) {
|
||||
if (/\bmarker\b/i.test(allInputText) && exactMarkerDirective) {
|
||||
return exactMarkerDirective;
|
||||
}
|
||||
if (/visible skill marker/i.test(prompt)) {
|
||||
@@ -753,7 +754,7 @@ function buildAssistantText(
|
||||
if (/switch(?:ing)? models?/i.test(prompt)) {
|
||||
return `Protocol note: model switch acknowledged. Continuing on ${model || "the requested model"}.`;
|
||||
}
|
||||
if (/(image generation check|capability flip image check)/i.test(prompt) && mediaPath) {
|
||||
if (QA_IMAGE_GENERATION_PROMPT_RE.test(allInputText) && mediaPath) {
|
||||
return `Protocol note: generated the QA lighthouse image successfully.\nMEDIA:${mediaPath}`;
|
||||
}
|
||||
if (QA_SKILL_WORKSHOP_GIF_PROMPT_RE.test(prompt) && toolOutput) {
|
||||
@@ -1146,6 +1147,8 @@ async function buildResponsesPayload(
|
||||
const allInputText = extractAllRequestTexts(input, body);
|
||||
const exactReplyDirective =
|
||||
extractExactReplyDirective(prompt) ?? extractExactReplyDirective(allInputText);
|
||||
const exactMarkerDirective =
|
||||
extractExactMarkerDirective(prompt) ?? extractExactMarkerDirective(allInputText);
|
||||
const firstExactMarkerDirective = extractLabeledMarkerDirective(
|
||||
allInputText,
|
||||
"first exact marker",
|
||||
@@ -1269,6 +1272,12 @@ async function buildResponsesPayload(
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (/\bmarker\b/i.test(allInputText) && exactReplyDirective) {
|
||||
return buildAssistantEvents(exactReplyDirective);
|
||||
}
|
||||
if (/\bmarker\b/i.test(allInputText) && exactMarkerDirective) {
|
||||
return buildAssistantEvents(exactMarkerDirective);
|
||||
}
|
||||
if (QA_SKILL_WORKSHOP_REVIEW_PROMPT_RE.test(allInputText)) {
|
||||
return buildAssistantEvents(
|
||||
JSON.stringify({
|
||||
@@ -1485,7 +1494,7 @@ async function buildResponsesPayload(
|
||||
});
|
||||
}
|
||||
}
|
||||
if (/(image generation check|capability flip image check)/i.test(prompt) && !toolOutput) {
|
||||
if (QA_IMAGE_GENERATION_PROMPT_RE.test(allInputText) && !toolOutput) {
|
||||
return buildToolCallEventsWithArgs("image_generate", {
|
||||
prompt: "A QA lighthouse on a dark sea with a tiny protocol droid silhouette.",
|
||||
filename: "qa-lighthouse.png",
|
||||
|
||||
@@ -491,26 +491,81 @@ describe("matrix live qa runtime", () => {
|
||||
expect(report).toContain("observed events: /tmp/observed.json");
|
||||
});
|
||||
|
||||
it("batches Matrix scenarios by config key while preserving stable in-group order", () => {
|
||||
it("keeps Matrix scenario execution in catalog order across config changes", () => {
|
||||
const scenarios = liveTesting.findMatrixQaScenarios([
|
||||
"matrix-top-level-reply-shape",
|
||||
"matrix-room-thread-reply-override",
|
||||
"matrix-thread-follow-up",
|
||||
"matrix-room-quiet-streaming-preview",
|
||||
"matrix-reaction-notification",
|
||||
"matrix-e2ee-cli-encryption-setup-multi-account",
|
||||
"matrix-e2ee-cli-setup-then-gateway-reply",
|
||||
"matrix-e2ee-cli-self-verification",
|
||||
]);
|
||||
|
||||
expect(
|
||||
liveTesting.scheduleMatrixQaScenariosByConfig(scenarios).map(({ scenario }) => scenario.id),
|
||||
liveTesting
|
||||
.scheduleMatrixQaScenariosInCatalogOrder(scenarios)
|
||||
.map(({ scenario }) => scenario.id),
|
||||
).toEqual([
|
||||
"matrix-thread-follow-up",
|
||||
"matrix-top-level-reply-shape",
|
||||
"matrix-reaction-notification",
|
||||
"matrix-room-thread-reply-override",
|
||||
"matrix-room-quiet-streaming-preview",
|
||||
"matrix-e2ee-cli-encryption-setup-multi-account",
|
||||
"matrix-e2ee-cli-setup-then-gateway-reply",
|
||||
"matrix-e2ee-cli-self-verification",
|
||||
]);
|
||||
});
|
||||
|
||||
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,
|
||||
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),
|
||||
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),
|
||||
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);
|
||||
|
||||
@@ -54,7 +54,7 @@ const DEFAULT_MATRIX_QA_CLEANUP_TIMEOUT_MS = 90_000;
|
||||
|
||||
type MatrixQaLiveLaneGatewayHarness = {
|
||||
gateway: MatrixQaGatewayChild;
|
||||
stop(): Promise<void>;
|
||||
stop(opts?: { keepTemp?: boolean; preserveToDir?: string }): Promise<void>;
|
||||
};
|
||||
|
||||
function buildMatrixQaGatewayConfigKey(overrides?: MatrixQaConfigOverrides) {
|
||||
@@ -310,23 +310,14 @@ function buildMatrixQaScenarioResult(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleMatrixQaScenariosByConfig(
|
||||
function scheduleMatrixQaScenariosInCatalogOrder(
|
||||
scenarios: readonly (typeof MATRIX_QA_SCENARIOS)[number][],
|
||||
): MatrixQaScheduledScenario[] {
|
||||
const grouped = new Map<string, MatrixQaScheduledScenario[]>();
|
||||
return scenarios.map((scenario, originalIndex) => ({ originalIndex, scenario }));
|
||||
}
|
||||
|
||||
scenarios.forEach((scenario, originalIndex) => {
|
||||
const configKey = buildMatrixQaGatewayConfigKey(scenario.configOverrides);
|
||||
const existing = grouped.get(configKey);
|
||||
const scheduled = { originalIndex, scenario };
|
||||
if (existing) {
|
||||
existing.push(scheduled);
|
||||
return;
|
||||
}
|
||||
grouped.set(configKey, [scheduled]);
|
||||
});
|
||||
|
||||
return [...grouped.values()].flat();
|
||||
function getMatrixQaScenarioRestartReadyTimeoutMs(scenario: { timeoutMs: number }): number {
|
||||
return scenario.timeoutMs;
|
||||
}
|
||||
|
||||
export type MatrixQaRunResult = {
|
||||
@@ -411,6 +402,7 @@ async function waitForMatrixChannelReady(
|
||||
const pollMs = opts?.pollMs ?? 500;
|
||||
const timeoutMs = opts?.timeoutMs ?? 60_000;
|
||||
const startedAt = Date.now();
|
||||
let lastAccounts: unknown;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const payload = (await gateway.call(
|
||||
@@ -430,6 +422,7 @@ async function waitForMatrixChannelReady(
|
||||
>;
|
||||
};
|
||||
const accounts = payload.channelAccounts?.matrix ?? [];
|
||||
lastAccounts = accounts;
|
||||
const match = accounts.find((entry) => entry.accountId === accountId);
|
||||
if (isMatrixAccountReady(match)) {
|
||||
return;
|
||||
@@ -439,7 +432,11 @@ async function waitForMatrixChannelReady(
|
||||
}
|
||||
await sleep(pollMs);
|
||||
}
|
||||
throw new Error(`matrix account "${accountId}" did not become ready`);
|
||||
throw new Error(
|
||||
`matrix account "${accountId}" did not become ready; last matrix accounts: ${JSON.stringify(
|
||||
lastAccounts ?? [],
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function patchMatrixQaGatewayConfig(params: {
|
||||
@@ -447,21 +444,35 @@ async function patchMatrixQaGatewayConfig(params: {
|
||||
patch: Record<string, unknown>;
|
||||
restartDelayMs?: number;
|
||||
}) {
|
||||
const snapshot = (await params.gateway.call("config.get", {}, { timeoutMs: 60_000 })) as {
|
||||
hash?: string;
|
||||
};
|
||||
if (!snapshot.hash) {
|
||||
throw new Error("Matrix QA config patch requires config.get hash");
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const snapshot = (await params.gateway.call("config.get", {}, { timeoutMs: 60_000 })) as {
|
||||
hash?: string;
|
||||
};
|
||||
if (!snapshot.hash) {
|
||||
throw new Error("Matrix QA config patch requires config.get hash");
|
||||
}
|
||||
try {
|
||||
await params.gateway.call(
|
||||
"config.patch",
|
||||
{
|
||||
raw: JSON.stringify(params.patch, null, 2),
|
||||
baseHash: snapshot.hash,
|
||||
restartDelayMs: params.restartDelayMs ?? 0,
|
||||
},
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt === 0 && isMatrixQaStaleConfigPatchError(error)) {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await params.gateway.call(
|
||||
"config.patch",
|
||||
{
|
||||
raw: JSON.stringify(params.patch, null, 2),
|
||||
baseHash: snapshot.hash,
|
||||
restartDelayMs: params.restartDelayMs ?? 0,
|
||||
},
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
function isMatrixQaStaleConfigPatchError(error: unknown) {
|
||||
return formatErrorMessage(error).toLowerCase().includes("config changed since last load");
|
||||
}
|
||||
|
||||
async function startMatrixQaLiveLaneGateway(params: {
|
||||
@@ -582,6 +593,7 @@ export async function runMatrixQaLive(params: {
|
||||
let canaryArtifact: MatrixQaCanaryArtifact | undefined;
|
||||
let gatewayHarness: MatrixQaLiveLaneGatewayHarness | null = null;
|
||||
let gatewayHarnessKey: string | null = null;
|
||||
let preservedGatewayDebugDirPath: string | undefined;
|
||||
let canaryFailed = false;
|
||||
const syncState: { driver?: string; observer?: string } = {};
|
||||
const syncStreams: MatrixQaSyncStreams = {};
|
||||
@@ -604,7 +616,7 @@ export async function runMatrixQaLive(params: {
|
||||
const defaultConfigSnapshot = buildMatrixQaConfigSnapshot(gatewayConfigParams);
|
||||
const scenarioConfigSnapshots: MatrixQaScenarioConfigEntry[] = [];
|
||||
|
||||
const scheduledScenarios = scheduleMatrixQaScenariosByConfig(scenarios);
|
||||
const scheduledScenarios = scheduleMatrixQaScenariosInCatalogOrder(scenarios);
|
||||
|
||||
try {
|
||||
const ensureGatewayHarness = async (overrides?: MatrixQaConfigOverrides) => {
|
||||
@@ -754,6 +766,7 @@ export async function runMatrixQaLive(params: {
|
||||
gatewayRuntimeEnv: scenarioGateway.harness.gateway.runtimeEnv,
|
||||
gatewayStateDir: scenarioGateway.harness.gateway.runtimeEnv?.OPENCLAW_STATE_DIR,
|
||||
outputDir,
|
||||
registrationToken: harness.registrationToken,
|
||||
restartGateway: async () => {
|
||||
if (!gatewayHarness) {
|
||||
throw new Error("Matrix restart scenario requires a live gateway");
|
||||
@@ -761,7 +774,9 @@ export async function runMatrixQaLive(params: {
|
||||
writeMatrixQaProgress(`gateway restart start ${scenario.id}`);
|
||||
const measuredRestart = await measureMatrixQaStep(async () => {
|
||||
await scenarioGateway.harness.gateway.restart();
|
||||
await waitForMatrixChannelReady(scenarioGateway.harness.gateway, sutAccountId);
|
||||
await waitForMatrixChannelReady(scenarioGateway.harness.gateway, sutAccountId, {
|
||||
timeoutMs: getMatrixQaScenarioRestartReadyTimeoutMs(scenario),
|
||||
});
|
||||
});
|
||||
gatewayRestartMs += measuredRestart.durationMs;
|
||||
scenarioRestartGatewayMs += measuredRestart.durationMs;
|
||||
@@ -769,7 +784,7 @@ export async function runMatrixQaLive(params: {
|
||||
`gateway restart done ${scenario.id} ${formatMatrixQaDurationMs(measuredRestart.durationMs)}`,
|
||||
);
|
||||
},
|
||||
restartGatewayAfterStateMutation: async (mutateState) => {
|
||||
restartGatewayAfterStateMutation: async (mutateState, opts) => {
|
||||
if (!gatewayHarness) {
|
||||
throw new Error(
|
||||
"Matrix persisted-state restart scenario requires a live gateway",
|
||||
@@ -785,7 +800,14 @@ export async function runMatrixQaLive(params: {
|
||||
writeMatrixQaProgress(`gateway hard restart start ${scenario.id}`);
|
||||
const measuredRestart = await measureMatrixQaStep(async () => {
|
||||
await restartAfterStateMutation(mutateState);
|
||||
await waitForMatrixChannelReady(scenarioGateway.harness.gateway, sutAccountId);
|
||||
await waitForMatrixChannelReady(
|
||||
scenarioGateway.harness.gateway,
|
||||
opts?.waitAccountId ?? sutAccountId,
|
||||
{
|
||||
timeoutMs:
|
||||
opts?.timeoutMs ?? getMatrixQaScenarioRestartReadyTimeoutMs(scenario),
|
||||
},
|
||||
);
|
||||
});
|
||||
gatewayRestartMs += measuredRestart.durationMs;
|
||||
scenarioRestartGatewayMs += measuredRestart.durationMs;
|
||||
@@ -802,7 +824,9 @@ export async function runMatrixQaLive(params: {
|
||||
await scenarioGateway.harness.gateway.restart();
|
||||
await sleep(250);
|
||||
await queueMessage();
|
||||
await waitForMatrixChannelReady(scenarioGateway.harness.gateway, sutAccountId);
|
||||
await waitForMatrixChannelReady(scenarioGateway.harness.gateway, sutAccountId, {
|
||||
timeoutMs: getMatrixQaScenarioRestartReadyTimeoutMs(scenario),
|
||||
});
|
||||
});
|
||||
gatewayRestartMs += measuredRestart.durationMs;
|
||||
scenarioRestartGatewayMs += measuredRestart.durationMs;
|
||||
@@ -827,6 +851,12 @@ export async function runMatrixQaLive(params: {
|
||||
restartDelayMs: opts?.restartDelayMs,
|
||||
});
|
||||
},
|
||||
waitGatewayAccountReady: async (accountId, opts) => {
|
||||
await waitForMatrixChannelReady(scenarioGateway.harness.gateway, accountId, {
|
||||
timeoutMs:
|
||||
opts?.timeoutMs ?? getMatrixQaScenarioRestartReadyTimeoutMs(scenario),
|
||||
});
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -871,9 +901,20 @@ export async function runMatrixQaLive(params: {
|
||||
} finally {
|
||||
if (gatewayHarness) {
|
||||
try {
|
||||
const shouldPreserveGatewayDebugArtifacts = scenarioResults.some(
|
||||
(scenario) => scenario?.status === "fail",
|
||||
);
|
||||
preservedGatewayDebugDirPath = shouldPreserveGatewayDebugArtifacts
|
||||
? path.join(outputDir, "gateway-debug")
|
||||
: undefined;
|
||||
await cleanupMatrixQaResource({
|
||||
label: "Matrix live gateway cleanup",
|
||||
action: () => gatewayHarness!.stop(),
|
||||
action: () =>
|
||||
gatewayHarness!.stop(
|
||||
preservedGatewayDebugDirPath
|
||||
? { preserveToDir: preservedGatewayDebugDirPath }
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
appendLiveLaneIssue(cleanupErrors, "live gateway cleanup", error);
|
||||
@@ -899,6 +940,13 @@ export async function runMatrixQaLive(params: {
|
||||
details: cleanupErrors.join("\n"),
|
||||
});
|
||||
}
|
||||
if (preservedGatewayDebugDirPath) {
|
||||
checks.push({
|
||||
name: "Matrix gateway debug logs",
|
||||
status: "pass",
|
||||
details: `preserved at: ${preservedGatewayDebugDirPath}`,
|
||||
});
|
||||
}
|
||||
|
||||
const finishedAtDate = new Date();
|
||||
const finishedAt = finishedAtDate.toISOString();
|
||||
@@ -1037,7 +1085,8 @@ export async function runMatrixQaLive(params: {
|
||||
|
||||
export const __testing = {
|
||||
buildMatrixQaSummary,
|
||||
scheduleMatrixQaScenariosByConfig,
|
||||
getMatrixQaScenarioRestartReadyTimeoutMs,
|
||||
scheduleMatrixQaScenariosInCatalogOrder,
|
||||
MATRIX_QA_SCENARIOS,
|
||||
buildMatrixQaConfig,
|
||||
buildMatrixQaConfigSnapshot,
|
||||
|
||||
@@ -59,6 +59,14 @@ export type MatrixQaScenarioId =
|
||||
| "matrix-e2ee-bootstrap-success"
|
||||
| "matrix-e2ee-recovery-key-lifecycle"
|
||||
| "matrix-e2ee-recovery-owner-verification-required"
|
||||
| "matrix-e2ee-cli-account-add-enable-e2ee"
|
||||
| "matrix-e2ee-cli-encryption-setup"
|
||||
| "matrix-e2ee-cli-encryption-setup-idempotent"
|
||||
| "matrix-e2ee-cli-encryption-setup-bootstrap-failure"
|
||||
| "matrix-e2ee-cli-recovery-key-setup"
|
||||
| "matrix-e2ee-cli-recovery-key-invalid"
|
||||
| "matrix-e2ee-cli-encryption-setup-multi-account"
|
||||
| "matrix-e2ee-cli-setup-then-gateway-reply"
|
||||
| "matrix-e2ee-cli-self-verification"
|
||||
| "matrix-e2ee-state-loss-external-recovery-key"
|
||||
| "matrix-e2ee-state-loss-stored-recovery-key"
|
||||
@@ -68,6 +76,7 @@ export type MatrixQaScenarioId =
|
||||
| "matrix-e2ee-server-backup-deleted-local-reupload-restores"
|
||||
| "matrix-e2ee-corrupt-crypto-idb-snapshot"
|
||||
| "matrix-e2ee-server-device-deleted-local-state-intact"
|
||||
| "matrix-e2ee-server-device-deleted-relogin-recovers"
|
||||
| "matrix-e2ee-sync-state-loss-crypto-intact"
|
||||
| "matrix-e2ee-wrong-account-recovery-key"
|
||||
| "matrix-e2ee-history-exists-backup-empty"
|
||||
@@ -238,6 +247,11 @@ const MATRIX_QA_E2EE_CONFIG = {
|
||||
startupVerification: "off",
|
||||
} satisfies MatrixQaConfigOverrides;
|
||||
|
||||
const MATRIX_QA_E2EE_CLI_SETUP_CONFIG = {
|
||||
encryption: false,
|
||||
startupVerification: "off",
|
||||
} satisfies MatrixQaConfigOverrides;
|
||||
|
||||
export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [
|
||||
{
|
||||
id: "matrix-thread-follow-up",
|
||||
@@ -590,6 +604,86 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-cli-account-add-enable-e2ee",
|
||||
timeoutMs: 120_000,
|
||||
title: "Matrix E2EE CLI account add enables encryption and bootstraps verification",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-cli-account-add-enable-e2ee",
|
||||
name: "Matrix QA E2EE CLI Account Add Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-cli-encryption-setup",
|
||||
timeoutMs: 120_000,
|
||||
title: "Matrix E2EE CLI encryption setup upgrades an existing account",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-cli-encryption-setup",
|
||||
name: "Matrix QA E2EE CLI Encryption Setup Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-cli-encryption-setup-idempotent",
|
||||
timeoutMs: 120_000,
|
||||
title: "Matrix E2EE CLI encryption setup is idempotent on encrypted accounts",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-cli-encryption-setup-idempotent",
|
||||
name: "Matrix QA E2EE CLI Encryption Setup Idempotent Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-cli-encryption-setup-bootstrap-failure",
|
||||
timeoutMs: 120_000,
|
||||
title: "Matrix E2EE CLI encryption setup reports bootstrap failures",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-cli-encryption-setup-bootstrap-failure",
|
||||
name: "Matrix QA E2EE CLI Encryption Setup Failure Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-cli-recovery-key-setup",
|
||||
timeoutMs: 120_000,
|
||||
title: "Matrix E2EE CLI encryption setup accepts a recovery key on a second device",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-cli-recovery-key-setup",
|
||||
name: "Matrix QA E2EE CLI Recovery Key Setup Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-cli-recovery-key-invalid",
|
||||
timeoutMs: 120_000,
|
||||
title: "Matrix E2EE CLI encryption setup rejects an invalid recovery key",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-cli-recovery-key-invalid",
|
||||
name: "Matrix QA E2EE CLI Invalid Recovery Key Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-cli-encryption-setup-multi-account",
|
||||
timeoutMs: 120_000,
|
||||
title: "Matrix E2EE CLI encryption setup targets one account in a multi-account config",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-cli-encryption-setup-multi-account",
|
||||
name: "Matrix QA E2EE CLI Multi Account Setup Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-cli-setup-then-gateway-reply",
|
||||
timeoutMs: 180_000,
|
||||
title: "Matrix E2EE CLI setup leaves the gateway able to reply in encrypted rooms",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-cli-setup-then-gateway-reply",
|
||||
name: "Matrix QA E2EE CLI Setup Gateway Reply Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CLI_SETUP_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-cli-self-verification",
|
||||
timeoutMs: 180_000,
|
||||
@@ -598,7 +692,6 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [
|
||||
scenarioId: "matrix-e2ee-cli-self-verification",
|
||||
name: "Matrix QA E2EE CLI Self Verification Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-state-loss-external-recovery-key",
|
||||
@@ -680,6 +773,16 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-server-device-deleted-relogin-recovers",
|
||||
timeoutMs: 180_000,
|
||||
title: "Matrix E2EE server-side device deletion recovers through re-login and backup restore",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-server-device-deleted-relogin-recovers",
|
||||
name: "Matrix QA E2EE Server Device Relogin Recovery Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-sync-state-loss-crypto-intact",
|
||||
timeoutMs: MATRIX_QA_E2EE_REPLY_TIMEOUT_MS,
|
||||
|
||||
@@ -140,4 +140,40 @@ describe("Matrix QA CLI runtime", () => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,7 +146,15 @@ export function startMatrixQaOpenClawCli(params: {
|
||||
child.kill("SIGTERM");
|
||||
finish(
|
||||
result,
|
||||
new Error(`${formatMatrixQaCliCommand(params.args)} timed out after ${params.timeoutMs}ms`),
|
||||
new Error(
|
||||
[
|
||||
`${formatMatrixQaCliCommand(params.args)} timed out after ${params.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"),
|
||||
),
|
||||
);
|
||||
}, params.timeoutMs);
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile, rename, writeFile } from "node:fs/promises";
|
||||
|
||||
export function isMatrixQaPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
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) {
|
||||
const tempPath = `${configPath}.${randomUUID()}.tmp`;
|
||||
await writeFile(tempPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
||||
await rename(tempPath, configPath);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -30,9 +30,11 @@ export type MatrixQaScenarioContext = {
|
||||
gatewayRuntimeEnv?: NodeJS.ProcessEnv;
|
||||
gatewayStateDir?: string;
|
||||
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;
|
||||
@@ -50,6 +52,7 @@ export type MatrixQaScenarioContext = {
|
||||
patch: Record<string, unknown>,
|
||||
opts?: { restartDelayMs?: number },
|
||||
) => Promise<void>;
|
||||
waitGatewayAccountReady?: (accountId: string, opts?: { timeoutMs?: number }) => Promise<void>;
|
||||
};
|
||||
|
||||
export const NO_REPLY_WINDOW_MS = 8_000;
|
||||
|
||||
@@ -98,18 +98,22 @@ export async function rewriteMatrixSyncStoreCursor(params: { cursor: string; pat
|
||||
}
|
||||
|
||||
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 === params.context.sutUserId) {
|
||||
if (isRecord(metadata) && metadata.userId === expectedUserId) {
|
||||
score += 16;
|
||||
}
|
||||
if (isRecord(metadata) && metadata.accountId === params.context.sutAccountId) {
|
||||
if (isRecord(metadata) && metadata.accountId === expectedAccountId) {
|
||||
score += 8;
|
||||
}
|
||||
} catch {
|
||||
@@ -119,9 +123,11 @@ async function scoreMatrixStateFile(params: {
|
||||
}
|
||||
|
||||
async function resolveBestMatrixStateFile(params: {
|
||||
accountId?: string;
|
||||
context: MatrixQaScenarioContext;
|
||||
filename: string;
|
||||
stateDir: string;
|
||||
userId?: string;
|
||||
}) {
|
||||
const candidates = await findFilesByName({
|
||||
filename: params.filename,
|
||||
@@ -136,6 +142,8 @@ async function resolveBestMatrixStateFile(params: {
|
||||
score: await scoreMatrixStateFile({
|
||||
context: params.context,
|
||||
pathname,
|
||||
...(params.accountId ? { accountId: params.accountId } : {}),
|
||||
...(params.userId ? { userId: params.userId } : {}),
|
||||
}),
|
||||
})),
|
||||
);
|
||||
@@ -144,9 +152,11 @@ async function resolveBestMatrixStateFile(params: {
|
||||
}
|
||||
|
||||
export async function waitForMatrixSyncStoreWithCursor(params: {
|
||||
accountId?: string;
|
||||
context: MatrixQaScenarioContext;
|
||||
stateDir: string;
|
||||
timeoutMs: number;
|
||||
userId?: string;
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
let lastPath: string | null = null;
|
||||
@@ -155,6 +165,8 @@ export async function waitForMatrixSyncStoreWithCursor(params: {
|
||||
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) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
runMatrixQaE2eeServerBackupDeletedLocalStateIntactScenario,
|
||||
runMatrixQaE2eeServerBackupDeletedLocalReuploadRestoresScenario,
|
||||
runMatrixQaE2eeServerDeviceDeletedLocalStateIntactScenario,
|
||||
runMatrixQaE2eeServerDeviceDeletedReloginRecoversScenario,
|
||||
runMatrixQaE2eeStaleRecoveryKeyAfterBackupResetScenario,
|
||||
runMatrixQaE2eeStateLossExternalRecoveryKeyScenario,
|
||||
runMatrixQaE2eeStateLossNoRecoveryKeyScenario,
|
||||
@@ -25,6 +26,14 @@ import {
|
||||
runMatrixQaE2eeArtifactRedactionScenario,
|
||||
runMatrixQaE2eeBasicReplyScenario,
|
||||
runMatrixQaE2eeBootstrapSuccessScenario,
|
||||
runMatrixQaE2eeCliAccountAddEnableE2eeScenario,
|
||||
runMatrixQaE2eeCliEncryptionSetupBootstrapFailureScenario,
|
||||
runMatrixQaE2eeCliEncryptionSetupIdempotentScenario,
|
||||
runMatrixQaE2eeCliEncryptionSetupMultiAccountScenario,
|
||||
runMatrixQaE2eeCliEncryptionSetupScenario,
|
||||
runMatrixQaE2eeCliRecoveryKeyInvalidScenario,
|
||||
runMatrixQaE2eeCliRecoveryKeySetupScenario,
|
||||
runMatrixQaE2eeCliSetupThenGatewayReplyScenario,
|
||||
runMatrixQaE2eeCliSelfVerificationScenario,
|
||||
runMatrixQaE2eeDeviceSasVerificationScenario,
|
||||
runMatrixQaE2eeDmSasVerificationScenario,
|
||||
@@ -325,6 +334,22 @@ export async function runMatrixQaScenario(
|
||||
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":
|
||||
@@ -343,6 +368,8 @@ export async function runMatrixQaScenario(
|
||||
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":
|
||||
|
||||
@@ -30,6 +30,7 @@ export type MatrixQaScenarioArtifacts = {
|
||||
attachmentFilename?: string;
|
||||
attachmentKind?: string;
|
||||
attachmentMsgtype?: string;
|
||||
accountId?: string;
|
||||
actorUserId?: string;
|
||||
blocked?: MatrixQaScenarioArtifacts;
|
||||
catchupDriverEventId?: string;
|
||||
@@ -38,6 +39,7 @@ export type MatrixQaScenarioArtifacts = {
|
||||
dedupeCommitObserved?: boolean;
|
||||
duplicateWindowMs?: number;
|
||||
driverEventId?: string;
|
||||
driverUserId?: string;
|
||||
editEventId?: string;
|
||||
editedToken?: string;
|
||||
expectedNoReplyWindowMs?: number;
|
||||
@@ -101,6 +103,8 @@ export type MatrixQaScenarioArtifacts = {
|
||||
backupRestored?: boolean;
|
||||
backupReset?: boolean;
|
||||
completedVerificationId?: string;
|
||||
backupVersion?: string | null;
|
||||
cliDeviceId?: string | null;
|
||||
completedVerificationIds?: string[];
|
||||
currentDeviceId?: string | null;
|
||||
accountRoot?: string;
|
||||
@@ -117,7 +121,11 @@ export type MatrixQaScenarioArtifacts = {
|
||||
qrBytes?: number;
|
||||
recoveryDeviceId?: string;
|
||||
recoveryKeyPreserved?: boolean;
|
||||
decoyAccountPreserved?: boolean;
|
||||
defaultAccountPreserved?: boolean;
|
||||
recoveryKeyAccepted?: boolean;
|
||||
recoveryKeyId?: string | null;
|
||||
recoveryKeyRejected?: boolean;
|
||||
recoveryKeyStored?: boolean;
|
||||
rotatedRecoveryKeyId?: string | null;
|
||||
remainingDeviceIds?: string[];
|
||||
@@ -132,9 +140,21 @@ export type MatrixQaScenarioArtifacts = {
|
||||
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;
|
||||
verificationBootstrapAttempted?: boolean;
|
||||
verificationBootstrapSuccess?: boolean;
|
||||
gatewayReply?: MatrixQaReplyArtifact;
|
||||
verificationRoomId?: string;
|
||||
joinedRoomId?: string;
|
||||
localEventId?: string;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ describe("matrix qa e2ee client storage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shares persisted crypto by actor and scopes sync replay by scenario", () => {
|
||||
it("shares persisted crypto and sync state by actor account", () => {
|
||||
const first = __testing.buildMatrixQaE2eeStoragePaths({
|
||||
actorId: "driver",
|
||||
outputDir: "/tmp/openclaw/.artifacts/qa-e2e/matrix-run",
|
||||
@@ -34,27 +34,42 @@ describe("matrix qa e2ee client storage", () => {
|
||||
);
|
||||
expect(first.cryptoDatabasePrefix).toBe(second.cryptoDatabasePrefix);
|
||||
expect(first.recoveryKeyPath).toBe(path.join(first.accountDir, "recovery-key.json"));
|
||||
expect(first.storagePath).toBe(
|
||||
path.join(
|
||||
"/tmp/openclaw/.artifacts/qa-e2e/matrix-run",
|
||||
"matrix-e2ee",
|
||||
"accounts",
|
||||
"driver",
|
||||
"scenarios",
|
||||
"matrix-e2ee-basic-reply",
|
||||
"sync-store.json",
|
||||
),
|
||||
);
|
||||
expect(second.storagePath).toBe(
|
||||
path.join(
|
||||
"/tmp/openclaw/.artifacts/qa-e2e/matrix-run",
|
||||
"matrix-e2ee",
|
||||
"accounts",
|
||||
"driver",
|
||||
"scenarios",
|
||||
"matrix-e2ee-qr-verification",
|
||||
"sync-store.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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ import { findMatrixQaObservedEventMatch, normalizeMatrixQaObservedEvent } from "
|
||||
import type { MatrixQaObservedEvent } from "./events.js";
|
||||
import type { MatrixQaRoomEventWaitResult } from "./sync.js";
|
||||
|
||||
type MatrixQaE2eeActorId = "driver" | "observer" | `driver-${string}`;
|
||||
type MatrixQaE2eeActorId = "driver" | "observer" | `driver-${string}` | `cli-${string}`;
|
||||
|
||||
type MatrixQaE2eeRuntime = typeof import("@openclaw/matrix/test-api.js");
|
||||
|
||||
@@ -43,6 +43,24 @@ const MATRIX_QA_E2EE_SYNC_FILTER = {
|
||||
},
|
||||
};
|
||||
|
||||
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?: {
|
||||
@@ -111,6 +129,7 @@ export type MatrixQaE2eeScenarioClient = {
|
||||
roomId: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<MatrixQaRoomEventWaitResult>;
|
||||
waitForJoinedMember(params: { roomId: string; timeoutMs: number; userId: string }): Promise<void>;
|
||||
waitForRoomEvent(params: {
|
||||
predicate: (event: MatrixQaObservedEvent) => boolean;
|
||||
roomId: string;
|
||||
@@ -134,7 +153,6 @@ function buildMatrixQaE2eeStoragePaths(params: {
|
||||
}) {
|
||||
const rootDir = path.join(params.outputDir, "matrix-e2ee", "accounts", params.actorId);
|
||||
const accountDir = path.join(rootDir, "account");
|
||||
const scenarioKey = params.scenarioId.replace(/[^A-Za-z0-9_-]/g, "-").slice(-80);
|
||||
const runKey = path
|
||||
.basename(params.outputDir)
|
||||
.replace(/[^A-Za-z0-9_-]/g, "-")
|
||||
@@ -146,7 +164,7 @@ function buildMatrixQaE2eeStoragePaths(params: {
|
||||
idbSnapshotPath: path.join(accountDir, "crypto-idb-snapshot.json"),
|
||||
recoveryKeyPath: path.join(accountDir, "recovery-key.json"),
|
||||
rootDir,
|
||||
storagePath: path.join(rootDir, "scenarios", scenarioKey || "scenario", "sync-store.json"),
|
||||
storagePath: path.join(accountDir, "sync-store.json"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -198,15 +216,21 @@ export async function createMatrixQaE2eeScenarioClient(
|
||||
const client: MatrixClient = await createMatrixQaE2eeMatrixClient(params);
|
||||
const localEvents: MatrixQaObservedEvent[] = [];
|
||||
const verificationSummaries: MatrixVerificationSummary[] = [];
|
||||
const observedEventIds = new Set<string>();
|
||||
const observedEventsById = new Map<string, MatrixQaObservedEvent>();
|
||||
let cursorIndex = 0;
|
||||
|
||||
const recordEvent = (roomId: string, event: MatrixRawEvent) => {
|
||||
const normalized = normalizeMatrixQaObservedEvent(roomId, event);
|
||||
if (!normalized || observedEventIds.has(normalized.eventId)) {
|
||||
if (
|
||||
!normalized ||
|
||||
!shouldRecordMatrixQaObservedEventUpdate({
|
||||
next: normalized,
|
||||
previous: observedEventsById.get(normalized.eventId),
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
observedEventIds.add(normalized.eventId);
|
||||
observedEventsById.set(normalized.eventId, normalized);
|
||||
localEvents.push(normalized);
|
||||
params.observedEvents.push(normalized);
|
||||
};
|
||||
@@ -300,6 +324,18 @@ export async function createMatrixQaE2eeScenarioClient(
|
||||
);
|
||||
},
|
||||
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);
|
||||
},
|
||||
@@ -388,4 +424,5 @@ export const __testing = {
|
||||
MATRIX_QA_E2EE_SYNC_FILTER,
|
||||
buildMatrixQaE2eeStoragePaths,
|
||||
findMatrixQaObservedEventMatch,
|
||||
shouldRecordMatrixQaObservedEventUpdate,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user