fMatrix: fix remaining typecheck regressions

This commit is contained in:
Gustavo Madeira Santana
2026-03-11 21:34:11 +00:00
parent 9d2b104868
commit 238ff2def9
13 changed files with 281 additions and 86 deletions

View File

@@ -1,7 +1,19 @@
import { vi } from "vitest";
import { vi, type Mock } from "vitest";
import type { MatrixClient } from "./sdk.js";
export const matrixClientResolverMocks = {
type MatrixClientResolverMocks = {
loadConfigMock: Mock<() => unknown>;
getMatrixRuntimeMock: Mock<() => unknown>;
getActiveMatrixClientMock: Mock<(...args: unknown[]) => MatrixClient | null>;
createMatrixClientMock: Mock<(...args: unknown[]) => Promise<MatrixClient>>;
isBunRuntimeMock: Mock<() => boolean>;
resolveMatrixAuthMock: Mock<(...args: unknown[]) => Promise<unknown>>;
resolveMatrixAuthContextMock: Mock<
(params: { cfg: unknown; accountId?: string | null }) => unknown
>;
};
export const matrixClientResolverMocks: MatrixClientResolverMocks = {
loadConfigMock: vi.fn(() => ({})),
getMatrixRuntimeMock: vi.fn(),
getActiveMatrixClientMock: vi.fn(),

View File

@@ -102,7 +102,9 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
const memberCount = await resolveMemberCount(roomId);
if (memberCount === 2) {
try {
const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "");
const nameState = (await client.getRoomStateEvent(roomId, "m.room.name", "")) as {
name?: string | null;
} | null;
if (!nameState?.name?.trim()) {
log(`matrix: dm detected via fallback (2 members, no room name) room=${roomId}`);
return true;

View File

@@ -64,7 +64,23 @@ type MatrixHandlerTestHarnessOptions = {
getMemberDisplayName?: MatrixMonitorHandlerParams["getMemberDisplayName"];
};
export function createMatrixHandlerTestHarness(options: MatrixHandlerTestHarnessOptions = {}) {
type MatrixHandlerTestHarness = {
dispatchReplyFromConfig: () => Promise<{
queuedFinal: boolean;
counts: { final: number; block: number; tool: number };
}>;
enqueueSystemEvent: (...args: unknown[]) => void;
finalizeInboundContext: (ctx: unknown) => unknown;
handler: ReturnType<typeof createMatrixRoomMessageHandler>;
readAllowFromStore: MatrixMonitorHandlerParams["core"]["channel"]["pairing"]["readAllowFromStore"];
recordInboundSession: (...args: unknown[]) => Promise<void>;
resolveAgentRoute: () => typeof DEFAULT_ROUTE;
upsertPairingRequest: MatrixMonitorHandlerParams["core"]["channel"]["pairing"]["upsertPairingRequest"];
};
export function createMatrixHandlerTestHarness(
options: MatrixHandlerTestHarnessOptions = {},
): MatrixHandlerTestHarness {
const readAllowFromStore = options.readAllowFromStore ?? vi.fn(async () => [] as string[]);
const upsertPairingRequest =
options.upsertPairingRequest ?? vi.fn(async () => ({ code: "ABCDEFGH", created: false }));
@@ -109,7 +125,8 @@ export function createMatrixHandlerTestHarness(options: MatrixHandlerTestHarness
},
reply: {
resolveEnvelopeFormatOptions: options.resolveEnvelopeFormatOptions ?? (() => ({})),
formatAgentEnvelope: options.formatAgentEnvelope ?? (({ body }) => body),
formatAgentEnvelope:
options.formatAgentEnvelope ?? (({ body }: { body: string }) => body),
finalizeInboundContext,
createReplyDispatcherWithTyping:
options.createReplyDispatcherWithTyping ??

View File

@@ -188,7 +188,7 @@ describe("matrix monitor handler pairing account scope", () => {
accountId: "ops",
sessionKey: "agent:ops:main",
mainSessionKey: "agent:ops:main",
matchedBy: "binding.account",
matchedBy: "binding.account" as const,
}));
const { handler } = createMatrixHandlerTestHarness({

View File

@@ -1,66 +1,121 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixProfileSyncResult } from "../profile.js";
import type { MatrixOwnDeviceVerificationStatus } from "../sdk.js";
import type { MatrixLegacyCryptoRestoreResult } from "./legacy-crypto-restore.js";
import type { MatrixStartupVerificationOutcome } from "./startup-verification.js";
import { runMatrixStartupMaintenance } from "./startup.js";
const hoisted = vi.hoisted(() => ({
maybeRestoreLegacyMatrixBackup: vi.fn(async () => ({ kind: "skipped" as const })),
summarizeMatrixDeviceHealth: vi.fn(() => ({
staleOpenClawDevices: [] as Array<{ deviceId: string }>,
})),
syncMatrixOwnProfile: vi.fn(async () => ({
function createVerificationStatus(
overrides: Partial<MatrixOwnDeviceVerificationStatus> = {},
): MatrixOwnDeviceVerificationStatus {
return {
encryptionEnabled: true,
userId: "@bot:example.org",
deviceId: "DEVICE",
verified: false,
localVerified: false,
crossSigningVerified: false,
signedByOwner: false,
recoveryKeyStored: false,
recoveryKeyCreatedAt: null,
recoveryKeyId: null,
backupVersion: null,
backup: {
serverVersion: null,
activeVersion: null,
trusted: null,
matchesDecryptionKey: null,
decryptionKeyCached: null,
keyLoadAttempted: false,
keyLoadError: null,
},
...overrides,
};
}
function createProfileSyncResult(
overrides: Partial<MatrixProfileSyncResult> = {},
): MatrixProfileSyncResult {
return {
skipped: false,
displayNameUpdated: false,
avatarUpdated: false,
resolvedAvatarUrl: null,
uploadedAvatarSource: null,
convertedAvatarFromHttp: false,
...overrides,
};
}
function createStartupVerificationOutcome(
kind: Exclude<MatrixStartupVerificationOutcome["kind"], "unsupported">,
overrides: Partial<Extract<MatrixStartupVerificationOutcome, { kind: typeof kind }>> = {},
): MatrixStartupVerificationOutcome {
return {
kind,
verification: createVerificationStatus({ verified: kind === "verified" }),
...overrides,
} as MatrixStartupVerificationOutcome;
}
function createLegacyCryptoRestoreResult(
overrides: Partial<MatrixLegacyCryptoRestoreResult> = {},
): MatrixLegacyCryptoRestoreResult {
return {
kind: "skipped",
...overrides,
} as MatrixLegacyCryptoRestoreResult;
}
const hoisted = vi.hoisted(() => ({
maybeRestoreLegacyMatrixBackup: vi.fn(async () => createLegacyCryptoRestoreResult()),
summarizeMatrixDeviceHealth: vi.fn(() => ({
staleOpenClawDevices: [] as Array<{ deviceId: string }>,
})),
ensureMatrixStartupVerification: vi.fn(async () => ({ kind: "verified" as const })),
syncMatrixOwnProfile: vi.fn(async () => createProfileSyncResult()),
ensureMatrixStartupVerification: vi.fn(async () => createStartupVerificationOutcome("verified")),
updateMatrixAccountConfig: vi.fn((cfg: unknown) => cfg),
}));
vi.mock("../config-update.js", () => ({
updateMatrixAccountConfig: (...args: unknown[]) => hoisted.updateMatrixAccountConfig(...args),
updateMatrixAccountConfig: hoisted.updateMatrixAccountConfig,
}));
vi.mock("../device-health.js", () => ({
summarizeMatrixDeviceHealth: (...args: unknown[]) => hoisted.summarizeMatrixDeviceHealth(...args),
summarizeMatrixDeviceHealth: hoisted.summarizeMatrixDeviceHealth,
}));
vi.mock("../profile.js", () => ({
syncMatrixOwnProfile: (...args: unknown[]) => hoisted.syncMatrixOwnProfile(...args),
syncMatrixOwnProfile: hoisted.syncMatrixOwnProfile,
}));
vi.mock("./legacy-crypto-restore.js", () => ({
maybeRestoreLegacyMatrixBackup: (...args: unknown[]) =>
hoisted.maybeRestoreLegacyMatrixBackup(...args),
maybeRestoreLegacyMatrixBackup: hoisted.maybeRestoreLegacyMatrixBackup,
}));
vi.mock("./startup-verification.js", () => ({
ensureMatrixStartupVerification: (...args: unknown[]) =>
hoisted.ensureMatrixStartupVerification(...args),
ensureMatrixStartupVerification: hoisted.ensureMatrixStartupVerification,
}));
describe("runMatrixStartupMaintenance", () => {
beforeEach(() => {
hoisted.maybeRestoreLegacyMatrixBackup.mockClear().mockResolvedValue({ kind: "skipped" });
hoisted.maybeRestoreLegacyMatrixBackup
.mockClear()
.mockResolvedValue(createLegacyCryptoRestoreResult());
hoisted.summarizeMatrixDeviceHealth.mockClear().mockReturnValue({ staleOpenClawDevices: [] });
hoisted.syncMatrixOwnProfile.mockClear().mockResolvedValue({
skipped: false,
displayNameUpdated: false,
avatarUpdated: false,
resolvedAvatarUrl: null,
uploadedAvatarSource: null,
convertedAvatarFromHttp: false,
});
hoisted.ensureMatrixStartupVerification.mockClear().mockResolvedValue({ kind: "verified" });
hoisted.syncMatrixOwnProfile.mockClear().mockResolvedValue(createProfileSyncResult());
hoisted.ensureMatrixStartupVerification
.mockClear()
.mockResolvedValue(createStartupVerificationOutcome("verified"));
hoisted.updateMatrixAccountConfig.mockClear().mockImplementation((cfg: unknown) => cfg);
});
function createParams() {
function createParams(): Parameters<typeof runMatrixStartupMaintenance>[0] {
return {
client: {
crypto: {},
listOwnDevices: vi.fn(async () => []),
getOwnDeviceVerificationStatus: vi.fn(async () => createVerificationStatus()),
} as never,
auth: {
accountId: "ops",
@@ -90,20 +145,20 @@ describe("runMatrixStartupMaintenance", () => {
fileName: "avatar.png",
})),
env: {},
} as const;
};
}
it("persists converted avatar URLs after profile sync", async () => {
const params = createParams();
const updatedCfg = { channels: { matrix: { avatarUrl: "mxc://avatar" } } };
hoisted.syncMatrixOwnProfile.mockResolvedValue({
skipped: false,
displayNameUpdated: false,
avatarUpdated: true,
resolvedAvatarUrl: "mxc://avatar",
uploadedAvatarSource: "http",
convertedAvatarFromHttp: true,
});
hoisted.syncMatrixOwnProfile.mockResolvedValue(
createProfileSyncResult({
avatarUpdated: true,
resolvedAvatarUrl: "mxc://avatar",
uploadedAvatarSource: "http",
convertedAvatarFromHttp: true,
}),
);
hoisted.updateMatrixAccountConfig.mockReturnValue(updatedCfg);
await runMatrixStartupMaintenance(params);
@@ -132,13 +187,17 @@ describe("runMatrixStartupMaintenance", () => {
hoisted.summarizeMatrixDeviceHealth.mockReturnValue({
staleOpenClawDevices: [{ deviceId: "DEV123" }],
});
hoisted.ensureMatrixStartupVerification.mockResolvedValue({ kind: "pending" });
hoisted.maybeRestoreLegacyMatrixBackup.mockResolvedValue({
kind: "restored",
imported: 2,
total: 3,
localOnlyKeys: 1,
});
hoisted.ensureMatrixStartupVerification.mockResolvedValue(
createStartupVerificationOutcome("pending"),
);
hoisted.maybeRestoreLegacyMatrixBackup.mockResolvedValue(
createLegacyCryptoRestoreResult({
kind: "restored",
imported: 2,
total: 3,
localOnlyKeys: 1,
}),
);
await runMatrixStartupMaintenance(params);
@@ -162,10 +221,9 @@ describe("runMatrixStartupMaintenance", () => {
it("logs cooldown and request-failure verification outcomes without throwing", async () => {
const params = createParams();
params.auth.encryption = true;
hoisted.ensureMatrixStartupVerification.mockResolvedValueOnce({
kind: "cooldown",
retryAfterMs: 321,
});
hoisted.ensureMatrixStartupVerification.mockResolvedValueOnce(
createStartupVerificationOutcome("cooldown", { retryAfterMs: 321 }),
);
await runMatrixStartupMaintenance(params);
@@ -173,10 +231,9 @@ describe("runMatrixStartupMaintenance", () => {
"matrix: skipped startup verification request due to cooldown (retryAfterMs=321)",
);
hoisted.ensureMatrixStartupVerification.mockResolvedValueOnce({
kind: "request-failed",
error: "boom",
});
hoisted.ensureMatrixStartupVerification.mockResolvedValueOnce(
createStartupVerificationOutcome("request-failed", { error: "boom" }),
);
await runMatrixStartupMaintenance(params);

View File

@@ -11,6 +11,7 @@ import { ensureMatrixStartupVerification } from "./startup-verification.js";
type MatrixStartupClient = Pick<
MatrixClient,
| "crypto"
| "getOwnDeviceVerificationStatus"
| "getUserProfile"
| "listOwnDevices"
| "restoreRoomKeyBackup"

View File

@@ -801,9 +801,9 @@ export class MatrixClient {
}
let defaultKeyId: string | null | undefined = undefined;
const canReadSecretStorageStatus = typeof crypto.getSecretStorageStatus === "function"; // pragma: allowlist secret
if (canReadSecretStorageStatus) {
const status = await crypto.getSecretStorageStatus().catch(() => null); // pragma: allowlist secret
const getSecretStorageStatus = crypto.getSecretStorageStatus; // pragma: allowlist secret
if (typeof getSecretStorageStatus === "function") {
const status = await getSecretStorageStatus.call(crypto).catch(() => null); // pragma: allowlist secret
defaultKeyId = status?.defaultKeyId;
}
@@ -870,9 +870,9 @@ export class MatrixClient {
const rawRecoveryKey = params.recoveryKey?.trim();
if (rawRecoveryKey) {
let defaultKeyId: string | null | undefined = undefined;
const canReadSecretStorageStatus = typeof crypto.getSecretStorageStatus === "function"; // pragma: allowlist secret
if (canReadSecretStorageStatus) {
const status = await crypto.getSecretStorageStatus().catch(() => null); // pragma: allowlist secret
const getSecretStorageStatus = crypto.getSecretStorageStatus; // pragma: allowlist secret
if (typeof getSecretStorageStatus === "function") {
const status = await getSecretStorageStatus.call(crypto).catch(() => null); // pragma: allowlist secret
defaultKeyId = status?.defaultKeyId;
}
this.recoveryKeyStore.storeEncodedRecoveryKey({
@@ -1077,9 +1077,9 @@ export class MatrixClient {
const rawRecoveryKey = params?.recoveryKey?.trim();
if (rawRecoveryKey) {
let defaultKeyId: string | null | undefined = undefined;
const canReadSecretStorageStatus = typeof crypto.getSecretStorageStatus === "function"; // pragma: allowlist secret
if (canReadSecretStorageStatus) {
const status = await crypto.getSecretStorageStatus().catch(() => null); // pragma: allowlist secret
const getSecretStorageStatus = crypto.getSecretStorageStatus; // pragma: allowlist secret
if (typeof getSecretStorageStatus === "function") {
const status = await getSecretStorageStatus.call(crypto).catch(() => null); // pragma: allowlist secret
defaultKeyId = status?.defaultKeyId;
}
this.recoveryKeyStore.storeEncodedRecoveryKey({
@@ -1190,11 +1190,11 @@ export class MatrixClient {
private async resolveCachedRoomKeyBackupDecryptionKey(
crypto: MatrixCryptoBootstrapApi,
): Promise<boolean | null> {
const canGetSessionBackupPrivateKey = typeof crypto.getSessionBackupPrivateKey === "function"; // pragma: allowlist secret
if (!canGetSessionBackupPrivateKey) {
const getSessionBackupPrivateKey = crypto.getSessionBackupPrivateKey; // pragma: allowlist secret
if (typeof getSessionBackupPrivateKey !== "function") {
return null;
}
const key = await crypto.getSessionBackupPrivateKey().catch(() => null); // pragma: allowlist secret
const key = await getSessionBackupPrivateKey.call(crypto).catch(() => null); // pragma: allowlist secret
return key ? key.length > 0 : false;
}

View File

@@ -152,10 +152,10 @@ export class MatrixRecoveryKeyStore {
} = {},
): Promise<void> {
let status: MatrixSecretStorageStatus | null = null;
const canReadSecretStorageStatus = typeof crypto.getSecretStorageStatus === "function"; // pragma: allowlist secret
if (canReadSecretStorageStatus) {
const getSecretStorageStatus = crypto.getSecretStorageStatus; // pragma: allowlist secret
if (typeof getSecretStorageStatus === "function") {
try {
status = await crypto.getSecretStorageStatus();
status = await getSecretStorageStatus.call(crypto);
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to read secret storage status:", err);
}

View File

@@ -22,7 +22,7 @@ vi.mock("../client.js", () => ({
createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args),
isBunRuntime: () => isBunRuntimeMock(),
resolveMatrixAuth: (...args: unknown[]) => resolveMatrixAuthMock(...args),
resolveMatrixAuthContext: (...args: unknown[]) => resolveMatrixAuthContextMock(...args),
resolveMatrixAuthContext: resolveMatrixAuthContextMock,
}));
vi.mock("../../runtime.js", () => ({

View File

@@ -294,4 +294,37 @@ describe("matrix onboarding", () => {
allowFromKey: "channels.matrix.accounts.ops.dm.allowFrom",
});
});
it("reports configured when only the effective default Matrix account is configured", async () => {
setMatrixRuntime({
state: {
resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) =>
(homeDir ?? (() => "/tmp"))(),
},
config: {
loadConfig: () => ({}),
},
} as never);
const status = await matrixOnboardingAdapter.getStatus({
cfg: {
channels: {
matrix: {
defaultAccount: "ops",
accounts: {
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig,
accountOverrides: {},
});
expect(status.configured).toBe(true);
expect(status.statusLines).toContain("Matrix: configured");
expect(status.selectionHint).toBe("configured");
});
});

View File

@@ -493,7 +493,10 @@ async function runMatrixConfigure(params: {
export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const account = resolveMatrixAccount({ cfg: cfg as CoreConfig });
const account = resolveMatrixAccount({
cfg: cfg as CoreConfig,
accountId: resolveMatrixOnboardingAccountId(cfg as CoreConfig),
});
const configured = account.configured;
const sdkReady = isMatrixSdkAvailable();
return {

View File

@@ -295,4 +295,27 @@ describe("matrix legacy encrypted-state migration", () => {
);
});
});
it("warns instead of throwing when a legacy crypto path is a file", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "crypto"), "not-a-directory");
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
};
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.plans).toHaveLength(0);
expect(detection.warnings).toContain(
`Legacy Matrix encrypted state path exists but is not a directory: ${path.join(stateDir, "matrix", "crypto")}. OpenClaw skipped automatic crypto migration for that path.`,
);
});
});
});

View File

@@ -87,18 +87,50 @@ type MatrixStoredRecoveryKey = {
};
};
function isLegacyBotSdkCryptoStore(cryptoRootDir: string): boolean {
return (
fs.existsSync(path.join(cryptoRootDir, "bot-sdk.json")) ||
fs.existsSync(path.join(cryptoRootDir, "matrix-sdk-crypto.sqlite3")) ||
fs
.readdirSync(cryptoRootDir, { withFileTypes: true })
.some(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")),
)
);
function detectLegacyBotSdkCryptoStore(cryptoRootDir: string): {
detected: boolean;
warning?: string;
} {
try {
const stat = fs.statSync(cryptoRootDir);
if (!stat.isDirectory()) {
return {
detected: false,
warning:
`Legacy Matrix encrypted state path exists but is not a directory: ${cryptoRootDir}. ` +
"OpenClaw skipped automatic crypto migration for that path.",
};
}
} catch (err) {
return {
detected: false,
warning:
`Failed reading legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` +
"OpenClaw skipped automatic crypto migration for that path.",
};
}
try {
return {
detected:
fs.existsSync(path.join(cryptoRootDir, "bot-sdk.json")) ||
fs.existsSync(path.join(cryptoRootDir, "matrix-sdk-crypto.sqlite3")) ||
fs
.readdirSync(cryptoRootDir, { withFileTypes: true })
.some(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")),
),
};
} catch (err) {
return {
detected: false,
warning:
`Failed scanning legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` +
"OpenClaw skipped automatic crypto migration for that path.",
};
}
}
function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] {
@@ -110,7 +142,14 @@ function resolveLegacyMatrixFlatStorePlan(params: {
env: NodeJS.ProcessEnv;
}): MatrixLegacyCryptoPlan | { warning: string } | null {
const legacy = resolveMatrixLegacyFlatStoragePaths(resolveStateDir(params.env, os.homedir));
if (!fs.existsSync(legacy.cryptoPath) || !isLegacyBotSdkCryptoStore(legacy.cryptoPath)) {
if (!fs.existsSync(legacy.cryptoPath)) {
return null;
}
const legacyStore = detectLegacyBotSdkCryptoStore(legacy.cryptoPath);
if (legacyStore.warning) {
return { warning: legacyStore.warning };
}
if (!legacyStore.detected) {
return null;
}
@@ -183,7 +222,15 @@ function resolveMatrixLegacyCryptoPlans(params: {
continue;
}
const legacyCryptoPath = path.join(target.rootDir, "crypto");
if (!fs.existsSync(legacyCryptoPath) || !isLegacyBotSdkCryptoStore(legacyCryptoPath)) {
if (!fs.existsSync(legacyCryptoPath)) {
continue;
}
const detectedStore = detectLegacyBotSdkCryptoStore(legacyCryptoPath);
if (detectedStore.warning) {
warnings.push(detectedStore.warning);
continue;
}
if (!detectedStore.detected) {
continue;
}
if (