diff --git a/extensions/matrix/src/actions.account-propagation.test.ts b/extensions/matrix/src/actions.account-propagation.test.ts index adceb03ab95..2b6595e5ec8 100644 --- a/extensions/matrix/src/actions.account-propagation.test.ts +++ b/extensions/matrix/src/actions.account-propagation.test.ts @@ -82,4 +82,27 @@ describe("matrixMessageActions account propagation", () => { expect.any(Object), ); }); + + it("forwards accountId for self-profile updates", async () => { + await matrixMessageActions.handleAction?.( + createContext({ + action: "set-profile", + accountId: "ops", + params: { + displayName: "Ops Bot", + avatarUrl: "mxc://example/avatar", + }, + }), + ); + + expect(mocks.handleMatrixAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: "setProfile", + accountId: "ops", + displayName: "Ops Bot", + avatarUrl: "mxc://example/avatar", + }), + expect.any(Object), + ); + }); }); diff --git a/extensions/matrix/src/actions.test.ts b/extensions/matrix/src/actions.test.ts index 72341362d33..7c7e5ef466d 100644 --- a/extensions/matrix/src/actions.test.ts +++ b/extensions/matrix/src/actions.test.ts @@ -66,4 +66,16 @@ describe("matrixMessageActions", () => { expect(supportsAction!({ action: "poll" } as never)).toBe(false); expect(supportsAction!({ action: "poll-vote" } as never)).toBe(true); }); + + it("exposes and handles self-profile updates", () => { + const listActions = matrixMessageActions.listActions; + const supportsAction = matrixMessageActions.supportsAction; + + const actions = listActions!({ + cfg: createConfiguredMatrixConfig(), + } as never); + + expect(actions).toContain("set-profile"); + expect(supportsAction!({ action: "set-profile" } as never)).toBe(true); + }); }); diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts index 022e87300ea..b504fcaba0b 100644 --- a/extensions/matrix/src/actions.ts +++ b/extensions/matrix/src/actions.ts @@ -22,6 +22,7 @@ const MATRIX_PLUGIN_HANDLED_ACTIONS = new Set([ "pin", "unpin", "list-pins", + "set-profile", "member-info", "channel-info", "permissions", @@ -53,6 +54,9 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { actions.add("unpin"); actions.add("list-pins"); } + if (gate("profile")) { + actions.add("set-profile"); + } if (gate("memberInfo")) { actions.add("member-info"); } @@ -184,6 +188,14 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { }); } + if (action === "set-profile") { + return await dispatch({ + action: "setProfile", + displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"), + avatarUrl: readStringParam(params, "avatarUrl"), + }); + } + if (action === "member-info") { const userId = readStringParam(params, "userId", { required: true }); return await dispatch({ diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts index 60e213a89c4..fd189a92b76 100644 --- a/extensions/matrix/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -310,6 +310,9 @@ describe("matrix CLI verification commands", () => { getMatrixVerificationStatusMock.mockResolvedValue({ encryptionEnabled: true, verified: true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, userId: "@bot:example.org", deviceId: "DEVICE123", backupVersion: "1", @@ -332,6 +335,8 @@ describe("matrix CLI verification commands", () => { `Recovery key created at: ${formatExpectedLocalTimestamp(recoveryCreatedAt)}`, ); expect(console.log).toHaveBeenCalledWith("Diagnostics:"); + expect(console.log).toHaveBeenCalledWith("Locally trusted: yes"); + expect(console.log).toHaveBeenCalledWith("Signed by owner: yes"); expect(setMatrixSdkLogModeMock).toHaveBeenCalledWith("default"); }); @@ -413,6 +418,9 @@ describe("matrix CLI verification commands", () => { getMatrixVerificationStatusMock.mockResolvedValue({ encryptionEnabled: true, verified: true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, userId: "@bot:example.org", deviceId: "DEVICE123", backupVersion: "1", @@ -444,6 +452,9 @@ describe("matrix CLI verification commands", () => { getMatrixVerificationStatusMock.mockResolvedValue({ encryptionEnabled: true, verified: true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, userId: "@bot:example.org", deviceId: "DEVICE123", backupVersion: "5256", @@ -479,6 +490,9 @@ describe("matrix CLI verification commands", () => { getMatrixVerificationStatusMock.mockResolvedValue({ encryptionEnabled: true, verified: true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, userId: "@bot:example.org", deviceId: "DEVICE123", backupVersion: "5256", diff --git a/extensions/matrix/src/cli.ts b/extensions/matrix/src/cli.ts index 829fef21819..325e7dc3d95 100644 --- a/extensions/matrix/src/cli.ts +++ b/extensions/matrix/src/cli.ts @@ -15,6 +15,7 @@ import { } from "./matrix/actions/verification.js"; import { setMatrixSdkLogMode } from "./matrix/client/logging.js"; import { resolveMatrixConfigPath, updateMatrixAccountConfig } from "./matrix/config-update.js"; +import { applyMatrixProfileUpdate, type MatrixProfileUpdateResult } from "./profile-update.js"; import { getMatrixRuntime } from "./runtime.js"; import type { CoreConfig } from "./types.js"; @@ -200,60 +201,18 @@ async function addMatrixAccount(params: { }; } -type MatrixCliProfileSetResult = { - accountId: string; - displayName: string | null; - avatarUrl: string | null; - profile: { - displayNameUpdated: boolean; - avatarUpdated: boolean; - resolvedAvatarUrl: string | null; - convertedAvatarFromHttp: boolean; - }; - configPath: string; -}; +type MatrixCliProfileSetResult = MatrixProfileUpdateResult; async function setMatrixProfile(params: { account?: string; name?: string; avatarUrl?: string; }): Promise { - const runtime = getMatrixRuntime(); - const cfg = runtime.config.loadConfig() as CoreConfig; - const accountId = normalizeAccountId(params.account); - const displayName = params.name?.trim() || null; - const avatarUrl = params.avatarUrl?.trim() || null; - if (!displayName && !avatarUrl) { - throw new Error("Provide --name and/or --avatar-url."); - } - - const synced = await updateMatrixOwnProfile({ - accountId, - displayName: displayName ?? undefined, - avatarUrl: avatarUrl ?? undefined, + return await applyMatrixProfileUpdate({ + account: params.account, + displayName: params.name, + avatarUrl: params.avatarUrl, }); - const persistedAvatarUrl = - synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl - ? synced.resolvedAvatarUrl - : avatarUrl; - const updated = updateMatrixAccountConfig(cfg, accountId, { - name: displayName ?? undefined, - avatarUrl: persistedAvatarUrl ?? undefined, - }); - await runtime.config.writeConfigFile(updated as never); - - return { - accountId, - displayName, - avatarUrl: persistedAvatarUrl ?? null, - profile: { - displayNameUpdated: synced.displayNameUpdated, - avatarUpdated: synced.avatarUpdated, - resolvedAvatarUrl: synced.resolvedAvatarUrl, - convertedAvatarFromHttp: synced.convertedAvatarFromHttp, - }, - configPath: resolveMatrixConfigPath(updated, accountId), - }; } type MatrixCliCommandConfig = { @@ -309,6 +268,9 @@ type MatrixCliVerificationStatus = { verified: boolean; userId: string | null; deviceId: string | null; + localVerified: boolean; + crossSigningVerified: boolean; + signedByOwner: boolean; backupVersion: string | null; backup?: MatrixCliBackupStatus; recoveryKeyStored: boolean; @@ -391,6 +353,16 @@ function printVerificationBackupStatus(status: { printBackupStatus(resolveBackupStatus(status)); } +function printVerificationTrustDiagnostics(status: { + localVerified: boolean; + crossSigningVerified: boolean; + signedByOwner: boolean; +}): void { + console.log(`Locally trusted: ${status.localVerified ? "yes" : "no"}`); + console.log(`Cross-signing verified: ${status.crossSigningVerified ? "yes" : "no"}`); + console.log(`Signed by owner: ${status.signedByOwner ? "yes" : "no"}`); +} + function printVerificationGuidance(status: MatrixCliVerificationStatus): void { printGuidance(buildVerificationGuidance(status)); } @@ -525,7 +497,7 @@ function printGuidance(lines: string[]): void { } function printVerificationStatus(status: MatrixCliVerificationStatus, verbose = false): void { - console.log(`Verified: ${status.verified ? "yes" : "no"}`); + console.log(`Verified by owner: ${status.verified ? "yes" : "no"}`); const backup = resolveBackupStatus(status); const backupIssue = resolveBackupIssue(backup); printVerificationBackupSummary(status); @@ -535,6 +507,7 @@ function printVerificationStatus(status: MatrixCliVerificationStatus, verbose = if (verbose) { console.log("Diagnostics:"); printVerificationIdentity(status); + printVerificationTrustDiagnostics(status); printVerificationBackupStatus(status); console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`); printTimestamp("Recovery key created at", status.recoveryKeyCreatedAt); @@ -804,9 +777,10 @@ export function registerMatrixCli(params: { program: Command }): void { if (result.error) { console.log(`Error: ${result.error}`); } - console.log(`Verified: ${result.verification.verified ? "yes" : "no"}`); + console.log(`Verified by owner: ${result.verification.verified ? "yes" : "no"}`); printVerificationIdentity(result.verification); if (verbose) { + printVerificationTrustDiagnostics(result.verification); console.log( `Cross-signing published: ${result.crossSigning.published ? "yes" : "no"} (master=${result.crossSigning.masterKeyPublished ? "yes" : "no"}, self=${result.crossSigning.selfSigningKeyPublished ? "yes" : "no"}, user=${result.crossSigning.userSigningKeyPublished ? "yes" : "no"})`, ); @@ -853,6 +827,7 @@ export function registerMatrixCli(params: { program: Command }): void { printVerificationIdentity(result); printVerificationBackupSummary(result); if (verbose) { + printVerificationTrustDiagnostics(result); printVerificationBackupStatus(result); printTimestamp("Recovery key created at", result.recoveryKeyCreatedAt); printTimestamp("Verified at", result.verifiedAt); diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index e9b03d60a1b..e25ba141a2e 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -13,6 +13,7 @@ const matrixActionSchema = z reactions: z.boolean().optional(), messages: z.boolean().optional(), pins: z.boolean().optional(), + profile: z.boolean().optional(), memberInfo: z.boolean().optional(), channelInfo: z.boolean().optional(), verification: z.boolean().optional(), diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 161eb28150a..89730b69c0c 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -402,7 +402,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi env: process.env, }); if (startupVerification.kind === "verified") { - logger.info("matrix: device is verified and ready for encrypted rooms"); + logger.info("matrix: device is verified by its owner and ready for encrypted rooms"); } else if ( startupVerification.kind === "disabled" || startupVerification.kind === "cooldown" || diff --git a/extensions/matrix/src/matrix/monitor/startup-verification.test.ts b/extensions/matrix/src/matrix/monitor/startup-verification.test.ts index b45674a5ed5..b35dfbaeffc 100644 --- a/extensions/matrix/src/matrix/monitor/startup-verification.test.ts +++ b/extensions/matrix/src/matrix/monitor/startup-verification.test.ts @@ -22,6 +22,9 @@ type VerificationSummaryLike = { function createHarness(params?: { verified?: boolean; + localVerified?: boolean; + crossSigningVerified?: boolean; + signedByOwner?: boolean; requestVerification?: () => Promise<{ id: string; transactionId?: string }>; listVerifications?: () => Promise; }) { @@ -37,9 +40,9 @@ function createHarness(params?: { userId: "@bot:example.org", deviceId: "DEVICE123", verified: params?.verified === true, - localVerified: params?.verified === true, - crossSigningVerified: params?.verified === true, - signedByOwner: params?.verified === true, + localVerified: params?.localVerified ?? params?.verified === true, + crossSigningVerified: params?.crossSigningVerified ?? params?.verified === true, + signedByOwner: params?.signedByOwner ?? params?.verified === true, recoveryKeyStored: false, recoveryKeyCreatedAt: null, recoveryKeyId: null, @@ -91,6 +94,31 @@ describe("ensureMatrixStartupVerification", () => { expect(harness.client.crypto.requestVerification).not.toHaveBeenCalled(); }); + it("still requests startup verification when trust is only local", async () => { + const tempHome = createTempStateDir(); + const harness = createHarness({ + verified: false, + localVerified: true, + crossSigningVerified: false, + signedByOwner: false, + }); + + const result = await ensureMatrixStartupVerification({ + client: harness.client as never, + auth: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + encryption: true, + }, + accountConfig: {}, + stateFilePath: createStateFilePath(tempHome), + }); + + expect(result.kind).toBe("requested"); + expect(harness.client.crypto.requestVerification).toHaveBeenCalledWith({ ownUser: true }); + }); + it("skips automatic requests when a self verification is already pending", async () => { const tempHome = createTempStateDir(); const harness = createHarness({ diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index 01945313241..23353676a85 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -843,6 +843,34 @@ describe("MatrixClient crypto bootstrapping", () => { expect(status.deviceId).toBe("DEVICE123"); }); + it("does not treat local-only trust as owner verification", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + 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: false, + signedByOwner: false, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + await client.start(); + + const status = await client.getOwnDeviceVerificationStatus(); + expect(status.localVerified).toBe(true); + expect(status.crossSigningVerified).toBe(false); + expect(status.signedByOwner).toBe(false); + expect(status.verified).toBe(false); + }); + it("verifies with a provided recovery key and reports success", async () => { const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1))); expect(encoded).toBeTypeOf("string"); @@ -887,6 +915,42 @@ describe("MatrixClient crypto bootstrapping", () => { expect(bootstrapCrossSigning).toHaveBeenCalled(); }); + it("fails recovery-key verification when the device is only locally trusted", async () => { + const encoded = encodeRecoveryKey(new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1))); + + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + 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: () => true, + localVerified: true, + crossSigningVerified: false, + signedByOwner: false, + })), + })); + + const recoveryDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-verify-local-only-")); + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + recoveryKeyPath: path.join(recoveryDir, "recovery-key.json"), + }); + await client.start(); + + const result = await client.verifyWithRecoveryKey(encoded as string); + expect(result.success).toBe(false); + expect(result.verified).toBe(false); + expect(result.error).toContain("not verified by its owner"); + }); + it("reports detailed room-key backup health", async () => { matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); @@ -1140,6 +1204,42 @@ describe("MatrixClient crypto bootstrapping", () => { expect(result.cryptoBootstrap).not.toBeNull(); }); + it("reports bootstrap failure when the device is only locally trusted", async () => { + matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); + matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); + matrixJsClient.getCrypto = vi.fn(() => ({ + on: vi.fn(), + bootstrapCrossSigning: vi.fn(async () => {}), + bootstrapSecretStorage: vi.fn(async () => {}), + requestOwnUserVerification: vi.fn(async () => null), + isCrossSigningReady: vi.fn(async () => true), + userHasCrossSigningKeys: vi.fn(async () => true), + getDeviceVerificationStatus: vi.fn(async () => ({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: false, + signedByOwner: false, + })), + })); + + const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, { + encryption: true, + }); + vi.spyOn(client, "getOwnCrossSigningPublicationStatus").mockResolvedValue({ + userId: "@bot:example.org", + masterKeyPublished: true, + selfSigningKeyPublished: true, + userSigningKeyPublished: true, + published: true, + }); + + const result = await client.bootstrapOwnDeviceVerification(); + expect(result.success).toBe(false); + expect(result.verification.localVerified).toBe(true); + expect(result.verification.signedByOwner).toBe(false); + expect(result.error).toContain("not verified by its owner after bootstrap"); + }); + it("creates a key backup during bootstrap when none exists on the server", async () => { matrixJsClient.getUserId = vi.fn(() => "@bot:example.org"); matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123"); diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index 3888c28a56f..9b18b0e60ce 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -27,6 +27,7 @@ import type { MessageEventContent, } from "./sdk/types.js"; import { MatrixVerificationManager } from "./sdk/verification-manager.js"; +import { isMatrixDeviceOwnerVerified } from "./sdk/verification-status.js"; export { ConsoleLogger, LogService }; export type { @@ -47,6 +48,8 @@ export type MatrixOwnDeviceVerificationStatus = { encryptionEnabled: boolean; userId: string | null; deviceId: string | null; + // "verified" is intentionally strict: other Matrix clients should trust messages + // from this device without showing "not verified by its owner" warnings. verified: boolean; localVerified: boolean; crossSigningVerified: boolean; @@ -102,17 +105,6 @@ export type MatrixVerificationBootstrapResult = { cryptoBootstrap: MatrixCryptoBootstrapResult | null; }; -function isMatrixDeviceVerified( - status: MatrixDeviceVerificationStatusLike | null | undefined, -): boolean { - return ( - status?.isVerified?.() === true || - status?.localVerified === true || - status?.crossSigningVerified === true || - status?.signedByOwner === true - ); -} - function normalizeOptionalString(value: string | null | undefined): string | null { const normalized = value?.trim(); return normalized ? normalized : null; @@ -659,7 +651,7 @@ export class MatrixClient { encryptionEnabled: true, userId, deviceId, - verified: isMatrixDeviceVerified(deviceStatus), + verified: isMatrixDeviceOwnerVerified(deviceStatus), localVerified: deviceStatus?.localVerified === true, crossSigningVerified: deviceStatus?.crossSigningVerified === true, signedByOwner: deviceStatus?.signedByOwner === true, @@ -715,7 +707,7 @@ export class MatrixClient { return { success: false, error: - "Matrix device is still unverified after applying recovery key. Verify your recovery key and ensure cross-signing is available.", + "Matrix device is still not verified by its owner after applying the recovery key. Ensure cross-signing is available and the device is signed.", ...status, }; } @@ -901,7 +893,7 @@ export class MatrixClient { const error = success ? undefined : (bootstrapError ?? - "Matrix verification bootstrap did not produce a verified device with published cross-signing keys"); + "Matrix verification bootstrap did not produce a device verified by its owner with published cross-signing keys"); return { success, error, diff --git a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts index 7b15d3b4d56..651e31ecf80 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.test.ts @@ -230,6 +230,48 @@ describe("MatrixCryptoBootstrapper", () => { expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123"); }); + it("does not treat local-only trust as sufficient for own-device bootstrap", async () => { + const deps = createBootstrapperDeps(); + const setDeviceVerified = vi.fn(async () => {}); + const crossSignDevice = vi.fn(async () => {}); + const getDeviceVerificationStatus = vi + .fn< + () => Promise<{ + isVerified: () => boolean; + localVerified: boolean; + crossSigningVerified: boolean; + signedByOwner: boolean; + }> + >() + .mockResolvedValueOnce({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: false, + signedByOwner: false, + }) + .mockResolvedValueOnce({ + isVerified: () => true, + localVerified: true, + crossSigningVerified: true, + signedByOwner: true, + }); + const crypto = createCryptoApi({ + getDeviceVerificationStatus, + setDeviceVerified, + crossSignDevice, + isCrossSigningReady: vi.fn(async () => true), + }); + const bootstrapper = new MatrixCryptoBootstrapper( + deps as unknown as MatrixCryptoBootstrapperDeps, + ); + + await bootstrapper.bootstrap(crypto); + + expect(setDeviceVerified).toHaveBeenCalledWith("@bot:example.org", "DEVICE123", true); + expect(crossSignDevice).toHaveBeenCalledWith("DEVICE123"); + expect(getDeviceVerificationStatus).toHaveBeenCalledTimes(2); + }); + it("auto-accepts incoming verification requests from other users", async () => { const deps = createBootstrapperDeps(); const listeners = new Map void>(); diff --git a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts index c403f4caf2a..a0321ca3de5 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-bootstrap.ts @@ -13,6 +13,7 @@ import type { MatrixVerificationManager, MatrixVerificationRequestLike, } from "./verification-manager.js"; +import { isMatrixDeviceOwnerVerified } from "./verification-status.js"; export type MatrixCryptoBootstrapperDeps = { getUserId: () => Promise; @@ -293,11 +294,7 @@ export class MatrixCryptoBootstrapper { typeof crypto.getDeviceVerificationStatus === "function" ? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null) : null; - const alreadyVerified = - deviceStatus?.isVerified?.() === true || - deviceStatus?.localVerified === true || - deviceStatus?.crossSigningVerified === true || - deviceStatus?.signedByOwner === true; + const alreadyVerified = isMatrixDeviceOwnerVerified(deviceStatus); if (alreadyVerified) { return true; @@ -321,13 +318,9 @@ export class MatrixCryptoBootstrapper { typeof crypto.getDeviceVerificationStatus === "function" ? await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null) : null; - const verified = - refreshedStatus?.isVerified?.() === true || - refreshedStatus?.localVerified === true || - refreshedStatus?.crossSigningVerified === true || - refreshedStatus?.signedByOwner === true; + const verified = isMatrixDeviceOwnerVerified(refreshedStatus); if (!verified && strict) { - throw new Error(`Matrix own device ${deviceId} is not verified after bootstrap`); + throw new Error(`Matrix own device ${deviceId} is not verified by its owner after bootstrap`); } return verified; } diff --git a/extensions/matrix/src/matrix/sdk/verification-status.ts b/extensions/matrix/src/matrix/sdk/verification-status.ts new file mode 100644 index 00000000000..e6de1906a75 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/verification-status.ts @@ -0,0 +1,23 @@ +import type { MatrixDeviceVerificationStatusLike } from "./types.js"; + +export function isMatrixDeviceLocallyVerified( + status: MatrixDeviceVerificationStatusLike | null | undefined, +): boolean { + return status?.localVerified === true; +} + +export function isMatrixDeviceOwnerVerified( + status: MatrixDeviceVerificationStatusLike | null | undefined, +): boolean { + return status?.crossSigningVerified === true || status?.signedByOwner === true; +} + +export function isMatrixDeviceVerifiedInCurrentClient( + status: MatrixDeviceVerificationStatusLike | null | undefined, +): boolean { + return ( + status?.isVerified?.() === true || + isMatrixDeviceLocallyVerified(status) || + isMatrixDeviceOwnerVerified(status) + ); +} diff --git a/extensions/matrix/src/profile-update.ts b/extensions/matrix/src/profile-update.ts new file mode 100644 index 00000000000..a0d67147f8d --- /dev/null +++ b/extensions/matrix/src/profile-update.ts @@ -0,0 +1,61 @@ +import { normalizeAccountId } from "openclaw/plugin-sdk/matrix"; +import { updateMatrixOwnProfile } from "./matrix/actions/profile.js"; +import { updateMatrixAccountConfig, resolveMatrixConfigPath } from "./matrix/config-update.js"; +import { getMatrixRuntime } from "./runtime.js"; +import type { CoreConfig } from "./types.js"; + +export type MatrixProfileUpdateResult = { + accountId: string; + displayName: string | null; + avatarUrl: string | null; + profile: { + displayNameUpdated: boolean; + avatarUpdated: boolean; + resolvedAvatarUrl: string | null; + convertedAvatarFromHttp: boolean; + }; + configPath: string; +}; + +export async function applyMatrixProfileUpdate(params: { + account?: string; + displayName?: string; + avatarUrl?: string; +}): Promise { + const runtime = getMatrixRuntime(); + const cfg = runtime.config.loadConfig() as CoreConfig; + const accountId = normalizeAccountId(params.account); + const displayName = params.displayName?.trim() || null; + const avatarUrl = params.avatarUrl?.trim() || null; + if (!displayName && !avatarUrl) { + throw new Error("Provide name/displayName and/or avatarUrl."); + } + + const synced = await updateMatrixOwnProfile({ + accountId, + displayName: displayName ?? undefined, + avatarUrl: avatarUrl ?? undefined, + }); + const persistedAvatarUrl = + synced.convertedAvatarFromHttp && synced.resolvedAvatarUrl + ? synced.resolvedAvatarUrl + : avatarUrl; + const updated = updateMatrixAccountConfig(cfg, accountId, { + name: displayName ?? undefined, + avatarUrl: persistedAvatarUrl ?? undefined, + }); + await runtime.config.writeConfigFile(updated as never); + + return { + accountId, + displayName, + avatarUrl: persistedAvatarUrl ?? null, + profile: { + displayNameUpdated: synced.displayNameUpdated, + avatarUpdated: synced.avatarUpdated, + resolvedAvatarUrl: synced.resolvedAvatarUrl, + convertedAvatarFromHttp: synced.convertedAvatarFromHttp, + }, + configPath: resolveMatrixConfigPath(updated, accountId), + }; +} diff --git a/extensions/matrix/src/tool-actions.test.ts b/extensions/matrix/src/tool-actions.test.ts index 415e8cbc088..f9fdfaca9c5 100644 --- a/extensions/matrix/src/tool-actions.test.ts +++ b/extensions/matrix/src/tool-actions.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ listMatrixPins: vi.fn(), getMatrixMemberInfo: vi.fn(), getMatrixRoomInfo: vi.fn(), + applyMatrixProfileUpdate: vi.fn(), })); vi.mock("./matrix/actions.js", async () => { @@ -35,6 +36,10 @@ vi.mock("./matrix/send.js", async () => { }; }); +vi.mock("./profile-update.js", () => ({ + applyMatrixProfileUpdate: (...args: unknown[]) => mocks.applyMatrixProfileUpdate(...args), +})); + describe("handleMatrixAction pollVote", () => { beforeEach(() => { vi.clearAllMocks(); @@ -55,6 +60,18 @@ describe("handleMatrixAction pollVote", () => { }); mocks.getMatrixMemberInfo.mockResolvedValue({ userId: "@u:example" }); mocks.getMatrixRoomInfo.mockResolvedValue({ roomId: "!room:example" }); + mocks.applyMatrixProfileUpdate.mockResolvedValue({ + accountId: "ops", + displayName: "Ops Bot", + avatarUrl: "mxc://example/avatar", + profile: { + displayNameUpdated: true, + avatarUpdated: true, + resolvedAvatarUrl: "mxc://example/avatar", + convertedAvatarFromHttp: false, + }, + configPath: "channels.matrix.accounts.ops", + }); }); it("parses snake_case vote params and forwards normalized selectors", async () => { @@ -219,4 +236,30 @@ describe("handleMatrixAction pollVote", () => { accountId: "ops", }); }); + + it("persists self-profile updates through the shared profile helper", async () => { + const result = await handleMatrixAction( + { + action: "setProfile", + account_id: "ops", + display_name: "Ops Bot", + avatar_url: "mxc://example/avatar", + }, + { channels: { matrix: { actions: { profile: true } } } } as CoreConfig, + ); + + expect(mocks.applyMatrixProfileUpdate).toHaveBeenCalledWith({ + account: "ops", + displayName: "Ops Bot", + avatarUrl: "mxc://example/avatar", + }); + expect(result.details).toMatchObject({ + ok: true, + accountId: "ops", + profile: { + displayNameUpdated: true, + avatarUpdated: true, + }, + }); + }); }); diff --git a/extensions/matrix/src/tool-actions.ts b/extensions/matrix/src/tool-actions.ts index 4f208705926..add018d1a58 100644 --- a/extensions/matrix/src/tool-actions.ts +++ b/extensions/matrix/src/tool-actions.ts @@ -39,12 +39,14 @@ import { verifyMatrixRecoveryKey, } from "./matrix/actions.js"; import { reactMatrixMessage } from "./matrix/send.js"; +import { applyMatrixProfileUpdate } from "./profile-update.js"; import type { CoreConfig } from "./types.js"; const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]); const reactionActions = new Set(["react", "reactions"]); const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); const pollActions = new Set(["pollVote"]); +const profileActions = new Set(["setProfile"]); const verificationActions = new Set([ "encryptionStatus", "verificationList", @@ -258,6 +260,18 @@ export async function handleMatrixAction( return jsonResult({ ok: true, pinned: result.pinned, events: result.events }); } + if (profileActions.has(action)) { + if (!isActionEnabled("profile")) { + throw new Error("Matrix profile updates are disabled."); + } + const result = await applyMatrixProfileUpdate({ + account: accountId, + displayName: readStringParam(params, "displayName") ?? readStringParam(params, "name"), + avatarUrl: readStringParam(params, "avatarUrl"), + }); + return jsonResult({ ok: true, ...result }); + } + if (action === "memberInfo") { if (!isActionEnabled("memberInfo")) { throw new Error("Matrix member info is disabled."); diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index 2e9beebea7d..962fb0f3169 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -35,6 +35,7 @@ export type MatrixActionConfig = { reactions?: boolean; messages?: boolean; pins?: boolean; + profile?: boolean; memberInfo?: boolean; channelInfo?: boolean; verification?: boolean; diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 930f8d95a25..c43a4ac749e 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -156,6 +156,14 @@ describe("message tool schema scoping", () => { actions: ["send", "poll", "poll-vote"], }); + const matrixPlugin = createChannelPlugin({ + id: "matrix", + label: "Matrix", + docsPath: "/channels/matrix", + blurb: "Matrix test plugin.", + actions: ["send", "set-profile"], + }); + afterEach(() => { setActivePluginRegistry(createTestRegistry([])); }); @@ -191,6 +199,7 @@ describe("message tool schema scoping", () => { createTestRegistry([ { pluginId: "telegram", source: "test", plugin: telegramPlugin }, { pluginId: "discord", source: "test", plugin: discordPlugin }, + { pluginId: "matrix", source: "test", plugin: matrixPlugin }, ]), ); @@ -235,6 +244,8 @@ describe("message tool schema scoping", () => { expect(properties.pollId).toBeDefined(); expect(properties.pollOptionIndex).toBeDefined(); expect(properties.pollOptionId).toBeDefined(); + expect(properties.avatarUrl).toBeDefined(); + expect(properties.displayName).toBeDefined(); }, ); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 96b2702f065..0ac8162e001 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -421,6 +421,33 @@ function buildPresenceSchema() { }; } +function buildProfileSchema() { + return { + displayName: Type.Optional( + Type.String({ + description: "Profile display name for self-profile update actions.", + }), + ), + display_name: Type.Optional( + Type.String({ + description: "snake_case alias of displayName for self-profile update actions.", + }), + ), + avatarUrl: Type.Optional( + Type.String({ + description: + "Profile avatar URL for self-profile update actions. Matrix accepts mxc:// and http(s) URLs.", + }), + ), + avatar_url: Type.Optional( + Type.String({ + description: + "snake_case alias of avatarUrl for self-profile update actions. Matrix accepts mxc:// and http(s) URLs.", + }), + ), + }; +} + function buildChannelManagementSchema() { return { name: Type.Optional(Type.String()), @@ -459,6 +486,7 @@ function buildMessageToolSchemaProps(options: { ...buildGatewaySchema(), ...buildChannelManagementSchema(), ...buildPresenceSchema(), + ...buildProfileSchema(), }; } diff --git a/src/channels/plugins/message-action-names.ts b/src/channels/plugins/message-action-names.ts index 809d239be2c..f6409891be1 100644 --- a/src/channels/plugins/message-action-names.ts +++ b/src/channels/plugins/message-action-names.ts @@ -51,6 +51,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [ "kick", "ban", "set-presence", + "set-profile", "download-file", ] as const; diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index b49a60c6991..93c6700a8cd 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -56,6 +56,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record