From 1a2d4467885b39007c16a8a40188237344b58770 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 25 Feb 2026 19:29:53 -0500 Subject: [PATCH] Matrix-js: add legacy config migration and bind integration coverage --- .../matrix-js/src/channel.directory.test.ts | 35 +++++++++- extensions/matrix-js/src/channel.ts | 4 +- extensions/matrix-js/src/config-migration.ts | 70 +++++++++++++++++++ src/cli/program/register.agent.test.ts | 9 +++ src/cli/program/register.agent.ts | 7 +- .../agents.bind.matrix-js.integration.test.ts | 52 ++++++++++++++ 6 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 extensions/matrix-js/src/config-migration.ts create mode 100644 src/commands/agents.bind.matrix-js.integration.test.ts diff --git a/extensions/matrix-js/src/channel.directory.test.ts b/extensions/matrix-js/src/channel.directory.test.ts index 7b0b63dad3a..6126dbb286c 100644 --- a/extensions/matrix-js/src/channel.directory.test.ts +++ b/extensions/matrix-js/src/channel.directory.test.ts @@ -1,6 +1,7 @@ import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { matrixPlugin } from "./channel.js"; +import { migrateMatrixLegacyCredentialsToDefaultAccount } from "./config-migration.js"; import { setMatrixRuntime } from "./runtime.js"; import type { CoreConfig } from "./types.js"; @@ -153,7 +154,11 @@ describe("matrix directory", () => { }, }) as CoreConfig; - expect(updated.channels?.["matrix-js"]?.accessToken).toBe("default-token"); + expect(updated.channels?.["matrix-js"]?.accessToken).toBeUndefined(); + expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({ + accessToken: "default-token", + homeserver: "https://default.example.org", + }); expect(updated.channels?.["matrix-js"]?.accounts?.ops).toMatchObject({ enabled: true, homeserver: "https://matrix.example.org", @@ -182,7 +187,7 @@ describe("matrix directory", () => { }, }) as CoreConfig; - expect(updated.channels?.["matrix-js"]?.homeserver).toBe("https://legacy.example.org"); + expect(updated.channels?.["matrix-js"]?.homeserver).toBeUndefined(); expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({ enabled: true, homeserver: "https://matrix.example.org", @@ -191,6 +196,32 @@ describe("matrix directory", () => { }); }); + it("migrates legacy top-level matrix-js credentials into accounts.default", () => { + const cfg = { + channels: { + "matrix-js": { + homeserver: "https://legacy.example.org", + userId: "@legacy:example.org", + accessToken: "legacy-token", + deviceName: "Legacy Device", + encryption: true, + }, + }, + } as unknown as CoreConfig; + + const updated = migrateMatrixLegacyCredentialsToDefaultAccount(cfg); + expect(updated.channels?.["matrix-js"]?.homeserver).toBeUndefined(); + expect(updated.channels?.["matrix-js"]?.accessToken).toBeUndefined(); + expect(updated.channels?.["matrix-js"]?.deviceName).toBeUndefined(); + expect(updated.channels?.["matrix-js"]?.encryption).toBe(true); + expect(updated.channels?.["matrix-js"]?.accounts?.default).toMatchObject({ + homeserver: "https://legacy.example.org", + userId: "@legacy:example.org", + accessToken: "legacy-token", + deviceName: "Legacy Device", + }); + }); + it("rejects useEnv for non-default matrix-js accounts", () => { const error = matrixPlugin.setup!.validateInput?.({ cfg: {} as CoreConfig, diff --git a/extensions/matrix-js/src/channel.ts b/extensions/matrix-js/src/channel.ts index c292e091ca1..ad73caf2174 100644 --- a/extensions/matrix-js/src/channel.ts +++ b/extensions/matrix-js/src/channel.ts @@ -13,6 +13,7 @@ import { type ChannelPlugin, } from "openclaw/plugin-sdk"; import { matrixMessageActions } from "./actions.js"; +import { migrateMatrixLegacyCredentialsToDefaultAccount } from "./config-migration.js"; import { MatrixConfigSchema } from "./config-schema.js"; import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js"; import { @@ -357,8 +358,9 @@ export const matrixPlugin: ChannelPlugin = { return null; }, applyAccountConfig: ({ cfg, accountId, input }) => { + const migratedConfig = migrateMatrixLegacyCredentialsToDefaultAccount(cfg as CoreConfig); const namedConfig = applyAccountNameToChannelSection({ - cfg: cfg as CoreConfig, + cfg: migratedConfig, channelKey: "matrix-js", accountId, name: input.name, diff --git a/extensions/matrix-js/src/config-migration.ts b/extensions/matrix-js/src/config-migration.ts new file mode 100644 index 00000000000..30663a2f8d3 --- /dev/null +++ b/extensions/matrix-js/src/config-migration.ts @@ -0,0 +1,70 @@ +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk"; +import type { CoreConfig, MatrixAccountConfig, MatrixConfig } from "./types.js"; + +type LegacyAccountField = + | "homeserver" + | "userId" + | "accessToken" + | "password" + | "deviceId" + | "deviceName" + | "initialSyncLimit"; + +const LEGACY_ACCOUNT_FIELDS: ReadonlyArray = [ + "homeserver", + "userId", + "accessToken", + "password", + "deviceId", + "deviceName", + "initialSyncLimit", +]; + +export function migrateMatrixLegacyCredentialsToDefaultAccount(cfg: CoreConfig): CoreConfig { + const matrix = cfg.channels?.["matrix-js"]; + if (!matrix) { + return cfg; + } + + const defaultAccount = { + ...(matrix.accounts?.[DEFAULT_ACCOUNT_ID] ?? {}), + } as MatrixAccountConfig; + let changed = false; + + for (const field of LEGACY_ACCOUNT_FIELDS) { + const legacyValue = matrix[field] as MatrixAccountConfig[LegacyAccountField] | undefined; + if (legacyValue === undefined) { + continue; + } + if (defaultAccount[field] === undefined) { + ( + defaultAccount as Record< + LegacyAccountField, + MatrixAccountConfig[LegacyAccountField] | undefined + > + )[field] = legacyValue; + } + changed = true; + } + + if (!changed) { + return cfg; + } + + const nextMatrix = { ...matrix } as MatrixConfig; + for (const field of LEGACY_ACCOUNT_FIELDS) { + delete nextMatrix[field]; + } + nextMatrix.accounts = { + ...matrix.accounts, + [DEFAULT_ACCOUNT_ID]: defaultAccount, + }; + + return { + ...cfg, + channels: { + ...cfg.channels, + "matrix-js": nextMatrix, + }, + }; +} diff --git a/src/cli/program/register.agent.test.ts b/src/cli/program/register.agent.test.ts index 09f7d6415e7..2d37e56a702 100644 --- a/src/cli/program/register.agent.test.ts +++ b/src/cli/program/register.agent.test.ts @@ -189,6 +189,15 @@ describe("registerAgentCommands", () => { ); }); + it("documents bind accountId resolution behavior in help text", () => { + const program = new Command(); + registerAgentCommands(program, { agentChannelOptions: "last|telegram|discord" }); + const agents = program.commands.find((command) => command.name() === "agents"); + const bind = agents?.commands.find((command) => command.name() === "bind"); + const help = bind?.helpInformation() ?? ""; + expect(help).toContain("accountId is resolved by channel defaults/hooks"); + }); + it("forwards agents unbind options", async () => { await runCli(["agents", "unbind", "--agent", "ops", "--all", "--json"]); expect(agentsUnbindCommandMock).toHaveBeenCalledWith( diff --git a/src/cli/program/register.agent.ts b/src/cli/program/register.agent.ts index 84281c9e11b..fdb45a0960a 100644 --- a/src/cli/program/register.agent.ts +++ b/src/cli/program/register.agent.ts @@ -126,7 +126,12 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/age .command("bind") .description("Add routing bindings for an agent") .option("--agent ", "Agent id (defaults to current default agent)") - .option("--bind ", "Binding to add (repeatable)", collectOption, []) + .option( + "--bind ", + "Binding to add (repeatable). If omitted, accountId is resolved by channel defaults/hooks.", + collectOption, + [], + ) .option("--json", "Output JSON summary", false) .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { diff --git a/src/commands/agents.bind.matrix-js.integration.test.ts b/src/commands/agents.bind.matrix-js.integration.test.ts new file mode 100644 index 00000000000..0c1ea91b6b0 --- /dev/null +++ b/src/commands/agents.bind.matrix-js.integration.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { matrixPlugin } from "../../extensions/matrix-js/src/channel.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { agentsBindCommand } from "./agents.js"; +import { setDefaultChannelPluginRegistryForTests } from "./channel-test-helpers.js"; +import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; + +const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); +const writeConfigFileMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); + +vi.mock("../config/config.js", async (importOriginal) => ({ + ...(await importOriginal()), + readConfigFileSnapshot: readConfigFileSnapshotMock, + writeConfigFile: writeConfigFileMock, +})); + +describe("agents bind matrix-js integration", () => { + const runtime = createTestRuntime(); + + beforeEach(() => { + readConfigFileSnapshotMock.mockClear(); + writeConfigFileMock.mockClear(); + runtime.log.mockClear(); + runtime.error.mockClear(); + runtime.exit.mockClear(); + + setActivePluginRegistry( + createTestRegistry([{ pluginId: "matrix-js", plugin: matrixPlugin, source: "test" }]), + ); + }); + + afterEach(() => { + setDefaultChannelPluginRegistryForTests(); + }); + + it("uses matrix-js plugin binding resolver when accountId is omitted", async () => { + readConfigFileSnapshotMock.mockResolvedValue({ + ...baseConfigSnapshot, + config: {}, + }); + + await agentsBindCommand({ agent: "main", bind: ["matrix-js"] }, runtime); + + expect(writeConfigFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + bindings: [{ agentId: "main", match: { channel: "matrix-js", accountId: "main" } }], + }), + ); + expect(runtime.exit).not.toHaveBeenCalled(); + }); +});