Matrix: prompt invite auto-join during onboarding

This commit is contained in:
Gustavo Madeira Santana
2026-04-06 18:50:45 -04:00
parent 8d2ccd851c
commit 5812627bb2
7 changed files with 341 additions and 94 deletions

View File

@@ -52,6 +52,7 @@ Docs: https://docs.openclaw.ai
- Hooks/wake: queue direct and mapped wake-hook payloads as untrusted system events so external wake content no longer enters the main session as trusted input. (#62003)
- Slack/thread mentions: add `channels.slack.thread.requireExplicitMention` so Slack channels that already require mentions can also require explicit `@bot` mentions inside bot-participated threads. (#58276) Thanks @praktika-engineer.
- UI/light mode: target both root and nested WebKit scrollbar thumbs in the light theme so page-level and container scrollbars stay visible on light backgrounds. (#61753) Thanks @chziyue.
- Matrix/onboarding: add an invite auto-join setup step with explicit off warnings and strict stable-target validation so new Matrix accounts stop silently ignoring invited rooms and fresh DM-style invites unless operators opt in. (#62168) Thanks @gumadeiras.
## 2026.4.5

View File

@@ -61,13 +61,15 @@ What the Matrix wizard actually asks for:
- optional device name
- whether to enable E2EE
- whether to configure Matrix room access now
- whether to configure Matrix invite auto-join now
Wizard behavior that matters:
- If Matrix auth env vars already exist for the selected account, and that account does not already have auth saved in config, the wizard offers an env shortcut and only writes `enabled: true` for that account.
- If Matrix auth env vars already exist for the selected account, and that account does not already have auth saved in config, the wizard offers an env shortcut so setup can keep auth in env vars instead of copying secrets into config.
- When you add another Matrix account interactively, the entered account name is normalized into the account ID used in config and env vars. For example, `Ops Bot` becomes `ops-bot`.
- DM allowlist prompts accept full `@user:server` values immediately. Display names only work when live directory lookup finds one exact match; otherwise the wizard asks you to retry with a full Matrix ID.
- Room allowlist prompts accept room IDs and aliases directly. They can also resolve joined-room names live, but unresolved names are only kept as typed during setup and are ignored later by runtime allowlist resolution. Prefer `!room:server` or `#alias:server`.
- The wizard now shows an explicit warning before the invite auto-join step because `channels.matrix.autoJoin` defaults to `off`; agents will not join invited rooms or fresh DM-style invites unless you set it.
- Runtime room/session identity uses the stable Matrix room ID. Room-declared aliases are only used as lookup inputs, not as the long-term session key or stable group identity.
- To resolve room names before saving them, use `openclaw channels resolve --channel matrix "Project Room"`.

View File

@@ -30,6 +30,8 @@ export type MatrixAccountPatch = {
encryption?: boolean | null;
initialSyncLimit?: number | null;
allowBots?: MatrixConfig["allowBots"] | null;
autoJoin?: MatrixConfig["autoJoin"] | null;
autoJoinAllowlist?: MatrixConfig["autoJoinAllowlist"] | null;
dm?: MatrixConfig["dm"] | null;
groupPolicy?: MatrixConfig["groupPolicy"] | null;
groupAllowFrom?: MatrixConfig["groupAllowFrom"] | null;
@@ -203,6 +205,14 @@ export function updateMatrixAccountConfig(
nextAccount.allowBots = patch.allowBots;
}
}
if (patch.autoJoin !== undefined) {
if (patch.autoJoin === null) {
delete nextAccount.autoJoin;
} else {
nextAccount.autoJoin = patch.autoJoin;
}
}
applyNullableArrayField(nextAccount, "autoJoinAllowlist", patch.autoJoinAllowlist);
if (patch.dm !== undefined) {
if (patch.dm === null) {
delete nextAccount.dm;
@@ -245,16 +255,20 @@ export function updateMatrixAccountConfig(
);
if (shouldStoreMatrixAccountAtTopLevel(cfg, normalizedAccountId)) {
const { accounts: _ignoredAccounts, defaultAccount, ...baseMatrix } = matrix;
const { accounts: _ignoredAccounts, defaultAccount } = matrix;
const {
accounts: _ignoredNextAccounts,
defaultAccount: _ignoredNextDefaultAccount,
...topLevelAccount
} = nextAccount;
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...baseMatrix,
...(defaultAccount ? { defaultAccount } : {}),
enabled: true,
...nextAccount,
...topLevelAccount,
},
},
};

View File

@@ -117,13 +117,18 @@ export async function runMatrixAddAccountAllowlistConfigure(params: {
cfg: CoreConfig;
allowFromInput: string;
roomsAllowlistInput: string;
autoJoinPolicy?: "always" | "allowlist" | "off";
autoJoinAllowlistInput?: string;
deviceName?: string;
notes?: string[];
}) {
const prompter = createMatrixWizardPrompter({
notes: params.notes,
select: {
"Matrix already configured. What do you want to do?": "add-account",
"Matrix auth method": "token",
"Matrix rooms access": "allowlist",
"Matrix invite auto-join": params.autoJoinPolicy ?? "allowlist",
},
text: {
"Matrix account name": "ops",
@@ -132,10 +137,13 @@ export async function runMatrixAddAccountAllowlistConfigure(params: {
"Matrix device name (optional)": params.deviceName ?? "",
"Matrix allowFrom (full @user:server; display name only if unique)": params.allowFromInput,
"Matrix rooms allowlist (comma-separated)": params.roomsAllowlistInput,
"Matrix invite auto-join allowlist (comma-separated)":
params.autoJoinAllowlistInput ?? "!ops-room:example.org",
},
confirm: {
"Enable end-to-end encryption (E2EE)?": false,
"Configure Matrix rooms access?": true,
"Configure Matrix invite auto-join?": true,
},
onConfirm: async () => false,
});

View File

@@ -85,6 +85,72 @@ describe("matrix onboarding", () => {
).toBe(true);
});
it("routes env-shortcut add-account flow through Matrix invite auto-join setup", async () => {
installMatrixTestRuntime();
process.env.MATRIX_HOMESERVER = "https://matrix.env.example.org";
process.env.MATRIX_USER_ID = "@env:example.org";
process.env.MATRIX_PASSWORD = "env-password"; // pragma: allowlist secret
process.env.MATRIX_ACCESS_TOKEN = "";
process.env.MATRIX_OPS_HOMESERVER = "https://matrix.ops.env.example.org";
process.env.MATRIX_OPS_ACCESS_TOKEN = "ops-env-token";
const notes: string[] = [];
const prompter = createMatrixWizardPrompter({
notes,
select: {
"Matrix already configured. What do you want to do?": "add-account",
"Matrix rooms access": "allowlist",
"Matrix invite auto-join": "allowlist",
},
text: {
"Matrix account name": "ops",
"Matrix rooms allowlist (comma-separated)": "!ops-room:example.org",
"Matrix invite auto-join allowlist (comma-separated)": "#ops-invites:example.org",
},
confirm: {
"Configure Matrix rooms access?": true,
"Configure Matrix invite auto-join?": true,
},
onConfirm: (message) => message.startsWith("Matrix env vars detected"),
});
const result = await runMatrixInteractiveConfigure({
cfg: {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.main.example.org",
accessToken: "main-token",
},
},
},
},
} as CoreConfig,
prompter,
shouldPromptAccountIds: true,
configured: true,
});
expect(result).not.toBe("skip");
if (result === "skip") {
return;
}
expect(result.accountId).toBe("ops");
expect(result.cfg.channels?.matrix?.accounts?.ops).toMatchObject({
enabled: true,
groupPolicy: "allowlist",
groups: {
"!ops-room:example.org": { enabled: true },
},
autoJoin: "allowlist",
autoJoinAllowlist: ["#ops-invites:example.org"],
});
expect(notes.join("\n")).toContain("WARNING: Matrix invite auto-join defaults to off.");
});
it("promotes legacy top-level Matrix config before adding a named account", async () => {
installMatrixTestRuntime();
@@ -289,6 +355,7 @@ describe("matrix onboarding", () => {
"Matrix credentials already configured. Keep them?": true,
"Enable end-to-end encryption (E2EE)?": false,
"Configure Matrix rooms access?": false,
"Configure Matrix invite auto-join?": false,
},
});
@@ -353,6 +420,7 @@ describe("matrix onboarding", () => {
it("writes allowlists and room access to the selected Matrix account", async () => {
installMatrixTestRuntime();
const notes: string[] = [];
const result = await runMatrixAddAccountAllowlistConfigure({
cfg: {
@@ -369,7 +437,9 @@ describe("matrix onboarding", () => {
} as CoreConfig,
allowFromInput: "@alice:example.org",
roomsAllowlistInput: "!ops-room:example.org",
autoJoinAllowlistInput: "#ops-invites:example.org",
deviceName: "Ops Gateway",
notes,
});
expect(result).not.toBe("skip");
@@ -387,12 +457,59 @@ describe("matrix onboarding", () => {
allowFrom: ["@alice:example.org"],
},
groupPolicy: "allowlist",
autoJoin: "allowlist",
autoJoinAllowlist: ["#ops-invites:example.org"],
groups: {
"!ops-room:example.org": { enabled: true },
},
});
expect(result.cfg.channels?.["matrix"]?.dm).toBeUndefined();
expect(result.cfg.channels?.["matrix"]?.groups).toBeUndefined();
expect(notes.join("\n")).toContain("WARNING: Matrix invite auto-join defaults to off.");
});
it("clears Matrix invite auto-join allowlists when switching auto-join off", async () => {
installMatrixTestRuntime();
const prompter = createMatrixWizardPrompter({
select: {
"Matrix already configured. What do you want to do?": "update",
"Matrix invite auto-join": "off",
},
text: {
"Matrix homeserver URL": "https://matrix.example.org",
"Matrix device name (optional)": "OpenClaw Gateway",
},
confirm: {
"Matrix credentials already configured. Keep them?": true,
"Enable end-to-end encryption (E2EE)?": false,
"Configure Matrix rooms access?": false,
"Update Matrix invite auto-join?": true,
},
});
const result = await runMatrixInteractiveConfigure({
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "matrix-token",
autoJoin: "allowlist",
autoJoinAllowlist: ["#ops:example.org"],
},
},
} as CoreConfig,
prompter,
configured: true,
});
expect(result).not.toBe("skip");
if (result === "skip") {
return;
}
expect(result.cfg.channels?.matrix?.autoJoin).toBe("off");
expect(result.cfg.channels?.matrix?.autoJoinAllowlist).toBeUndefined();
});
it("reports account-scoped DM config keys for named accounts", () => {

View File

@@ -30,6 +30,7 @@ import {
normalizeAccountId,
promptAccountId,
promptChannelAccessConfig,
splitSetupEntries,
type RuntimeEnv,
type WizardPrompter,
} from "./runtime-api.js";
@@ -37,6 +38,20 @@ import { moveSingleMatrixAccountConfigToNamedAccount } from "./setup-config.js";
import type { CoreConfig } from "./types.js";
const channel = "matrix" as const;
type MatrixInviteAutoJoinPolicy = "allowlist" | "always" | "off";
const matrixInviteAutoJoinOptions: Array<{
value: MatrixInviteAutoJoinPolicy;
label: string;
}> = [
{ value: "allowlist", label: "Allowlist (recommended)" },
{ value: "always", label: "Always (join every invite)" },
{ value: "off", label: "Off (do not auto-join invites)" },
];
function isMatrixInviteAutoJoinPolicy(value: string): value is MatrixInviteAutoJoinPolicy {
return value === "allowlist" || value === "always" || value === "off";
}
function resolveMatrixOnboardingAccountId(cfg: CoreConfig, accountId?: string): string {
return normalizeAccountId(
@@ -95,12 +110,6 @@ async function promptMatrixAllowFrom(params: {
const account = resolveMatrixAccount({ cfg, accountId });
const canResolve = Boolean(account.configured);
const parseInput = (raw: string) =>
raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
const isFullUserId = (value: string) => value.startsWith("@") && value.includes(":");
while (true) {
@@ -110,7 +119,7 @@ async function promptMatrixAllowFrom(params: {
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = parseInput(String(entry));
const parts = splitSetupEntries(String(entry));
const resolvedIds: string[] = [];
const pending: string[] = [];
const unresolved: string[] = [];
@@ -187,6 +196,174 @@ function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[], accountId?: st
});
}
function setMatrixAutoJoin(
cfg: CoreConfig,
autoJoin: MatrixInviteAutoJoinPolicy,
autoJoinAllowlist: string[],
accountId?: string,
) {
return updateMatrixAccountConfig(cfg, resolveMatrixOnboardingAccountId(cfg, accountId), {
autoJoin,
autoJoinAllowlist: autoJoin === "allowlist" ? autoJoinAllowlist : null,
});
}
async function configureMatrixInviteAutoJoin(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<CoreConfig> {
const accountId = resolveMatrixOnboardingAccountId(params.cfg, params.accountId);
const existingConfig = resolveMatrixAccountConfig({ cfg: params.cfg, accountId });
const currentPolicy = existingConfig.autoJoin ?? "off";
const currentAllowlist = (existingConfig.autoJoinAllowlist ?? []).map((entry) => String(entry));
const hasExistingConfig = existingConfig.autoJoin !== undefined || currentAllowlist.length > 0;
await params.prompter.note(
[
"WARNING: Matrix invite auto-join defaults to off.",
"OpenClaw agents will not join invited rooms or fresh DM-style invites unless you set autoJoin.",
'Choose "allowlist" to restrict joins or "always" to join every invite.',
].join("\n"),
"Matrix invite auto-join",
);
const wants = await params.prompter.confirm({
message: hasExistingConfig
? "Update Matrix invite auto-join?"
: "Configure Matrix invite auto-join?",
initialValue: hasExistingConfig ? currentPolicy !== "off" : true,
});
if (!wants) {
return params.cfg;
}
const selectedPolicy = await params.prompter.select({
message: "Matrix invite auto-join",
options: matrixInviteAutoJoinOptions,
initialValue: currentPolicy,
});
if (!isMatrixInviteAutoJoinPolicy(selectedPolicy)) {
throw new Error(`Unsupported Matrix invite auto-join policy: ${String(selectedPolicy)}`);
}
const policy = selectedPolicy;
if (policy !== "allowlist") {
return setMatrixAutoJoin(params.cfg, policy, [], accountId);
}
const rawAllowlist = String(
await params.prompter.text({
message: "Matrix invite auto-join allowlist (comma-separated)",
placeholder: "!roomId:server, #alias:server, *",
initialValue: currentAllowlist[0] ? currentAllowlist.join(", ") : undefined,
validate: (value) => {
const entries = splitSetupEntries(String(value ?? ""));
return entries.length > 0 ? undefined : "Required";
},
}),
);
const allowlist = splitSetupEntries(rawAllowlist);
return setMatrixAutoJoin(params.cfg, "allowlist", allowlist, accountId);
}
async function configureMatrixAccessPrompts(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
forceAllowFrom: boolean;
accountId: string;
}): Promise<CoreConfig> {
let next = params.cfg;
if (params.forceAllowFrom) {
next = await promptMatrixAllowFrom({
cfg: next,
prompter: params.prompter,
accountId: params.accountId,
});
}
const existingAccountConfig = resolveMatrixAccountConfig({
cfg: next,
accountId: params.accountId,
});
const existingGroups = existingAccountConfig.groups ?? existingAccountConfig.rooms;
const accessConfig = await promptChannelAccessConfig({
prompter: params.prompter,
label: "Matrix rooms",
currentPolicy: existingAccountConfig.groupPolicy ?? "allowlist",
currentEntries: Object.keys(existingGroups ?? {}),
placeholder: "!roomId:server, #alias:server, Project Room",
updatePrompt: Boolean(existingGroups),
});
if (accessConfig) {
if (accessConfig.policy !== "allowlist") {
next = setMatrixGroupPolicy(next, accessConfig.policy, params.accountId);
} else {
let roomKeys = accessConfig.entries;
if (accessConfig.entries.length > 0) {
try {
const resolvedIds: string[] = [];
const unresolved: string[] = [];
for (const entry of accessConfig.entries) {
const trimmed = entry.trim();
if (!trimmed) {
continue;
}
const cleaned = trimmed.replace(/^(room|channel):/i, "").trim();
if (cleaned.startsWith("!") && cleaned.includes(":")) {
resolvedIds.push(cleaned);
continue;
}
const matches = await listMatrixDirectoryGroupsLive({
cfg: next,
accountId: params.accountId,
query: trimmed,
limit: 10,
});
const exact = matches.find(
(match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(),
);
const best = exact ?? matches[0];
if (best?.id) {
resolvedIds.push(best.id);
} else {
unresolved.push(entry);
}
}
roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)];
if (resolvedIds.length > 0 || unresolved.length > 0) {
await params.prompter.note(
[
resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined,
unresolved.length > 0
? `Unresolved (kept as typed): ${unresolved.join(", ")}`
: undefined,
]
.filter(Boolean)
.join("\n"),
"Matrix rooms",
);
}
} catch (err) {
await params.prompter.note(
`Room lookup failed; keeping entries as typed. ${String(err)}`,
"Matrix rooms",
);
}
}
next = setMatrixGroupPolicy(next, "allowlist", params.accountId);
next = setMatrixGroupRooms(next, roomKeys, params.accountId);
}
}
return await configureMatrixInviteAutoJoin({
cfg: next,
prompter: params.prompter,
accountId: params.accountId,
});
}
const dmPolicy: ChannelSetupDmPolicy = {
label: "Matrix",
channel,
@@ -289,13 +466,12 @@ async function runMatrixConfigure(params: {
});
if (useEnv) {
next = updateMatrixAccountConfig(next, accountId, { enabled: true });
if (params.forceAllowFrom) {
next = await promptMatrixAllowFrom({
cfg: next,
prompter: params.prompter,
accountId,
});
}
next = await configureMatrixAccessPrompts({
cfg: next,
prompter: params.prompter,
forceAllowFrom: params.forceAllowFrom,
accountId,
});
return { cfg: next, accountId };
}
}
@@ -421,84 +597,12 @@ async function runMatrixConfigure(params: {
encryption: enableEncryption,
});
if (params.forceAllowFrom) {
next = await promptMatrixAllowFrom({
cfg: next,
prompter: params.prompter,
accountId,
});
}
const existingAccountConfig = resolveMatrixAccountConfig({ cfg: next, accountId });
const existingGroups = existingAccountConfig.groups ?? existingAccountConfig.rooms;
const accessConfig = await promptChannelAccessConfig({
next = await configureMatrixAccessPrompts({
cfg: next,
prompter: params.prompter,
label: "Matrix rooms",
currentPolicy: existingAccountConfig.groupPolicy ?? "allowlist",
currentEntries: Object.keys(existingGroups ?? {}),
placeholder: "!roomId:server, #alias:server, Project Room",
updatePrompt: Boolean(existingGroups),
forceAllowFrom: params.forceAllowFrom,
accountId,
});
if (accessConfig) {
if (accessConfig.policy !== "allowlist") {
next = setMatrixGroupPolicy(next, accessConfig.policy, accountId);
} else {
let roomKeys = accessConfig.entries;
if (accessConfig.entries.length > 0) {
try {
const resolvedIds: string[] = [];
const unresolved: string[] = [];
for (const entry of accessConfig.entries) {
const trimmed = entry.trim();
if (!trimmed) {
continue;
}
const cleaned = trimmed.replace(/^(room|channel):/i, "").trim();
if (cleaned.startsWith("!") && cleaned.includes(":")) {
resolvedIds.push(cleaned);
continue;
}
const matches = await listMatrixDirectoryGroupsLive({
cfg: next,
accountId,
query: trimmed,
limit: 10,
});
const exact = matches.find(
(match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(),
);
const best = exact ?? matches[0];
if (best?.id) {
resolvedIds.push(best.id);
} else {
unresolved.push(entry);
}
}
roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)];
if (resolvedIds.length > 0 || unresolved.length > 0) {
await params.prompter.note(
[
resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined,
unresolved.length > 0
? `Unresolved (kept as typed): ${unresolved.join(", ")}`
: undefined,
]
.filter(Boolean)
.join("\n"),
"Matrix rooms",
);
}
} catch (err) {
await params.prompter.note(
`Room lookup failed; keeping entries as typed. ${String(err)}`,
"Matrix rooms",
);
}
}
next = setMatrixGroupPolicy(next, "allowlist", accountId);
next = setMatrixGroupRooms(next, roomKeys, accountId);
}
}
return { cfg: next, accountId };
}

View File

@@ -57,6 +57,7 @@ export {
moveSingleAccountChannelSectionToDefaultAccount,
promptAccountId,
promptChannelAccessConfig,
splitSetupEntries,
} from "openclaw/plugin-sdk/setup";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export {