From 5a165afdcc92f822626dbadc502e8ccf62aebf9b Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 9 Mar 2026 04:56:16 -0400 Subject: [PATCH] Matrix: scope onboarding config to selected account --- .../matrix/src/matrix/config-update.test.ts | 54 ++++++++ extensions/matrix/src/matrix/config-update.ts | 73 ++++++++++ extensions/matrix/src/onboarding.test.ts | 103 ++++++++++++++ extensions/matrix/src/onboarding.ts | 130 +++++++++--------- src/channels/plugins/onboarding-types.ts | 4 +- src/commands/onboard-channels.ts | 30 ++-- 6 files changed, 314 insertions(+), 80 deletions(-) diff --git a/extensions/matrix/src/matrix/config-update.test.ts b/extensions/matrix/src/matrix/config-update.test.ts index 515eb82c4d6..0852e703413 100644 --- a/extensions/matrix/src/matrix/config-update.test.ts +++ b/extensions/matrix/src/matrix/config-update.test.ts @@ -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(); + }); }); diff --git a/extensions/matrix/src/matrix/config-update.ts b/extensions/matrix/src/matrix/config-update.ts index 3c17e4332df..06bd6f87fe4 100644 --- a/extensions/matrix/src/matrix/config-update.ts +++ b/extensions/matrix/src/matrix/config-update.ts @@ -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, + key: keyof MatrixAccountPatch, + value: Array | 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; diff --git a/extensions/matrix/src/onboarding.test.ts b/extensions/matrix/src/onboarding.test.ts index 6a809616027..a4ce2e55f1a 100644 --- a/extensions/matrix/src/onboarding.test.ts +++ b/extensions/matrix/src/onboarding.test.ts @@ -160,4 +160,107 @@ describe("matrix onboarding", () => { expect(noteText).toContain("MATRIX__DEVICE_ID"); expect(noteText).toContain("MATRIX__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(); + }); }); diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index 2b995307dfa..9fcdecd29a1 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -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 { @@ -70,8 +73,10 @@ async function promptMatrixAllowFrom(params: { accountId?: string; }): Promise { 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); } } diff --git a/src/channels/plugins/onboarding-types.ts b/src/channels/plugins/onboarding-types.ts index 75d1b3a62c9..faf1006203f 100644 --- a/src/channels/plugins/onboarding-types.ts +++ b/src/channels/plugins/onboarding-types.ts @@ -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; diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index 6e79379e1f1..3a154552395 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -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, }); } }