fix(matrix): honor room account scoping (#58449)

Merged via squash.

Prepared head SHA: d83f06ee3f
Co-authored-by: Daanvdplas <93204684+Daanvdplas@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Daan van der Plas
2026-04-01 19:49:22 +02:00
committed by GitHub
parent 5190b3b3fa
commit 7fa1a31094
12 changed files with 831 additions and 12 deletions

View File

@@ -8,6 +8,8 @@ Docs: https://docs.openclaw.ai
### Fixes
- Matrix/multi-account: keep room-level `account` scoping, inherited room overrides, and implicit account selection consistent across top-level default auth, named accounts, and cached-credential env setups. (#58449) thanks @Daanvdplas and @gumadeiras.
## 2026.4.1
### Changes

View File

@@ -656,6 +656,9 @@ See [Pairing](/channels/pairing) for the shared DM pairing flow and storage layo
```
Top-level `channels.matrix` values act as defaults for named accounts unless an account overrides them.
You can scope inherited room entries to one Matrix account with `groups.<room>.account` (or legacy `rooms.<room>.account`).
Entries without `account` stay shared across all Matrix accounts, and entries with `account: "default"` still work when the default account is configured directly on top-level `channels.matrix.*`.
Partial shared auth defaults do not create a separate implicit default account by themselves. OpenClaw only treats Matrix accounts with a usable homeserver plus access-token or user-ID-based auth shape as selectable for implicit routing.
Set `defaultAccount` when you want OpenClaw to prefer one named Matrix account for implicit routing, probing, and CLI operations.
If you configure multiple named accounts, set `defaultAccount` or pass `--account <id>` for CLI commands that rely on implicit account selection.
Pass `--account <id>` to `openclaw matrix verify ...` and `openclaw matrix devices ...` when you want to override that implicit selection for one command.

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import {
requiresExplicitMatrixDefaultAccount,
resolveConfiguredMatrixAccountIds,
resolveMatrixDefaultOrOnlyAccountId,
} from "./account-selection.js";
import type { CoreConfig } from "./types.js";
describe("Matrix account selection topology", () => {
it("includes a top-level default account when its auth is actually complete", () => {
const cfg = {
channels: {
matrix: {
accessToken: "default-token",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
} as NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["default", "ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("default");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(true);
});
it("does not materialize a top-level default account from partial shared auth fields", () => {
const cfg = {
channels: {
matrix: {
accessToken: "shared-token",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveConfiguredMatrixAccountIds(cfg, {} as NodeJS.ProcessEnv)).toEqual(["ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, {} as NodeJS.ProcessEnv)).toBe(false);
});
it("does not materialize a default env account from partial global auth fields", () => {
const cfg = {
channels: {
matrix: {},
},
} as CoreConfig;
const env = {
MATRIX_ACCESS_TOKEN: "shared-token",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
});
it("counts env-backed named accounts when shared homeserver comes from channel config", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
});
it("keeps env-backed named accounts that rely on cached credentials", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_USER_ID: "@ops:example.org",
} as NodeJS.ProcessEnv;
expect(resolveConfiguredMatrixAccountIds(cfg, env)).toEqual(["ops"]);
expect(resolveMatrixDefaultOrOnlyAccountId(cfg, env)).toBe("ops");
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
});
});

View File

@@ -10,12 +10,111 @@ import {
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { listMatrixEnvAccountIds } from "./env-vars.js";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
import {
resolveMatrixAccountStringValues,
type MatrixResolvedStringField,
} from "./auth-precedence.js";
import { getMatrixScopedEnvVarNames, listMatrixEnvAccountIds } from "./env-vars.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
type MatrixTopologyStringSources = Partial<Record<MatrixResolvedStringField, string>>;
function readConfiguredMatrixString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function readConfiguredMatrixSecretSource(value: unknown): string {
return hasConfiguredSecretInput(value) ? "configured" : "";
}
function resolveMatrixChannelStringSources(
entry: Record<string, unknown> | null,
): MatrixTopologyStringSources {
if (!entry) {
return {};
}
return {
homeserver: readConfiguredMatrixString(entry.homeserver),
userId: readConfiguredMatrixString(entry.userId),
accessToken: readConfiguredMatrixSecretSource(entry.accessToken),
password: readConfiguredMatrixSecretSource(entry.password),
deviceId: readConfiguredMatrixString(entry.deviceId),
deviceName: readConfiguredMatrixString(entry.deviceName),
};
}
function readEnvMatrixString(env: NodeJS.ProcessEnv, key: string): string {
const value = env[key];
return typeof value === "string" ? value.trim() : "";
}
function resolveScopedMatrixEnvStringSources(
accountId: string,
env: NodeJS.ProcessEnv,
): MatrixTopologyStringSources {
const keys = getMatrixScopedEnvVarNames(accountId);
return {
homeserver: readEnvMatrixString(env, keys.homeserver),
userId: readEnvMatrixString(env, keys.userId),
accessToken: readEnvMatrixString(env, keys.accessToken),
password: readEnvMatrixString(env, keys.password),
deviceId: readEnvMatrixString(env, keys.deviceId),
deviceName: readEnvMatrixString(env, keys.deviceName),
};
}
function resolveGlobalMatrixEnvStringSources(env: NodeJS.ProcessEnv): MatrixTopologyStringSources {
return {
homeserver: readEnvMatrixString(env, "MATRIX_HOMESERVER"),
userId: readEnvMatrixString(env, "MATRIX_USER_ID"),
accessToken: readEnvMatrixString(env, "MATRIX_ACCESS_TOKEN"),
password: readEnvMatrixString(env, "MATRIX_PASSWORD"),
deviceId: readEnvMatrixString(env, "MATRIX_DEVICE_ID"),
deviceName: readEnvMatrixString(env, "MATRIX_DEVICE_NAME"),
};
}
function hasUsableResolvedMatrixAuth(values: {
homeserver: string;
userId: string;
accessToken: string;
}): boolean {
// Account discovery must keep homeserver+userId shapes because auth can still
// resolve through cached Matrix credentials even when no fresh token/password
// is present in config or env.
return Boolean(values.homeserver && (values.accessToken || values.userId));
}
function hasReadyEffectiveMatrixAccountSource(params: {
channel: Record<string, unknown> | null;
accountId: string;
env: NodeJS.ProcessEnv;
}): boolean {
const normalizedAccountId = normalizeAccountId(params.accountId);
const resolved = resolveMatrixAccountStringValues({
accountId: normalizedAccountId,
scopedEnv: resolveScopedMatrixEnvStringSources(normalizedAccountId, params.env),
channel: resolveMatrixChannelStringSources(params.channel),
globalEnv: resolveGlobalMatrixEnvStringSources(params.env),
});
return hasUsableResolvedMatrixAuth(resolved);
}
function hasConfiguredDefaultMatrixAccountSource(params: {
channel: Record<string, unknown> | null;
env: NodeJS.ProcessEnv;
}): boolean {
return hasReadyEffectiveMatrixAccountSource({
channel: params.channel,
accountId: DEFAULT_ACCOUNT_ID,
env: params.env,
});
}
export function resolveMatrixChannelConfig(cfg: OpenClawConfig): Record<string, unknown> | null {
return isRecord(cfg.channels?.matrix) ? cfg.channels.matrix : null;
}
@@ -42,12 +141,19 @@ export function resolveConfiguredMatrixAccountIds(
env: NodeJS.ProcessEnv = process.env,
): string[] {
const channel = resolveMatrixChannelConfig(cfg);
const configuredAccountIds = listConfiguredAccountIds({
accounts: channel && isRecord(channel.accounts) ? channel.accounts : undefined,
normalizeAccountId,
});
if (hasConfiguredDefaultMatrixAccountSource({ channel, env })) {
configuredAccountIds.push(DEFAULT_ACCOUNT_ID);
}
const readyEnvAccountIds = listMatrixEnvAccountIds(env).filter((accountId) =>
hasReadyEffectiveMatrixAccountSource({ channel, accountId, env }),
);
return listCombinedAccountIds({
configuredAccountIds: listConfiguredAccountIds({
accounts: channel && isRecord(channel.accounts) ? channel.accounts : undefined,
normalizeAccountId,
}),
additionalAccountIds: listMatrixEnvAccountIds(env),
configuredAccountIds,
additionalAccountIds: readyEnvAccountIds,
fallbackAccountIdWhenEmpty: channel ? DEFAULT_ACCOUNT_ID : undefined,
});
}

View File

@@ -30,4 +30,40 @@ describe("MatrixConfigSchema SecretInput", () => {
});
expect(result.success).toBe(true);
});
it("accepts room-level account assignments", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
groups: {
"!room:example.org": {
allow: true,
account: "axis",
},
},
});
expect(result.success).toBe(true);
if (!result.success) {
throw new Error("expected schema parse to succeed");
}
expect(result.data.groups?.["!room:example.org"]?.account).toBe("axis");
});
it("accepts legacy room-level account assignments", () => {
const result = MatrixConfigSchema.safeParse({
homeserver: "https://matrix.example.org",
accessToken: "token",
rooms: {
"!room:example.org": {
allow: true,
account: "axis",
},
},
});
expect(result.success).toBe(true);
if (!result.success) {
throw new Error("expected schema parse to succeed");
}
expect(result.data.rooms?.["!room:example.org"]?.account).toBe("axis");
});
});

View File

@@ -32,6 +32,7 @@ const matrixThreadBindingsSchema = z
const matrixRoomSchema = z
.object({
account: z.string().optional(),
enabled: z.boolean().optional(),
allow: z.boolean().optional(),
requireMention: z.boolean().optional(),

View File

@@ -260,6 +260,46 @@ describe("resolveMatrixAccount", () => {
expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
});
it("includes a top-level configured default account alongside named accounts", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "default-token",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
};
expect(listMatrixAccountIds(cfg)).toEqual(["default", "ops"]);
expect(resolveDefaultMatrixAccountId(cfg)).toBe("default");
});
it("does not materialize a default account from shared top-level defaults alone", () => {
const cfg: CoreConfig = {
channels: {
matrix: {
name: "Shared Defaults",
enabled: true,
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
};
expect(listMatrixAccountIds(cfg)).toEqual(["ops"]);
expect(resolveDefaultMatrixAccountId(cfg)).toBe("ops");
});
it('uses the synthetic "default" account when multiple named accounts need explicit selection', () => {
const cfg: CoreConfig = {
channels: {
@@ -406,4 +446,289 @@ describe("resolveMatrixAccount", () => {
messages: false,
});
});
it("filters channel-level groups by room account in multi-account setups", () => {
const cfg = {
channels: {
matrix: {
groups: {
"!default-room:example.org": {
allow: true,
account: "default",
},
"!axis-room:example.org": {
allow: true,
account: "axis",
},
"!unassigned-room:example.org": {
allow: true,
},
},
accounts: {
default: {
homeserver: "https://matrix.example.org",
accessToken: "default-token",
},
axis: {
homeserver: "https://matrix.example.org",
accessToken: "axis-token",
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "default" }).config.groups).toEqual({
"!default-room:example.org": {
allow: true,
account: "default",
},
"!unassigned-room:example.org": {
allow: true,
},
});
expect(resolveMatrixAccount({ cfg, accountId: "axis" }).config.groups).toEqual({
"!axis-room:example.org": {
allow: true,
account: "axis",
},
"!unassigned-room:example.org": {
allow: true,
},
});
});
it("filters channel-level groups when the default account is configured at the top level", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "default-token",
groups: {
"!default-room:example.org": {
allow: true,
account: "default",
},
"!ops-room:example.org": {
allow: true,
account: "ops",
},
"!shared-room:example.org": {
allow: true,
},
},
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "default" }).config.groups).toEqual({
"!default-room:example.org": {
allow: true,
account: "default",
},
"!shared-room:example.org": {
allow: true,
},
});
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.groups).toEqual({
"!ops-room:example.org": {
allow: true,
account: "ops",
},
"!shared-room:example.org": {
allow: true,
},
});
});
it("filters legacy channel-level rooms by room account in multi-account setups", () => {
const cfg = {
channels: {
matrix: {
rooms: {
"!default-room:example.org": {
allow: true,
account: "default",
},
"!axis-room:example.org": {
allow: true,
account: "axis",
},
"!unassigned-room:example.org": {
allow: true,
},
},
accounts: {
default: {
homeserver: "https://matrix.example.org",
accessToken: "default-token",
},
axis: {
homeserver: "https://matrix.example.org",
accessToken: "axis-token",
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "default" }).config.rooms).toEqual({
"!default-room:example.org": {
allow: true,
account: "default",
},
"!unassigned-room:example.org": {
allow: true,
},
});
expect(resolveMatrixAccount({ cfg, accountId: "axis" }).config.rooms).toEqual({
"!axis-room:example.org": {
allow: true,
account: "axis",
},
"!unassigned-room:example.org": {
allow: true,
},
});
});
it("filters legacy channel-level rooms when the default account is configured at the top level", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "default-token",
rooms: {
"!default-room:example.org": {
allow: true,
account: "default",
},
"!ops-room:example.org": {
allow: true,
account: "ops",
},
"!shared-room:example.org": {
allow: true,
},
},
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "default" }).config.rooms).toEqual({
"!default-room:example.org": {
allow: true,
account: "default",
},
"!shared-room:example.org": {
allow: true,
},
});
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.rooms).toEqual({
"!ops-room:example.org": {
allow: true,
account: "ops",
},
"!shared-room:example.org": {
allow: true,
},
});
});
it("honors injected env when scoping room entries in multi-account setups", () => {
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_ACCESS_TOKEN: "default-token",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
const cfg = {
channels: {
matrix: {
groups: {
"!default-room:example.org": {
allow: true,
account: "default",
},
"!ops-room:example.org": {
allow: true,
account: "ops",
},
"!shared-room:example.org": {
allow: true,
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "ops", env }).config.groups).toEqual({
"!ops-room:example.org": {
allow: true,
account: "ops",
},
"!shared-room:example.org": {
allow: true,
},
});
});
it("lets an account clear inherited groups with an explicit empty map", () => {
const cfg = {
channels: {
matrix: {
groups: {
"!shared-room:example.org": {
allow: true,
},
},
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
groups: {},
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.groups).toBeUndefined();
});
it("lets an account clear inherited legacy rooms with an explicit empty map", () => {
const cfg = {
channels: {
matrix: {
rooms: {
"!shared-room:example.org": {
allow: true,
},
},
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
rooms: {},
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.rooms).toBeUndefined();
});
});

View File

@@ -10,6 +10,57 @@ import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "./account-conf
import { resolveMatrixConfigForAccount } from "./client.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials-read.js";
type MatrixRoomEntries = Record<string, NonNullable<MatrixConfig["groups"]>[string]>;
function selectInheritedMatrixRoomEntries(params: {
entries: MatrixRoomEntries | undefined;
accountId: string;
isMultiAccount: boolean;
}): MatrixRoomEntries | undefined {
const entries = params.entries;
if (!entries) {
return undefined;
}
if (!params.isMultiAccount) {
return entries;
}
const selected = Object.fromEntries(
Object.entries(entries).filter(([, value]) => {
const scopedAccount =
typeof value?.account === "string" ? normalizeAccountId(value.account) : undefined;
return scopedAccount === undefined || scopedAccount === params.accountId;
}),
) as MatrixRoomEntries;
return Object.keys(selected).length > 0 ? selected : undefined;
}
function mergeMatrixRoomEntries(
inherited: MatrixRoomEntries | undefined,
accountEntries: MatrixRoomEntries | undefined,
hasAccountOverride: boolean,
): MatrixRoomEntries | undefined {
if (!inherited && !accountEntries) {
return undefined;
}
if (hasAccountOverride && Object.keys(accountEntries ?? {}).length === 0) {
return undefined;
}
const merged: MatrixRoomEntries = {
...(inherited ?? {}),
};
for (const [key, value] of Object.entries(accountEntries ?? {})) {
const inheritedValue = merged[key];
merged[key] =
inheritedValue && value
? {
...inheritedValue,
...value,
}
: (value ?? inheritedValue);
}
return Object.keys(merged).length > 0 ? merged : undefined;
}
export type ResolvedMatrixAccount = {
accountId: string;
enabled: boolean;
@@ -95,7 +146,7 @@ export function resolveMatrixAccount(params: {
const env = params.env ?? process.env;
const accountId = normalizeAccountId(params.accountId);
const matrixBase = resolveMatrixBaseConfig(params.cfg);
const base = resolveMatrixAccountConfig({ cfg: params.cfg, accountId });
const base = resolveMatrixAccountConfig({ cfg: params.cfg, accountId, env });
const explicitAuthConfig =
accountId === DEFAULT_ACCOUNT_ID
? base
@@ -133,10 +184,13 @@ export function resolveMatrixAccount(params: {
export function resolveMatrixAccountConfig(params: {
cfg: CoreConfig;
accountId?: string | null;
env?: NodeJS.ProcessEnv;
}): MatrixConfig {
const env = params.env ?? process.env;
const accountId = normalizeAccountId(params.accountId);
return resolveMergedAccountConfig<MatrixConfig>({
channelConfig: resolveMatrixBaseConfig(params.cfg),
const base = resolveMatrixBaseConfig(params.cfg);
const merged = resolveMergedAccountConfig<MatrixConfig>({
channelConfig: base,
accounts: params.cfg.channels?.matrix?.accounts as
| Record<string, Partial<MatrixConfig>>
| undefined,
@@ -144,4 +198,31 @@ export function resolveMatrixAccountConfig(params: {
normalizeAccountId,
nestedObjectKeys: ["dm", "actions"],
});
const accountConfig = findMatrixAccountConfig(params.cfg, accountId);
const isMultiAccount = resolveConfiguredMatrixAccountIds(params.cfg, env).length > 1;
const groups = mergeMatrixRoomEntries(
selectInheritedMatrixRoomEntries({
entries: base.groups,
accountId,
isMultiAccount,
}),
accountConfig?.groups,
Boolean(accountConfig && Object.hasOwn(accountConfig, "groups")),
);
const rooms = mergeMatrixRoomEntries(
selectInheritedMatrixRoomEntries({
entries: base.rooms,
accountId,
isMultiAccount,
}),
accountConfig?.rooms,
Boolean(accountConfig && Object.hasOwn(accountConfig, "rooms")),
);
// Room maps need custom scoping, so keep the generic merge for all other fields.
const { groups: _ignoredGroups, rooms: _ignoredRooms, ...rest } = merged;
return {
...rest,
...(groups ? { groups } : {}),
...(rooms ? { rooms } : {}),
};
}

View File

@@ -378,6 +378,95 @@ describe("resolveMatrixConfig", () => {
);
});
it("does not materialize a default account from shared top-level defaults alone", () => {
const cfg = {
channels: {
matrix: {
name: "Shared Defaults",
accounts: {
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveImplicitMatrixAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("ops");
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
});
it("does not materialize a default account from partial top-level auth defaults", () => {
const cfg = {
channels: {
matrix: {
accessToken: "shared-token",
accounts: {
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
},
},
},
},
} as CoreConfig;
expect(resolveImplicitMatrixAccountId(cfg, {} as NodeJS.ProcessEnv)).toBe("ops");
expect(resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv }).accountId).toBe("ops");
});
it("honors injected env when implicit Matrix account selection becomes ambiguous", () => {
const cfg = {
channels: {
matrix: {},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_ACCESS_TOKEN: "default-token",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveImplicitMatrixAccountId(cfg, env)).toBeNull();
expect(() => resolveMatrixAuthContext({ cfg, env })).toThrow(
/channels\.matrix\.defaultAccount.*--account <id>/i,
);
});
it("does not materialize a default env account from partial global auth fields", () => {
const cfg = {
channels: {
matrix: {},
},
} as CoreConfig;
const env = {
MATRIX_ACCESS_TOKEN: "shared-token",
MATRIX_OPS_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_ACCESS_TOKEN: "ops-token",
} as NodeJS.ProcessEnv;
expect(resolveImplicitMatrixAccountId(cfg, env)).toBe("ops");
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
});
it("keeps implicit selection for env-backed accounts that can use cached credentials", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_USER_ID: "@ops:example.org",
} as NodeJS.ProcessEnv;
expect(resolveImplicitMatrixAccountId(cfg, env)).toBe("ops");
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
});
it("rejects explicit non-default account ids that are neither configured nor scoped in env", () => {
const cfg = {
channels: {
@@ -727,6 +816,43 @@ describe("resolveMatrixAuth", () => {
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
});
it("uses cached matching credentials for env-backed named accounts without fresh auth", async () => {
vi.mocked(credentialsReadModule!.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsReadModule!.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
},
},
} as CoreConfig;
const env = {
MATRIX_OPS_USER_ID: "@ops:example.org",
} as NodeJS.ProcessEnv;
const auth = await resolveMatrixAuth({
cfg,
env,
accountId: "ops",
});
expect(auth).toMatchObject({
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
});
expect(saveMatrixCredentialsMock).not.toHaveBeenCalled();
});
it("rejects embedded credentials in Matrix homeserver URLs", async () => {
const cfg = {
channels: {

View File

@@ -609,12 +609,12 @@ export function resolveMatrixConfigForAccount(
export function resolveImplicitMatrixAccountId(
cfg: CoreConfig,
_env: NodeJS.ProcessEnv = process.env,
env: NodeJS.ProcessEnv = process.env,
): string | null {
if (requiresExplicitMatrixDefaultAccount(cfg)) {
if (requiresExplicitMatrixDefaultAccount(cfg, env)) {
return null;
}
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg));
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg, env));
}
export function resolveMatrixAuthContext(params?: {

View File

@@ -148,6 +148,40 @@ describe("matrix credentials storage", () => {
expect(fs.existsSync(currentPath)).toBe(false);
});
it("migrates legacy credentials to the named account when top-level auth is only a shared default", () => {
const stateDir = setupStateDir({
channels: {
matrix: {
accessToken: "shared-token",
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
});
const legacyPath = path.join(stateDir, "credentials", "matrix", "credentials.json");
const currentPath = resolveMatrixCredentialsPath({}, "ops");
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
fs.writeFileSync(
legacyPath,
JSON.stringify({
homeserver: "https://matrix.example.org",
userId: "@ops:example.org",
accessToken: "legacy-token",
createdAt: "2026-03-01T10:00:00.000Z",
}),
);
const loaded = loadMatrixCredentials({}, "ops");
expect(loaded?.accessToken).toBe("legacy-token");
expect(fs.existsSync(legacyPath)).toBe(false);
expect(fs.existsSync(currentPath)).toBe(true);
});
it("clears both current and legacy credential paths", () => {
const stateDir = setupStateDir({
channels: {

View File

@@ -15,6 +15,8 @@ export type MatrixDmConfig = {
};
export type MatrixRoomConfig = {
/** Restrict this room entry to a specific Matrix account in multi-account setups. */
account?: string;
/** If false, disable the bot in this room (alias for allow: false). */
enabled?: boolean;
/** Legacy room allow toggle; prefer enabled. */