Matrix: honor env-backed legacy migration config

This commit is contained in:
Gustavo Madeira Santana
2026-03-09 11:46:56 -04:00
parent 581fdb80ba
commit efe987d2c2
5 changed files with 307 additions and 165 deletions

View File

@@ -195,4 +195,67 @@ describe("matrix legacy encrypted-state migration", () => {
expect(state.accountId).toBe("ops");
});
});
it("uses scoped Matrix env vars when resolving flat legacy crypto migration", async () => {
await withTempHome(
async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: "DEVICEOPS" }),
);
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {},
},
},
},
};
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: "https://matrix.example.org",
userId: "@ops-bot:example.org",
accessToken: "tok-ops-env",
accountId: "ops",
});
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.warnings).toEqual([]);
expect(detection.plans).toHaveLength(1);
expect(detection.plans[0]?.accountId).toBe("ops");
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: "DEVICEOPS",
roomKeyCounts: { total: 4, backedUp: 4 },
backupVersion: "9001",
decryptionKeyBase64: "YWJjZA==",
}),
},
});
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
const recovery = JSON.parse(
fs.readFileSync(path.join(rootDir, "recovery-key.json"), "utf8"),
) as {
privateKeyBase64: string;
};
expect(recovery.privateKeyBase64).toBe("YWJjZA==");
},
{
env: {
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_USER_ID: "@ops-bot:example.org",
MATRIX_OPS_ACCESS_TOKEN: "tok-ops-env",
},
},
);
});
});

View File

@@ -5,25 +5,22 @@ import path from "node:path";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { writeJsonFileAtomically } from "../plugin-sdk/json-store.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import {
resolveConfiguredMatrixAccountIds,
resolveMatrixChannelConfig,
resolveMatrixDefaultOrOnlyAccountId,
} from "./matrix-account-selection.js";
import {
credentialsMatchResolvedIdentity,
loadStoredMatrixCredentials,
resolveMatrixMigrationConfigFields,
} from "./matrix-migration-config.js";
import {
resolveMatrixAccountStorageRoot,
resolveMatrixCredentialsPath,
resolveMatrixLegacyFlatStoragePaths,
} from "./matrix-storage-paths.js";
type MatrixStoredCredentials = {
homeserver: string;
userId: string;
accessToken: string;
deviceId?: string;
};
type MatrixLegacyCryptoCounts = {
total: number;
backedUp: number;
@@ -99,10 +96,6 @@ type MatrixStoredRecoveryKey = {
};
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isLegacyBotSdkCryptoStore(cryptoRootDir: string): boolean {
return (
fs.existsSync(path.join(cryptoRootDir, "bot-sdk.json")) ||
@@ -117,40 +110,6 @@ function isLegacyBotSdkCryptoStore(cryptoRootDir: string): boolean {
);
}
function loadStoredMatrixCredentials(
env: NodeJS.ProcessEnv,
accountId: string,
): MatrixStoredCredentials | null {
const stateDir = resolveStateDir(env, os.homedir);
const credentialsPath = resolveMatrixCredentialsPath({
stateDir,
accountId: normalizeAccountId(accountId),
});
try {
if (!fs.existsSync(credentialsPath)) {
return null;
}
const parsed = JSON.parse(
fs.readFileSync(credentialsPath, "utf8"),
) as Partial<MatrixStoredCredentials>;
if (
typeof parsed.homeserver !== "string" ||
typeof parsed.userId !== "string" ||
typeof parsed.accessToken !== "string"
) {
return null;
}
return {
homeserver: parsed.homeserver,
userId: parsed.userId,
accessToken: parsed.accessToken,
deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId : undefined,
};
} catch {
return null;
}
}
function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] {
return resolveConfiguredMatrixAccountIds(cfg);
}
@@ -159,24 +118,6 @@ function resolveMatrixFlatStoreTargetAccountId(cfg: OpenClawConfig): string {
return resolveMatrixDefaultOrOnlyAccountId(cfg);
}
function resolveMatrixAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): Record<string, unknown> {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return {};
}
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
const accountEntry = accounts && isRecord(accounts[accountId]) ? accounts[accountId] : null;
const merged = {
...channel,
...accountEntry,
};
delete merged.accounts;
return merged;
}
function resolveLegacyMatrixFlatStorePlan(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
@@ -197,14 +138,20 @@ function resolveLegacyMatrixFlatStorePlan(params: {
const accountId = resolveMatrixFlatStoreTargetAccountId(params.cfg);
const stored = loadStoredMatrixCredentials(params.env, accountId);
const account = resolveMatrixAccountConfig(params.cfg, accountId);
const homeserver = typeof account.homeserver === "string" ? account.homeserver.trim() : "";
const userId =
(typeof account.userId === "string" ? account.userId.trim() : "") || stored?.userId || "";
const accessToken =
(typeof account.accessToken === "string" ? account.accessToken.trim() : "") ||
stored?.accessToken ||
"";
const resolved = resolveMatrixMigrationConfigFields({
cfg: params.cfg,
env: params.env,
accountId,
});
const matchingStored = credentialsMatchResolvedIdentity(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId,
})
? stored
: null;
const homeserver = resolved.homeserver;
const userId = resolved.userId || matchingStored?.userId || "";
const accessToken = resolved.accessToken || matchingStored?.accessToken || "";
if (!homeserver || !userId || !accessToken) {
return {
@@ -274,18 +221,21 @@ function resolveMatrixLegacyCryptoPlans(params: {
const stateDir = resolveStateDir(params.env, os.homedir);
for (const accountId of resolveMatrixAccountIds(params.cfg)) {
const account = resolveMatrixAccountConfig(params.cfg, accountId);
const stored = loadStoredMatrixCredentials(params.env, accountId);
const homeserver =
(typeof account.homeserver === "string" ? account.homeserver.trim() : "") ||
stored?.homeserver ||
"";
const userId =
(typeof account.userId === "string" ? account.userId.trim() : "") || stored?.userId || "";
const accessToken =
(typeof account.accessToken === "string" ? account.accessToken.trim() : "") ||
stored?.accessToken ||
"";
const resolved = resolveMatrixMigrationConfigFields({
cfg: params.cfg,
env: params.env,
accountId,
});
const matchingStored = credentialsMatchResolvedIdentity(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId,
})
? stored
: null;
const homeserver = resolved.homeserver;
const userId = resolved.userId || matchingStored?.userId || "";
const accessToken = resolved.accessToken || matchingStored?.accessToken || "";
if (!homeserver || !userId || !accessToken) {
continue;
}

View File

@@ -120,6 +120,48 @@ describe("matrix legacy state migration", () => {
});
});
it("uses scoped Matrix env vars when resolving a flat-store migration target", async () => {
await withTempHome(
async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto");
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {},
},
},
},
};
const detection = detectLegacyMatrixState({ cfg, env: process.env });
expect(detection && "warning" in detection).toBe(false);
if (!detection || "warning" in detection) {
throw new Error("expected scoped Matrix env vars to resolve a legacy state plan");
}
expect(detection.accountId).toBe("ops");
expect(detection.targetRootDir).toContain("matrix.example.org__ops-bot_example.org");
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
expect(result.migrated).toBe(true);
expect(result.warnings).toEqual([]);
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true);
},
{
env: {
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_USER_ID: "@ops-bot:example.org",
MATRIX_OPS_ACCESS_TOKEN: "tok-ops-env",
},
},
);
});
it("migrates flat legacy Matrix state into the only configured non-default account", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");

View File

@@ -8,18 +8,16 @@ import {
resolveMatrixChannelConfig,
resolveMatrixDefaultOrOnlyAccountId,
} from "./matrix-account-selection.js";
import {
credentialsMatchResolvedIdentity,
loadStoredMatrixCredentials,
resolveMatrixMigrationConfigFields,
} from "./matrix-migration-config.js";
import {
resolveMatrixAccountStorageRoot,
resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath,
resolveMatrixLegacyFlatStoragePaths,
} from "./matrix-storage-paths.js";
type MatrixStoredCredentials = {
homeserver: string;
userId: string;
accessToken: string;
};
export type MatrixLegacyStateMigrationResult = {
migrated: boolean;
changes: string[];
@@ -49,63 +47,6 @@ function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): {
return resolveMatrixLegacyFlatStoragePaths(stateDir);
}
function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv, accountId: string): string {
const stateDir = resolveStateDir(env, os.homedir);
return resolveSharedMatrixCredentialsPath({
stateDir,
accountId: normalizeAccountId(accountId),
});
}
function loadStoredMatrixCredentials(
env: NodeJS.ProcessEnv,
accountId: string,
): MatrixStoredCredentials | null {
const credentialsPath = resolveMatrixCredentialsPath(env, accountId);
try {
if (!fs.existsSync(credentialsPath)) {
return null;
}
const parsed = JSON.parse(
fs.readFileSync(credentialsPath, "utf-8"),
) as Partial<MatrixStoredCredentials>;
if (
typeof parsed.homeserver !== "string" ||
typeof parsed.userId !== "string" ||
typeof parsed.accessToken !== "string"
) {
return null;
}
return {
homeserver: parsed.homeserver,
userId: parsed.userId,
accessToken: parsed.accessToken,
};
} catch {
return null;
}
}
function resolveMatrixAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): Record<string, unknown> {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return {};
}
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
const accountEntry = accounts && isRecord(accounts[accountId]) ? accounts[accountId] : null;
const merged = {
...channel,
...accountEntry,
};
delete merged.accounts;
return merged;
}
function resolveMatrixTargetAccountId(cfg: OpenClawConfig): string {
return resolveMatrixDefaultOrOnlyAccountId(cfg);
}
@@ -155,25 +96,22 @@ function resolveMatrixMigrationPlan(params: {
}
const accountId = resolveMatrixTargetAccountId(params.cfg);
const account = resolveMatrixAccountConfig(params.cfg, accountId);
const stored = loadStoredMatrixCredentials(params.env, accountId);
const selectionNote = resolveMatrixFlatStoreSelectionNote({ channel, accountId });
const homeserver = typeof account.homeserver === "string" ? account.homeserver.trim() : "";
const configUserId = typeof account.userId === "string" ? account.userId.trim() : "";
const configAccessToken =
typeof account.accessToken === "string" ? account.accessToken.trim() : "";
const storedMatchesHomeserver =
stored && homeserver ? stored.homeserver === homeserver : Boolean(stored);
const storedMatchesUser =
stored && configUserId ? stored.userId === configUserId : Boolean(stored);
const userId =
configUserId || (storedMatchesHomeserver && storedMatchesUser ? (stored?.userId ?? "") : "");
const accessToken =
configAccessToken ||
(storedMatchesHomeserver && storedMatchesUser ? (stored?.accessToken ?? "") : "");
const resolved = resolveMatrixMigrationConfigFields({
cfg: params.cfg,
env: params.env,
accountId,
});
const matchingStored = credentialsMatchResolvedIdentity(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId,
})
? stored
: null;
const homeserver = resolved.homeserver;
const userId = resolved.userId || matchingStored?.userId || "";
const accessToken = resolved.accessToken || matchingStored?.accessToken || "";
if (!homeserver || !userId || !accessToken) {
return {

View File

@@ -0,0 +1,149 @@
import fs from "node:fs";
import os from "node:os";
import type { OpenClawConfig } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import { normalizeAccountId } from "../routing/session-key.js";
import { resolveMatrixChannelConfig } from "./matrix-account-selection.js";
import { resolveMatrixCredentialsPath } from "./matrix-storage-paths.js";
export type MatrixStoredCredentials = {
homeserver: string;
userId: string;
accessToken: string;
deviceId?: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function clean(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function resolveMatrixEnvAccountToken(accountId: string): string {
return normalizeAccountId(accountId)
.replace(/[^a-z0-9]+/gi, "_")
.replace(/^_+|_+$/g, "")
.toUpperCase();
}
function resolveScopedMatrixEnvConfig(
accountId: string,
env: NodeJS.ProcessEnv,
): {
homeserver: string;
userId: string;
accessToken: string;
} {
const token = resolveMatrixEnvAccountToken(accountId);
return {
homeserver: clean(env[`MATRIX_${token}_HOMESERVER`]),
userId: clean(env[`MATRIX_${token}_USER_ID`]),
accessToken: clean(env[`MATRIX_${token}_ACCESS_TOKEN`]),
};
}
function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): {
homeserver: string;
userId: string;
accessToken: string;
} {
return {
homeserver: clean(env.MATRIX_HOMESERVER),
userId: clean(env.MATRIX_USER_ID),
accessToken: clean(env.MATRIX_ACCESS_TOKEN),
};
}
function resolveMatrixAccountConfigEntry(
cfg: OpenClawConfig,
accountId: string,
): Record<string, unknown> | null {
const channel = resolveMatrixChannelConfig(cfg);
if (!channel) {
return null;
}
const accounts = isRecord(channel.accounts) ? channel.accounts : null;
return accounts && isRecord(accounts[accountId]) ? accounts[accountId] : null;
}
export function resolveMatrixMigrationConfigFields(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
accountId: string;
}): {
homeserver: string;
userId: string;
accessToken: string;
} {
const channel = resolveMatrixChannelConfig(params.cfg);
const account = resolveMatrixAccountConfigEntry(params.cfg, params.accountId);
const scopedEnv = resolveScopedMatrixEnvConfig(params.accountId, params.env);
const globalEnv = resolveGlobalMatrixEnvConfig(params.env);
return {
homeserver:
clean(account?.homeserver) ||
scopedEnv.homeserver ||
clean(channel?.homeserver) ||
globalEnv.homeserver,
userId:
clean(account?.userId) || scopedEnv.userId || clean(channel?.userId) || globalEnv.userId,
accessToken:
clean(account?.accessToken) ||
scopedEnv.accessToken ||
clean(channel?.accessToken) ||
globalEnv.accessToken,
};
}
export function loadStoredMatrixCredentials(
env: NodeJS.ProcessEnv,
accountId: string,
): MatrixStoredCredentials | null {
const stateDir = resolveStateDir(env, os.homedir);
const credentialsPath = resolveMatrixCredentialsPath({
stateDir,
accountId: normalizeAccountId(accountId),
});
try {
if (!fs.existsSync(credentialsPath)) {
return null;
}
const parsed = JSON.parse(
fs.readFileSync(credentialsPath, "utf8"),
) as Partial<MatrixStoredCredentials>;
if (
typeof parsed.homeserver !== "string" ||
typeof parsed.userId !== "string" ||
typeof parsed.accessToken !== "string"
) {
return null;
}
return {
homeserver: parsed.homeserver,
userId: parsed.userId,
accessToken: parsed.accessToken,
deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId : undefined,
};
} catch {
return null;
}
}
export function credentialsMatchResolvedIdentity(
stored: MatrixStoredCredentials | null,
identity: {
homeserver: string;
userId: string;
},
): stored is MatrixStoredCredentials {
if (!stored || !identity.homeserver) {
return false;
}
if (!identity.userId) {
return stored.homeserver === identity.homeserver;
}
return stored.homeserver === identity.homeserver && stored.userId === identity.userId;
}