Matrix-js: remove register mode and require existing accounts

This commit is contained in:
Gustavo Madeira Santana
2026-02-25 18:18:37 -05:00
parent 6ec6ccb854
commit 9fc8f8068d
12 changed files with 45 additions and 615 deletions

View File

@@ -70,7 +70,6 @@ function buildMatrixConfigUpdate(
userId?: string;
accessToken?: string;
password?: string;
register?: boolean;
deviceName?: string;
initialSyncLimit?: number;
},
@@ -93,7 +92,6 @@ function buildMatrixConfigUpdate(
...(input.userId ? { userId: input.userId } : {}),
...(input.accessToken ? { accessToken: input.accessToken } : {}),
...(input.password ? { password: input.password } : {}),
...(typeof input.register === "boolean" ? { register: input.register } : {}),
...(input.deviceName ? { deviceName: input.deviceName } : {}),
...(typeof input.initialSyncLimit === "number"
? { initialSyncLimit: input.initialSyncLimit }
@@ -115,7 +113,6 @@ function buildMatrixConfigUpdate(
...(input.userId ? { userId: input.userId } : {}),
...(input.accessToken ? { accessToken: input.accessToken } : {}),
...(input.password ? { password: input.password } : {}),
...(typeof input.register === "boolean" ? { register: input.register } : {}),
...(input.deviceName ? { deviceName: input.deviceName } : {}),
...(typeof input.initialSyncLimit === "number"
? { initialSyncLimit: input.initialSyncLimit }
@@ -168,7 +165,6 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
"userId",
"accessToken",
"password",
"register",
"deviceName",
"initialSyncLimit",
],

View File

@@ -161,8 +161,6 @@ describe("matrix-js CLI verification commands", () => {
"@ops:example.org",
"--password",
"secret",
"--register",
"on",
],
{ from: "user" },
);
@@ -184,7 +182,6 @@ describe("matrix-js CLI verification commands", () => {
accounts: {
ops: expect.objectContaining({
homeserver: "https://matrix.example.org",
register: true,
}),
},
},
@@ -192,7 +189,6 @@ describe("matrix-js CLI verification commands", () => {
}),
);
expect(console.log).toHaveBeenCalledWith("Saved matrix-js account: ops");
expect(console.log).toHaveBeenCalledWith("Register mode: on");
expect(console.log).toHaveBeenCalledWith(
"Bind this account to an agent: openclaw agents bind --agent <id> --bind matrix-js:ops",
);

View File

@@ -76,64 +76,9 @@ function parseOptionalInt(value: string | undefined, fieldName: string): number
return parsed;
}
function parseToggle(value: string | undefined, fieldName: string): boolean | undefined {
const trimmed = value?.trim().toLowerCase();
if (!trimmed) {
return undefined;
}
if (["on", "true", "1", "yes"].includes(trimmed)) {
return true;
}
if (["off", "false", "0", "no"].includes(trimmed)) {
return false;
}
throw new Error(`${fieldName} must be on|off`);
}
function applyRegisterFlag(
cfg: CoreConfig,
accountId: string,
register: boolean | undefined,
): CoreConfig {
if (typeof register !== "boolean") {
return cfg;
}
const matrix = cfg.channels?.["matrix-js"] ?? {};
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...(cfg.channels ?? {}),
"matrix-js": {
...matrix,
register,
},
},
};
}
const account = matrix.accounts?.[accountId] ?? {};
return {
...cfg,
channels: {
...(cfg.channels ?? {}),
"matrix-js": {
...matrix,
accounts: {
...matrix.accounts,
[accountId]: {
...account,
register,
},
},
},
},
};
}
type MatrixCliAccountAddResult = {
accountId: string;
configPath: string;
registerMode: boolean | undefined;
useEnv: boolean;
};
@@ -147,12 +92,10 @@ async function addMatrixJsAccount(params: {
deviceName?: string;
initialSyncLimit?: string;
useEnv?: boolean;
register?: string;
}): Promise<MatrixCliAccountAddResult> {
const runtime = getMatrixRuntime();
const cfg = runtime.config.loadConfig() as CoreConfig;
const accountId = normalizeAccountId(params.account);
const registerMode = parseToggle(params.register, "--register");
const setup = matrixPlugin.setup;
if (!setup?.applyAccountConfig) {
throw new Error("Matrix-js account setup is unavailable.");
@@ -183,8 +126,7 @@ async function addMatrixJsAccount(params: {
accountId,
input,
}) as CoreConfig;
const next = applyRegisterFlag(updated, accountId, registerMode);
await runtime.config.writeConfigFile(next as never);
await runtime.config.writeConfigFile(updated as never);
return {
accountId,
@@ -192,7 +134,6 @@ async function addMatrixJsAccount(params: {
accountId === DEFAULT_ACCOUNT_ID
? "channels.matrix-js"
: `channels.matrix-js.accounts.${accountId}`,
registerMode,
useEnv: input.useEnv === true,
};
}
@@ -506,7 +447,6 @@ export function registerMatrixJsCli(params: { program: Command }): void {
.option("--device-name <name>", "Matrix device display name")
.option("--initial-sync-limit <n>", "Matrix initial sync limit")
.option("--use-env", "Use MATRIX_* env vars (default account only)")
.option("--register <on|off>", "Enable/disable register mode for password auth")
.option("--verbose", "Show setup details")
.option("--json", "Output as JSON")
.action(
@@ -520,7 +460,6 @@ export function registerMatrixJsCli(params: { program: Command }): void {
deviceName?: string;
initialSyncLimit?: string;
useEnv?: boolean;
register?: string;
verbose?: boolean;
json?: boolean;
}) => {
@@ -538,14 +477,10 @@ export function registerMatrixJsCli(params: { program: Command }): void {
deviceName: options.deviceName,
initialSyncLimit: options.initialSyncLimit,
useEnv: options.useEnv === true,
register: options.register,
}),
onText: (result) => {
console.log(`Saved matrix-js account: ${result.accountId}`);
console.log(`Config path: ${result.configPath}`);
if (typeof result.registerMode === "boolean") {
console.log(`Register mode: ${result.registerMode ? "on" : "off"}`);
}
console.log(
`Credentials source: ${result.useEnv ? "MATRIX_* env vars" : "inline config"}`,
);

View File

@@ -43,7 +43,6 @@ export const MatrixConfigSchema = z.object({
userId: z.string().optional(),
accessToken: z.string().optional(),
password: z.string().optional(),
register: z.boolean().optional(),
deviceId: z.string().optional(),
deviceName: z.string().optional(),
initialSyncLimit: z.number().optional(),

View File

@@ -4,9 +4,7 @@ import { resolveMatrixAuth, resolveMatrixConfig } from "./client.js";
import * as credentialsModule from "./credentials.js";
import * as sdkModule from "./sdk.js";
const saveMatrixCredentialsMock = vi.fn();
const prepareMatrixRegisterModeMock = vi.fn(async () => null);
const finalizeMatrixRegisterConfigAfterSuccessMock = vi.fn(async () => false);
const saveMatrixCredentialsMock = vi.hoisted(() => vi.fn());
vi.mock("./credentials.js", () => ({
loadMatrixCredentials: vi.fn(() => null),
@@ -15,12 +13,6 @@ vi.mock("./credentials.js", () => ({
touchMatrixCredentials: vi.fn(),
}));
vi.mock("./client/register-mode.js", () => ({
prepareMatrixRegisterMode: prepareMatrixRegisterModeMock,
finalizeMatrixRegisterConfigAfterSuccess: finalizeMatrixRegisterConfigAfterSuccessMock,
resetPreparedMatrixRegisterModesForTests: vi.fn(),
}));
describe("resolveMatrixConfig", () => {
it("prefers config over env", () => {
const cfg = {
@@ -48,7 +40,6 @@ describe("resolveMatrixConfig", () => {
userId: "@cfg:example.org",
accessToken: "cfg-token",
password: "cfg-pass",
register: false,
deviceId: undefined,
deviceName: "CfgDevice",
initialSyncLimit: 5,
@@ -71,32 +62,11 @@ describe("resolveMatrixConfig", () => {
expect(resolved.userId).toBe("@env:example.org");
expect(resolved.accessToken).toBe("env-token");
expect(resolved.password).toBe("env-pass");
expect(resolved.register).toBe(false);
expect(resolved.deviceId).toBe("ENVDEVICE");
expect(resolved.deviceName).toBe("EnvDevice");
expect(resolved.initialSyncLimit).toBeUndefined();
expect(resolved.encryption).toBe(false);
});
it("reads register flag from config and env", () => {
const cfg = {
channels: {
"matrix-js": {
register: true,
},
},
} as CoreConfig;
const resolvedFromCfg = resolveMatrixConfig(cfg, {} as NodeJS.ProcessEnv);
expect(resolvedFromCfg.register).toBe(true);
const resolvedFromEnv = resolveMatrixConfig(
{} as CoreConfig,
{
MATRIX_REGISTER: "1",
} as NodeJS.ProcessEnv,
);
expect(resolvedFromEnv.register).toBe(true);
});
});
describe("resolveMatrixAuth", () => {
@@ -104,8 +74,6 @@ describe("resolveMatrixAuth", () => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
saveMatrixCredentialsMock.mockReset();
prepareMatrixRegisterModeMock.mockReset();
finalizeMatrixRegisterConfigAfterSuccessMock.mockReset();
});
it("uses the hardened client request path for password login and persists deviceId", async () => {
@@ -158,88 +126,9 @@ describe("resolveMatrixAuth", () => {
);
});
it("can register account when password login fails and register mode is enabled", async () => {
it("surfaces password login errors when account credentials are invalid", async () => {
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest");
doRequestSpy
.mockRejectedValueOnce(new Error("Invalid username or password"))
.mockResolvedValueOnce({
access_token: "tok-registered",
user_id: "@newbot:example.org",
device_id: "REGDEVICE123",
});
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@newbot:example.org",
password: "secret",
register: true,
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(doRequestSpy).toHaveBeenNthCalledWith(
1,
"POST",
"/_matrix/client/v3/login",
undefined,
expect.objectContaining({
type: "m.login.password",
device_id: undefined,
}),
);
expect(doRequestSpy).toHaveBeenNthCalledWith(
2,
"POST",
"/_matrix/client/v3/register",
undefined,
expect.objectContaining({
username: "newbot",
auth: { type: "m.login.dummy" },
}),
);
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@newbot:example.org",
accessToken: "tok-registered",
deviceId: "REGDEVICE123",
encryption: true,
});
expect(prepareMatrixRegisterModeMock).toHaveBeenCalledWith({
cfg,
homeserver: "https://matrix.example.org",
userId: "@newbot:example.org",
env: {} as NodeJS.ProcessEnv,
});
expect(finalizeMatrixRegisterConfigAfterSuccessMock).toHaveBeenCalledWith({
homeserver: "https://matrix.example.org",
userId: "@newbot:example.org",
deviceId: "REGDEVICE123",
});
});
it("ignores cached credentials when matrix-js.register=true", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
access_token: "tok-123",
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
doRequestSpy.mockRejectedValueOnce(new Error("Invalid username or password"));
const cfg = {
channels: {
@@ -247,12 +136,16 @@ describe("resolveMatrixAuth", () => {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
register: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv });
await expect(
resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
}),
).rejects.toThrow("Invalid username or password");
expect(doRequestSpy).toHaveBeenCalledWith(
"POST",
@@ -262,44 +155,41 @@ describe("resolveMatrixAuth", () => {
type: "m.login.password",
}),
);
expect(auth.accessToken).toBe("tok-123");
expect(prepareMatrixRegisterModeMock).toHaveBeenCalledTimes(1);
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
});
it("requires matrix-js.password when matrix-js.register=true", async () => {
it("uses cached matching credentials when access token is not configured", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
register: true,
},
},
} as CoreConfig;
await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow(
"Matrix password is required when matrix-js.register=true",
);
expect(prepareMatrixRegisterModeMock).not.toHaveBeenCalled();
expect(finalizeMatrixRegisterConfigAfterSuccessMock).not.toHaveBeenCalled();
});
it("requires matrix-js.userId when matrix-js.register=true", async () => {
const cfg = {
channels: {
"matrix-js": {
homeserver: "https://matrix.example.org",
password: "secret",
register: true,
},
},
} as CoreConfig;
await expect(resolveMatrixAuth({ cfg, env: {} as NodeJS.ProcessEnv })).rejects.toThrow(
"Matrix userId is required when matrix-js.register=true",
);
expect(prepareMatrixRegisterModeMock).not.toHaveBeenCalled();
expect(finalizeMatrixRegisterConfigAfterSuccessMock).not.toHaveBeenCalled();
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
});
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
});
it("falls back to config deviceId when cached credentials are missing it", async () => {

View File

@@ -3,36 +3,12 @@ import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { MatrixClient } from "../sdk.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import {
finalizeMatrixRegisterConfigAfterSuccess,
prepareMatrixRegisterMode,
} from "./register-mode.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
function clean(value?: string): string {
return value?.trim() ?? "";
}
function parseOptionalBoolean(value: unknown): boolean | undefined {
if (typeof value === "boolean") {
return value;
}
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return undefined;
}
if (["1", "true", "yes", "on"].includes(normalized)) {
return true;
}
if (["0", "false", "no", "off"].includes(normalized)) {
return false;
}
return undefined;
}
function findAccountConfig(cfg: CoreConfig, accountId: string): Record<string, unknown> {
const accounts = cfg.channels?.["matrix-js"]?.accounts;
if (!accounts || typeof accounts !== "object") {
@@ -54,70 +30,6 @@ function findAccountConfig(cfg: CoreConfig, accountId: string): Record<string, u
return {};
}
function resolveMatrixLocalpart(userId: string): string {
const trimmed = userId.trim();
const noPrefix = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
const localpart = noPrefix.split(":")[0]?.trim() || "";
if (!localpart) {
throw new Error(`Invalid Matrix userId for registration: ${userId}`);
}
return localpart;
}
async function registerMatrixPasswordAccount(params: {
homeserver: string;
userId: string;
password: string;
deviceId?: string;
deviceName?: string;
}): Promise<{
access_token?: string;
user_id?: string;
device_id?: string;
}> {
const registerClient = new MatrixClient(params.homeserver, "");
const payload = {
username: resolveMatrixLocalpart(params.userId),
password: params.password,
inhibit_login: false,
device_id: params.deviceId,
initial_device_display_name: params.deviceName ?? "OpenClaw Gateway",
};
let firstError: unknown = null;
try {
return (await registerClient.doRequest("POST", "/_matrix/client/v3/register", undefined, {
...payload,
auth: { type: "m.login.dummy" },
})) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
} catch (err) {
firstError = err;
}
try {
return (await registerClient.doRequest(
"POST",
"/_matrix/client/v3/register",
undefined,
payload,
)) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
} catch (err) {
const firstMessage = firstError instanceof Error ? firstError.message : String(firstError);
const secondMessage = err instanceof Error ? err.message : String(err);
throw new Error(
`Matrix registration failed (dummy auth: ${firstMessage}; plain registration: ${secondMessage})`,
);
}
}
export function resolveMatrixConfig(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
@@ -127,8 +39,6 @@ export function resolveMatrixConfig(
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
const accessToken = clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
const register =
parseOptionalBoolean(matrix.register) ?? parseOptionalBoolean(env.MATRIX_REGISTER) ?? false;
const deviceId = clean(matrix.deviceId) || clean(env.MATRIX_DEVICE_ID) || undefined;
const deviceName = clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
const initialSyncLimit =
@@ -141,7 +51,6 @@ export function resolveMatrixConfig(
userId,
accessToken,
password,
register,
deviceId,
deviceName,
initialSyncLimit,
@@ -180,11 +89,6 @@ export function resolveMatrixConfigForAccount(
accountAccessToken || clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
const password =
accountPassword || clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
const register =
parseOptionalBoolean(account.register) ??
parseOptionalBoolean(matrix.register) ??
parseOptionalBoolean(env.MATRIX_REGISTER) ??
false;
const deviceId =
accountDeviceId || clean(matrix.deviceId) || clean(env.MATRIX_DEVICE_ID) || undefined;
const deviceName =
@@ -207,7 +111,6 @@ export function resolveMatrixConfigForAccount(
userId,
accessToken,
password,
register,
deviceId,
deviceName,
initialSyncLimit,
@@ -226,7 +129,6 @@ export async function resolveMatrixAuth(params?: {
const resolved = accountId
? resolveMatrixConfigForAccount(cfg, accountId, env)
: resolveMatrixConfig(cfg, env);
const registerFromConfig = resolved.register === true;
if (!resolved.homeserver) {
throw new Error("Matrix homeserver is required (matrix-js.homeserver)");
}
@@ -248,23 +150,8 @@ export async function resolveMatrixAuth(params?: {
? cached
: null;
if (registerFromConfig) {
if (!resolved.userId) {
throw new Error("Matrix userId is required when matrix-js.register=true");
}
if (!resolved.password) {
throw new Error("Matrix password is required when matrix-js.register=true");
}
await prepareMatrixRegisterMode({
cfg,
homeserver: resolved.homeserver,
userId: resolved.userId,
env,
});
}
// If we have an access token, we can fetch userId via whoami if not provided
if (resolved.accessToken && !registerFromConfig) {
if (resolved.accessToken) {
let userId = resolved.userId;
const hasMatchingCachedToken = cachedCredentials?.accessToken === resolved.accessToken;
let knownDeviceId = hasMatchingCachedToken
@@ -322,7 +209,7 @@ export async function resolveMatrixAuth(params?: {
};
}
if (cachedCredentials && !registerFromConfig) {
if (cachedCredentials) {
touchMatrixCredentials(env, accountId);
return {
homeserver: cachedCredentials.homeserver,
@@ -351,48 +238,21 @@ export async function resolveMatrixAuth(params?: {
// Login with password using the same hardened request path as other Matrix HTTP calls.
ensureMatrixSdkLoggingConfigured();
const loginClient = new MatrixClient(resolved.homeserver, "");
let login: {
const login = (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, {
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
device_id: resolved.deviceId,
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
})) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
try {
login = (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, {
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
device_id: resolved.deviceId,
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
})) as {
access_token?: string;
user_id?: string;
device_id?: string;
};
} catch (loginErr) {
if (!resolved.register) {
throw loginErr;
}
try {
login = await registerMatrixPasswordAccount({
homeserver: resolved.homeserver,
userId: resolved.userId,
password: resolved.password,
deviceId: resolved.deviceId,
deviceName: resolved.deviceName,
});
} catch (registerErr) {
const loginMessage = loginErr instanceof Error ? loginErr.message : String(loginErr);
const registerMessage =
registerErr instanceof Error ? registerErr.message : String(registerErr);
throw new Error(
`Matrix login failed (${loginMessage}) and account registration failed (${registerMessage})`,
);
}
}
const accessToken = login.access_token?.trim();
if (!accessToken) {
throw new Error("Matrix login/registration did not return an access token");
throw new Error("Matrix login did not return an access token");
}
const auth: MatrixAuth = {
@@ -417,13 +277,5 @@ export async function resolveMatrixAuth(params?: {
accountId,
);
if (registerFromConfig) {
await finalizeMatrixRegisterConfigAfterSuccess({
homeserver: auth.homeserver,
userId: auth.userId,
deviceId: auth.deviceId,
});
}
return auth;
}

View File

@@ -1,98 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as runtimeModule from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import {
finalizeMatrixRegisterConfigAfterSuccess,
prepareMatrixRegisterMode,
resetPreparedMatrixRegisterModesForTests,
} from "./register-mode.js";
describe("matrix register mode helpers", () => {
const tempDirs: string[] = [];
afterEach(() => {
resetPreparedMatrixRegisterModesForTests();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
vi.restoreAllMocks();
});
it("moves existing matrix state into a .bak snapshot before fresh registration", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-register-mode-"));
tempDirs.push(stateDir);
const credentialsDir = path.join(stateDir, "credentials", "matrix-js");
const accountsDir = path.join(credentialsDir, "accounts");
fs.mkdirSync(accountsDir, { recursive: true });
fs.writeFileSync(path.join(credentialsDir, "credentials.json"), '{"accessToken":"old"}\n');
fs.writeFileSync(path.join(accountsDir, "dummy.txt"), "old-state\n");
const cfg = {
channels: {
"matrix-js": {
userId: "@pinguini:matrix.gumadeiras.com",
register: true,
encryption: true,
},
},
} as CoreConfig;
const backupDir = await prepareMatrixRegisterMode({
cfg,
homeserver: "https://matrix.gumadeiras.com",
userId: "@pinguini:matrix.gumadeiras.com",
env: { OPENCLAW_STATE_DIR: stateDir } as NodeJS.ProcessEnv,
});
expect(backupDir).toBeTruthy();
expect(fs.existsSync(path.join(credentialsDir, "credentials.json"))).toBe(false);
expect(fs.existsSync(path.join(credentialsDir, "accounts"))).toBe(false);
expect(fs.existsSync(path.join(backupDir as string, "credentials.json"))).toBe(true);
expect(fs.existsSync(path.join(backupDir as string, "accounts", "dummy.txt"))).toBe(true);
expect(fs.existsSync(path.join(backupDir as string, "matrix-config.json"))).toBe(true);
});
it("updates matrix config after successful register mode auth", async () => {
const writeConfigFile = vi.fn(async () => {});
vi.spyOn(runtimeModule, "getMatrixRuntime").mockReturnValue({
config: {
loadConfig: () =>
({
channels: {
"matrix-js": {
register: true,
accessToken: "stale-token",
userId: "@pinguini:matrix.gumadeiras.com",
},
},
}) as CoreConfig,
writeConfigFile,
},
} as never);
const updated = await finalizeMatrixRegisterConfigAfterSuccess({
homeserver: "https://matrix.gumadeiras.com",
userId: "@pinguini:matrix.gumadeiras.com",
deviceId: "DEVICE123",
});
expect(updated).toBe(true);
expect(writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
channels: expect.objectContaining({
"matrix-js": expect.objectContaining({
register: false,
homeserver: "https://matrix.gumadeiras.com",
userId: "@pinguini:matrix.gumadeiras.com",
deviceId: "DEVICE123",
}),
}),
}),
);
const firstCall = (writeConfigFile.mock.calls as unknown[][])[0];
const written = (firstCall?.[0] ?? {}) as CoreConfig;
expect(written.channels?.["matrix-js"]?.accessToken).toBeUndefined();
});
});

View File

@@ -1,125 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { resolveMatrixCredentialsDir } from "../credentials.js";
const preparedRegisterKeys = new Set<string>();
function resolveStateDirFromEnv(env: NodeJS.ProcessEnv): string {
try {
return getMatrixRuntime().state.resolveStateDir(env, os.homedir);
} catch {
// fall through to deterministic fallback for tests/early init
}
const override = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
if (override) {
if (override.startsWith("~")) {
const expanded = override.replace(/^~(?=$|[\\/])/, os.homedir());
return path.resolve(expanded);
}
return path.resolve(override);
}
return path.join(os.homedir(), ".openclaw");
}
function buildRegisterKey(params: { homeserver: string; userId: string }): string {
return `${params.homeserver.trim().toLowerCase()}|${params.userId.trim().toLowerCase()}`;
}
function buildBackupDirName(now = new Date()): string {
const ts = now.toISOString().replace(/[:.]/g, "-");
const suffix = Math.random().toString(16).slice(2, 8);
return `${ts}-${suffix}`;
}
export async function prepareMatrixRegisterMode(params: {
cfg: CoreConfig;
homeserver: string;
userId: string;
env?: NodeJS.ProcessEnv;
}): Promise<string | null> {
const env = params.env ?? process.env;
const registerKey = buildRegisterKey({
homeserver: params.homeserver,
userId: params.userId,
});
if (preparedRegisterKeys.has(registerKey)) {
return null;
}
const stateDir = resolveStateDirFromEnv(env);
const credentialsDir = resolveMatrixCredentialsDir(env, stateDir);
if (!fs.existsSync(credentialsDir)) {
return null;
}
const entries = fs.readdirSync(credentialsDir).filter((name) => name !== ".bak");
if (entries.length === 0) {
return null;
}
const backupRoot = path.join(credentialsDir, ".bak");
fs.mkdirSync(backupRoot, { recursive: true });
const backupDir = path.join(backupRoot, buildBackupDirName());
fs.mkdirSync(backupDir, { recursive: true });
const matrixConfig = params.cfg.channels?.["matrix-js"] ?? {};
fs.writeFileSync(
path.join(backupDir, "matrix-config.json"),
JSON.stringify(matrixConfig, null, 2).trimEnd().concat("\n"),
"utf-8",
);
for (const entry of entries) {
fs.renameSync(path.join(credentialsDir, entry), path.join(backupDir, entry));
}
preparedRegisterKeys.add(registerKey);
return backupDir;
}
export async function finalizeMatrixRegisterConfigAfterSuccess(params: {
homeserver: string;
userId: string;
deviceId?: string;
}): Promise<boolean> {
let runtime: ReturnType<typeof getMatrixRuntime> | null = null;
try {
runtime = getMatrixRuntime();
} catch {
return false;
}
const cfg = runtime.config.loadConfig() as CoreConfig;
if (cfg.channels?.["matrix-js"]?.register !== true) {
return false;
}
const matrixCfg = cfg.channels?.["matrix-js"] ?? {};
const nextMatrix: Record<string, unknown> = {
...matrixCfg,
register: false,
homeserver: params.homeserver,
userId: params.userId,
...(params.deviceId?.trim() ? { deviceId: params.deviceId.trim() } : {}),
};
// Registration mode should continue relying on password + cached credentials, not stale inline token.
delete nextMatrix.accessToken;
const next: CoreConfig = {
...cfg,
channels: {
...(cfg.channels ?? {}),
"matrix-js": nextMatrix as NonNullable<CoreConfig["channels"]>["matrix-js"],
},
};
await runtime.config.writeConfigFile(next as never);
return true;
}
export function resetPreparedMatrixRegisterModesForTests(): void {
preparedRegisterKeys.clear();
}

View File

@@ -4,7 +4,6 @@ export type MatrixResolvedConfig = {
accessToken?: string;
deviceId?: string;
password?: string;
register?: boolean;
deviceName?: string;
initialSyncLimit?: number;
encryption?: boolean;

View File

@@ -41,9 +41,8 @@ async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"Matrix requires a homeserver URL.",
"Use an access token (recommended), password login, or account registration.",
"Use an access token (recommended) or password login to an existing account.",
"With access token: user ID is fetched automatically.",
"Password + register mode can create an account on homeservers with open registration.",
"Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD.",
`Docs: ${formatDocsLink("/channels/matrix", "channels/matrix")}`,
].join("\n"),
@@ -266,7 +265,6 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
let accessToken = existing.accessToken ?? "";
let password = existing.password ?? "";
let userId = existing.userId ?? "";
let register = existing.register === true;
if (accessToken || password) {
const keep = await prompter.confirm({
@@ -277,7 +275,6 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
accessToken = "";
password = "";
userId = "";
register = false;
}
}
@@ -288,10 +285,6 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
options: [
{ value: "token", label: "Access token (user ID fetched automatically)" },
{ value: "password", label: "Password (requires user ID)" },
{
value: "register",
label: "Register account (open homeserver registration required)",
},
],
});
@@ -305,9 +298,8 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
// With access token, we can fetch the userId automatically - don't prompt for it
// The client.ts will use whoami() to get it
userId = "";
register = false;
} else {
// Password auth and registration mode require user ID upfront
// Password auth requires user ID upfront.
userId = String(
await prompter.text({
message: "Matrix user ID",
@@ -333,7 +325,6 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
register = authMode === "register";
}
}
@@ -361,7 +352,6 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
userId: userId || undefined,
accessToken: accessToken || undefined,
password: password || undefined,
register,
deviceName: deviceName || undefined,
encryption: enableEncryption || undefined,
},

View File

@@ -58,8 +58,6 @@ export type MatrixConfig = {
accessToken?: string;
/** Matrix password (used only to fetch access token). */
password?: string;
/** Auto-register account when password login fails (open registration homeservers). */
register?: boolean;
/** Optional Matrix device id (recommended when using access tokens + E2EE). */
deviceId?: string;
/** Optional device name when logging in via password. */