fix(matrix): tighten account scoping and default detection

This commit is contained in:
Gustavo Madeira Santana
2026-04-01 14:06:05 -04:00
parent d076153fc9
commit b24961c5d1
6 changed files with 169 additions and 14 deletions

View File

@@ -658,7 +658,7 @@ 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.
Partial shared auth defaults do not create a separate implicit default account by themselves. OpenClaw only synthesizes the top-level `default` account when that default has fresh auth (`homeserver` plus `accessToken`, or `homeserver` plus `userId` and `password`); named accounts can still stay discoverable from `homeserver` plus `userId` when cached credentials satisfy auth later.
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

@@ -67,6 +67,45 @@ describe("Matrix account selection topology", () => {
expect(requiresExplicitMatrixDefaultAccount(cfg, env)).toBe(false);
});
it("does not materialize a top-level default account from homeserver plus userId alone", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@default:example.org",
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 global homeserver plus userId alone", () => {
const cfg = {
channels: {
matrix: {},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_USER_ID: "@default:example.org",
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: {

View File

@@ -89,26 +89,50 @@ function hasUsableResolvedMatrixAuth(values: {
return Boolean(values.homeserver && (values.accessToken || values.userId));
}
function hasReadyEffectiveMatrixAccountSource(params: {
function hasFreshResolvedMatrixAuth(values: {
homeserver: string;
userId: string;
accessToken: string;
password: string;
}): boolean {
return Boolean(values.homeserver && (values.accessToken || (values.userId && values.password)));
}
function resolveEffectiveMatrixAccountSources(params: {
channel: Record<string, unknown> | null;
accountId: string;
env: NodeJS.ProcessEnv;
}): boolean {
}): ReturnType<typeof resolveMatrixAccountStringValues> {
const normalizedAccountId = normalizeAccountId(params.accountId);
const resolved = resolveMatrixAccountStringValues({
return resolveMatrixAccountStringValues({
accountId: normalizedAccountId,
scopedEnv: resolveScopedMatrixEnvStringSources(normalizedAccountId, params.env),
channel: resolveMatrixChannelStringSources(params.channel),
globalEnv: resolveGlobalMatrixEnvStringSources(params.env),
});
return hasUsableResolvedMatrixAuth(resolved);
}
function hasUsableEffectiveMatrixAccountSource(params: {
channel: Record<string, unknown> | null;
accountId: string;
env: NodeJS.ProcessEnv;
}): boolean {
return hasUsableResolvedMatrixAuth(resolveEffectiveMatrixAccountSources(params));
}
function hasFreshEffectiveMatrixAccountSource(params: {
channel: Record<string, unknown> | null;
accountId: string;
env: NodeJS.ProcessEnv;
}): boolean {
return hasFreshResolvedMatrixAuth(resolveEffectiveMatrixAccountSources(params));
}
function hasConfiguredDefaultMatrixAccountSource(params: {
channel: Record<string, unknown> | null;
env: NodeJS.ProcessEnv;
}): boolean {
return hasReadyEffectiveMatrixAccountSource({
return hasFreshEffectiveMatrixAccountSource({
channel: params.channel,
accountId: DEFAULT_ACCOUNT_ID,
env: params.env,
@@ -149,7 +173,9 @@ export function resolveConfiguredMatrixAccountIds(
configuredAccountIds.push(DEFAULT_ACCOUNT_ID);
}
const readyEnvAccountIds = listMatrixEnvAccountIds(env).filter((accountId) =>
hasReadyEffectiveMatrixAccountSource({ channel, accountId, env }),
normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID
? hasConfiguredDefaultMatrixAccountSource({ channel, env })
: hasUsableEffectiveMatrixAccountSource({ channel, accountId, env }),
);
return listCombinedAccountIds({
configuredAccountIds,

View File

@@ -686,6 +686,66 @@ describe("resolveMatrixAccount", () => {
});
});
it("keeps scoped groups bound to their account even when only one account is active", () => {
const cfg = {
channels: {
matrix: {
groups: {
"!default-room:example.org": {
allow: true,
account: "default",
},
"!shared-room:example.org": {
allow: true,
},
},
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.groups).toEqual({
"!shared-room:example.org": {
allow: true,
},
});
});
it("keeps scoped legacy rooms bound to their account even when only one account is active", () => {
const cfg = {
channels: {
matrix: {
rooms: {
"!default-room:example.org": {
allow: true,
account: "default",
},
"!shared-room:example.org": {
allow: true,
},
},
accounts: {
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
} as unknown as CoreConfig;
expect(resolveMatrixAccount({ cfg, accountId: "ops" }).config.rooms).toEqual({
"!shared-room:example.org": {
allow: true,
},
});
});
it("lets an account clear inherited groups with an explicit empty map", () => {
const cfg = {
channels: {

View File

@@ -15,15 +15,11 @@ type MatrixRoomEntries = Record<string, NonNullable<MatrixConfig["groups"]>[stri
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 =
@@ -199,12 +195,10 @@ export function resolveMatrixAccountConfig(params: {
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")),
@@ -213,7 +207,6 @@ export function resolveMatrixAccountConfig(params: {
selectInheritedMatrixRoomEntries({
entries: base.rooms,
accountId,
isMultiAccount,
}),
accountConfig?.rooms,
Boolean(accountConfig && Object.hasOwn(accountConfig, "rooms")),

View File

@@ -451,6 +451,43 @@ describe("resolveMatrixConfig", () => {
expect(resolveMatrixAuthContext({ cfg, env }).accountId).toBe("ops");
});
it("does not materialize a default account from top-level homeserver plus userId alone", () => {
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@default:example.org",
accounts: {
ops: {
homeserver: "https://matrix.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 env account from global homeserver plus userId alone", () => {
const cfg = {
channels: {
matrix: {},
},
} as CoreConfig;
const env = {
MATRIX_HOMESERVER: "https://matrix.example.org",
MATRIX_USER_ID: "@default:example.org",
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: {