mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 21:21:10 +00:00
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:
committed by
GitHub
parent
5190b3b3fa
commit
7fa1a31094
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
103
extensions/matrix/src/account-selection.test.ts
Normal file
103
extensions/matrix/src/account-selection.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user