Matrix: scope onboarding config to selected account

This commit is contained in:
Gustavo Madeira Santana
2026-03-09 04:56:16 -04:00
parent 979e65f169
commit 5a165afdcc
6 changed files with 314 additions and 80 deletions

View File

@@ -47,4 +47,58 @@ describe("updateMatrixAccountConfig", () => {
enabled: true,
});
});
it("updates nested access config for named accounts without touching top-level defaults", () => {
const cfg = {
channels: {
matrix: {
dm: {
policy: "pairing",
},
groups: {
"!default:example.org": { allow: true },
},
accounts: {
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
dm: {
enabled: true,
policy: "pairing",
},
},
},
},
},
} as CoreConfig;
const updated = updateMatrixAccountConfig(cfg, "ops", {
dm: {
policy: "allowlist",
allowFrom: ["@alice:example.org"],
},
groupPolicy: "allowlist",
groups: {
"!ops-room:example.org": { allow: true },
},
rooms: null,
});
expect(updated.channels?.["matrix"]?.dm?.policy).toBe("pairing");
expect(updated.channels?.["matrix"]?.groups).toEqual({
"!default:example.org": { allow: true },
});
expect(updated.channels?.["matrix"]?.accounts?.ops).toMatchObject({
dm: {
enabled: true,
policy: "allowlist",
allowFrom: ["@alice:example.org"],
},
groupPolicy: "allowlist",
groups: {
"!ops-room:example.org": { allow: true },
},
});
expect(updated.channels?.["matrix"]?.accounts?.ops?.rooms).toBeUndefined();
});
});

View File

@@ -13,6 +13,11 @@ export type MatrixAccountPatch = {
avatarUrl?: string | null;
encryption?: boolean | null;
initialSyncLimit?: number | null;
dm?: MatrixConfig["dm"] | null;
groupPolicy?: MatrixConfig["groupPolicy"] | null;
groupAllowFrom?: MatrixConfig["groupAllowFrom"] | null;
groups?: MatrixConfig["groups"] | null;
rooms?: MatrixConfig["rooms"] | null;
};
function applyNullableStringField(
@@ -35,6 +40,42 @@ function applyNullableStringField(
target[key] = trimmed;
}
function cloneMatrixDmConfig(dm: MatrixConfig["dm"]): MatrixConfig["dm"] {
if (!dm) {
return dm;
}
return {
...dm,
...(dm.allowFrom ? { allowFrom: [...dm.allowFrom] } : {}),
};
}
function cloneMatrixRoomMap(
rooms: MatrixConfig["groups"] | MatrixConfig["rooms"],
): MatrixConfig["groups"] | MatrixConfig["rooms"] {
if (!rooms) {
return rooms;
}
return Object.fromEntries(
Object.entries(rooms).map(([roomId, roomCfg]) => [roomId, roomCfg ? { ...roomCfg } : roomCfg]),
);
}
function applyNullableArrayField(
target: Record<string, unknown>,
key: keyof MatrixAccountPatch,
value: Array<string | number> | null | undefined,
): void {
if (value === undefined) {
return;
}
if (value === null) {
delete target[key];
return;
}
target[key] = [...value];
}
export function shouldStoreMatrixAccountAtTopLevel(cfg: CoreConfig, accountId: string): boolean {
const normalizedAccountId = normalizeAccountId(accountId);
if (normalizedAccountId !== DEFAULT_ACCOUNT_ID) {
@@ -103,6 +144,38 @@ export function updateMatrixAccountConfig(
nextAccount.encryption = patch.encryption;
}
}
if (patch.dm !== undefined) {
if (patch.dm === null) {
delete nextAccount.dm;
} else {
nextAccount.dm = cloneMatrixDmConfig({
...((nextAccount.dm as MatrixConfig["dm"] | undefined) ?? {}),
...patch.dm,
});
}
}
if (patch.groupPolicy !== undefined) {
if (patch.groupPolicy === null) {
delete nextAccount.groupPolicy;
} else {
nextAccount.groupPolicy = patch.groupPolicy;
}
}
applyNullableArrayField(nextAccount, "groupAllowFrom", patch.groupAllowFrom);
if (patch.groups !== undefined) {
if (patch.groups === null) {
delete nextAccount.groups;
} else {
nextAccount.groups = cloneMatrixRoomMap(patch.groups);
}
}
if (patch.rooms !== undefined) {
if (patch.rooms === null) {
delete nextAccount.rooms;
} else {
nextAccount.rooms = cloneMatrixRoomMap(patch.rooms);
}
}
if (shouldStoreMatrixAccountAtTopLevel(cfg, normalizedAccountId)) {
const { accounts: _ignoredAccounts, defaultAccount, ...baseMatrix } = matrix;

View File

@@ -160,4 +160,107 @@ describe("matrix onboarding", () => {
expect(noteText).toContain("MATRIX_<ACCOUNT_ID>_DEVICE_ID");
expect(noteText).toContain("MATRIX_<ACCOUNT_ID>_DEVICE_NAME");
});
it("writes allowlists and room access to the selected Matrix account", async () => {
setMatrixRuntime({
state: {
resolveStateDir: (_env: NodeJS.ProcessEnv, homeDir?: () => string) =>
(homeDir ?? (() => "/tmp"))(),
},
config: {
loadConfig: () => ({}),
},
} as never);
const prompter = {
note: vi.fn(async () => {}),
select: vi.fn(async ({ message }: { message: string }) => {
if (message === "Matrix already configured. What do you want to do?") {
return "add-account";
}
if (message === "Matrix auth method") {
return "token";
}
if (message === "Matrix rooms access") {
return "allowlist";
}
throw new Error(`unexpected select prompt: ${message}`);
}),
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "Matrix account name") {
return "ops";
}
if (message === "Matrix homeserver URL") {
return "https://matrix.ops.example.org";
}
if (message === "Matrix access token") {
return "ops-token";
}
if (message === "Matrix device name (optional)") {
return "Ops Gateway";
}
if (message === "Matrix allowFrom (full @user:server; display name only if unique)") {
return "@alice:example.org";
}
if (message === "Matrix rooms allowlist (comma-separated)") {
return "!ops-room:example.org";
}
throw new Error(`unexpected text prompt: ${message}`);
}),
confirm: vi.fn(async ({ message }: { message: string }) => {
if (message === "Enable end-to-end encryption (E2EE)?") {
return false;
}
if (message === "Configure Matrix rooms access?") {
return true;
}
return false;
}),
} as unknown as WizardPrompter;
const result = await matrixOnboardingAdapter.configureInteractive!({
cfg: {
channels: {
matrix: {
accounts: {
default: {
homeserver: "https://matrix.main.example.org",
accessToken: "main-token",
},
},
},
},
} as CoreConfig,
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv,
prompter,
options: undefined,
accountOverrides: {},
shouldPromptAccountIds: true,
forceAllowFrom: true,
configured: true,
label: "Matrix",
});
expect(result).not.toBe("skip");
if (result === "skip") {
return;
}
expect(result.accountId).toBe("ops");
expect(result.cfg.channels?.["matrix"]?.accounts?.ops).toMatchObject({
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
deviceName: "Ops Gateway",
dm: {
policy: "allowlist",
allowFrom: ["@alice:example.org"],
},
groupPolicy: "allowlist",
groups: {
"!ops-room:example.org": { allow: true },
},
});
expect(result.cfg.channels?.["matrix"]?.dm).toBeUndefined();
expect(result.cfg.channels?.["matrix"]?.groups).toBeUndefined();
});
});

View File

@@ -31,23 +31,26 @@ import type { CoreConfig } from "./types.js";
const channel = "matrix" as const;
function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) {
const allowFrom =
policy === "open" ? addWildcardAllowFrom(cfg.channels?.["matrix"]?.dm?.allowFrom) : undefined;
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...cfg.channels?.["matrix"],
dm: {
...cfg.channels?.["matrix"]?.dm,
policy,
...(allowFrom ? { allowFrom } : {}),
},
},
function resolveMatrixOnboardingAccountId(cfg: CoreConfig, accountId?: string): string {
return normalizeAccountId(
accountId?.trim() || resolveDefaultMatrixAccountId(cfg) || DEFAULT_ACCOUNT_ID,
);
}
function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy, accountId?: string) {
const resolvedAccountId = resolveMatrixOnboardingAccountId(cfg, accountId);
const existing = resolveMatrixAccountConfig({
cfg,
accountId: resolvedAccountId,
});
const allowFrom = policy === "open" ? addWildcardAllowFrom(existing.dm?.allowFrom) : undefined;
return updateMatrixAccountConfig(cfg, resolvedAccountId, {
dm: {
...existing.dm,
policy,
...(allowFrom ? { allowFrom } : {}),
},
};
});
}
async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise<void> {
@@ -70,8 +73,10 @@ async function promptMatrixAllowFrom(params: {
accountId?: string;
}): Promise<CoreConfig> {
const { cfg, prompter } = params;
const existingAllowFrom = cfg.channels?.["matrix"]?.dm?.allowFrom ?? [];
const account = resolveMatrixAccount({ cfg });
const accountId = resolveMatrixOnboardingAccountId(cfg, params.accountId);
const existingConfig = resolveMatrixAccountConfig({ cfg, accountId });
const existingAllowFrom = existingConfig.dm?.allowFrom ?? [];
const account = resolveMatrixAccount({ cfg, accountId });
const canResolve = Boolean(account.configured);
const parseInput = (raw: string) =>
@@ -137,51 +142,32 @@ async function promptMatrixAllowFrom(params: {
}
const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds);
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...cfg.channels?.["matrix"],
enabled: true,
dm: {
...cfg.channels?.["matrix"]?.dm,
policy: "allowlist",
allowFrom: unique,
},
},
return updateMatrixAccountConfig(cfg, accountId, {
dm: {
...existingConfig.dm,
policy: "allowlist",
allowFrom: unique,
},
};
});
}
}
function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") {
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...cfg.channels?.["matrix"],
enabled: true,
groupPolicy,
},
},
};
function setMatrixGroupPolicy(
cfg: CoreConfig,
groupPolicy: "open" | "allowlist" | "disabled",
accountId?: string,
) {
return updateMatrixAccountConfig(cfg, resolveMatrixOnboardingAccountId(cfg, accountId), {
groupPolicy,
});
}
function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) {
function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[], accountId?: string) {
const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }]));
return {
...cfg,
channels: {
...cfg.channels,
matrix: {
...cfg.channels?.["matrix"],
enabled: true,
groups,
},
},
};
return updateMatrixAccountConfig(cfg, resolveMatrixOnboardingAccountId(cfg, accountId), {
groups,
rooms: null,
});
}
const dmPolicy: ChannelOnboardingDmPolicy = {
@@ -189,8 +175,12 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
channel,
policyKey: "channels.matrix.dm.policy",
allowFromKey: "channels.matrix.dm.allowFrom",
getCurrent: (cfg) => (cfg as CoreConfig).channels?.["matrix"]?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy),
getCurrent: (cfg, accountId) =>
resolveMatrixAccountConfig({
cfg: cfg as CoreConfig,
accountId: resolveMatrixOnboardingAccountId(cfg as CoreConfig, accountId),
}).dm?.policy ?? "pairing",
setPolicy: (cfg, policy, accountId) => setMatrixDmPolicy(cfg as CoreConfig, policy, accountId),
promptAllowFrom: promptMatrixAllowFrom,
};
@@ -291,7 +281,11 @@ async function runMatrixConfigure(params: {
if (useEnv) {
next = updateMatrixAccountConfig(next, accountId, { enabled: true });
if (params.forceAllowFrom) {
next = await promptMatrixAllowFrom({ cfg: next, prompter: params.prompter });
next = await promptMatrixAllowFrom({
cfg: next,
prompter: params.prompter,
accountId,
});
}
return { cfg: next, accountId };
}
@@ -399,21 +393,26 @@ async function runMatrixConfigure(params: {
});
if (params.forceAllowFrom) {
next = await promptMatrixAllowFrom({ cfg: next, prompter: params.prompter });
next = await promptMatrixAllowFrom({
cfg: next,
prompter: params.prompter,
accountId,
});
}
const existingGroups = next.channels?.["matrix"]?.groups ?? next.channels?.["matrix"]?.rooms;
const existingAccountConfig = resolveMatrixAccountConfig({ cfg: next, accountId });
const existingGroups = existingAccountConfig.groups ?? existingAccountConfig.rooms;
const accessConfig = await promptChannelAccessConfig({
prompter: params.prompter,
label: "Matrix rooms",
currentPolicy: next.channels?.["matrix"]?.groupPolicy ?? "allowlist",
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);
next = setMatrixGroupPolicy(next, accessConfig.policy, accountId);
} else {
let roomKeys = accessConfig.entries;
if (accessConfig.entries.length > 0) {
@@ -432,6 +431,7 @@ async function runMatrixConfigure(params: {
}
const matches = await listMatrixDirectoryGroupsLive({
cfg: next,
accountId,
query: trimmed,
limit: 10,
});
@@ -466,8 +466,8 @@ async function runMatrixConfigure(params: {
);
}
}
next = setMatrixGroupPolicy(next, "allowlist");
next = setMatrixGroupRooms(next, roomKeys);
next = setMatrixGroupPolicy(next, "allowlist", accountId);
next = setMatrixGroupRooms(next, roomKeys, accountId);
}
}

View File

@@ -75,8 +75,8 @@ export type ChannelOnboardingDmPolicy = {
channel: ChannelId;
policyKey: string;
allowFromKey: string;
getCurrent: (cfg: OpenClawConfig) => DmPolicy;
setPolicy: (cfg: OpenClawConfig, policy: DmPolicy) => OpenClawConfig;
getCurrent: (cfg: OpenClawConfig, accountId?: string) => DmPolicy;
setPolicy: (cfg: OpenClawConfig, policy: DmPolicy, accountId?: string) => OpenClawConfig;
promptAllowFrom?: (params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;

View File

@@ -246,6 +246,7 @@ async function maybeConfigureDmPolicies(params: {
let cfg = params.cfg;
const selectPolicy = async (policy: ChannelOnboardingDmPolicy) => {
const accountId = accountIdsByChannel?.get(policy.channel);
await prompter.note(
[
"Default: pairing (unknown DMs get a pairing code).",
@@ -259,28 +260,31 @@ async function maybeConfigureDmPolicies(params: {
].join("\n"),
`${policy.label} DM access`,
);
return (await prompter.select({
message: `${policy.label} DM policy`,
options: [
{ value: "pairing", label: "Pairing (recommended)" },
{ value: "allowlist", label: "Allowlist (specific users only)" },
{ value: "open", label: "Open (public inbound DMs)" },
{ value: "disabled", label: "Disabled (ignore DMs)" },
],
})) as DmPolicy;
return {
accountId,
nextPolicy: (await prompter.select({
message: `${policy.label} DM policy`,
options: [
{ value: "pairing", label: "Pairing (recommended)" },
{ value: "allowlist", label: "Allowlist (specific users only)" },
{ value: "open", label: "Open (public inbound DMs)" },
{ value: "disabled", label: "Disabled (ignore DMs)" },
],
})) as DmPolicy,
};
};
for (const policy of dmPolicies) {
const current = policy.getCurrent(cfg);
const nextPolicy = await selectPolicy(policy);
const { accountId, nextPolicy } = await selectPolicy(policy);
const current = policy.getCurrent(cfg, accountId);
if (nextPolicy !== current) {
cfg = policy.setPolicy(cfg, nextPolicy);
cfg = policy.setPolicy(cfg, nextPolicy, accountId);
}
if (nextPolicy === "allowlist" && policy.promptAllowFrom) {
cfg = await policy.promptAllowFrom({
cfg,
prompter,
accountId: accountIdsByChannel?.get(policy.channel),
accountId,
});
}
}