diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bf5fe8d8b7..4d16987e1c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,7 @@ Docs: https://docs.openclaw.ai - Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava. - Telegram/setup: warn when setup leaves DMs on pairing without an allowlist, and show valid account-scoped remediation commands. (#50710) Thanks @ernestodeoliveira. - Doctor/Telegram: replace the fresh-install empty group-allowlist false positive with first-run guidance that explains DM pairing approval and the next group setup steps, so new Telegram installs get actionable setup help instead of a broken-config warning. Thanks @vincentkoc. +- Doctor/extensions: keep Matrix DM `allowFrom` repairs on the canonical `dm.allowFrom` path and stop treating Zalouser group sender gating as if it fell back to `allowFrom`, so doctor warnings and `--fix` stay aligned with runtime access control. Thanks @vincentkoc. - Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao. - Channels/plugins: keep shared interactive payloads merge-ready by fixing Slack custom callback routing and repeat-click dedupe, allowing interactive-only sends, and preserving ordered Discord shared text blocks. (#47715) Thanks @vincentkoc. - Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc. diff --git a/src/commands/doctor/shared/allow-from-mode.ts b/src/commands/doctor/shared/allow-from-mode.ts index e865f790b16..994824154ac 100644 --- a/src/commands/doctor/shared/allow-from-mode.ts +++ b/src/commands/doctor/shared/allow-from-mode.ts @@ -1,7 +1,7 @@ export type AllowFromMode = "topOnly" | "topOrNested" | "nestedOnly"; export function resolveAllowFromMode(channelName: string): AllowFromMode { - if (channelName === "googlechat") { + if (channelName === "googlechat" || channelName === "matrix") { return "nestedOnly"; } if (channelName === "discord" || channelName === "slack") { diff --git a/src/commands/doctor/shared/allowlist-policy-repair.test.ts b/src/commands/doctor/shared/allowlist-policy-repair.test.ts new file mode 100644 index 00000000000..aac58e9914d --- /dev/null +++ b/src/commands/doctor/shared/allowlist-policy-repair.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { maybeRepairAllowlistPolicyAllowFrom } from "./allowlist-policy-repair.js"; + +const { readChannelAllowFromStoreMock } = vi.hoisted(() => ({ + readChannelAllowFromStoreMock: vi.fn(), +})); + +vi.mock("../../../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: readChannelAllowFromStoreMock, +})); + +describe("doctor allowlist-policy repair", () => { + beforeEach(() => { + readChannelAllowFromStoreMock.mockReset(); + }); + + it("restores matrix dm allowFrom from the pairing store into the nested path", async () => { + readChannelAllowFromStoreMock.mockResolvedValue(["@alice:example.org"]); + + const result = await maybeRepairAllowlistPolicyAllowFrom({ + channels: { + matrix: { + dm: { + policy: "allowlist", + }, + }, + }, + }); + + expect(result.changes).toEqual([ + '- channels.matrix.dm.allowFrom: restored 1 sender entry from pairing store (dmPolicy="allowlist").', + ]); + expect(result.config.channels?.matrix?.dm?.allowFrom).toEqual(["@alice:example.org"]); + expect(result.config.channels?.matrix?.allowFrom).toBeUndefined(); + }); +}); diff --git a/src/commands/doctor/shared/empty-allowlist-policy.test.ts b/src/commands/doctor/shared/empty-allowlist-policy.test.ts index b9f469d8264..e4eebd46499 100644 --- a/src/commands/doctor/shared/empty-allowlist-policy.test.ts +++ b/src/commands/doctor/shared/empty-allowlist-policy.test.ts @@ -28,6 +28,17 @@ describe("doctor empty allowlist policy warnings", () => { ]); }); + it("stays quiet for zalouser hybrid route-and-sender group access", () => { + const warnings = collectEmptyAllowlistPolicyWarningsForAccount({ + account: { groupPolicy: "allowlist" }, + channelName: "zalouser", + doctorFixCommand: "openclaw doctor --fix", + prefix: "channels.zalouser", + }); + + expect(warnings).toEqual([]); + }); + it("stays quiet for channels that do not use sender-based group allowlists", () => { const warnings = collectEmptyAllowlistPolicyWarningsForAccount({ account: { groupPolicy: "allowlist" }, diff --git a/src/commands/doctor/shared/empty-allowlist-policy.ts b/src/commands/doctor/shared/empty-allowlist-policy.ts index a18e3de975b..e98380c1a60 100644 --- a/src/commands/doctor/shared/empty-allowlist-policy.ts +++ b/src/commands/doctor/shared/empty-allowlist-policy.ts @@ -15,7 +15,12 @@ function usesSenderBasedGroupAllowlist(channelName?: string): boolean { } // These channels enforce group access via channel/space config, not sender-based // groupAllowFrom lists. - return !(channelName === "discord" || channelName === "slack" || channelName === "googlechat"); + return !( + channelName === "discord" || + channelName === "slack" || + channelName === "googlechat" || + channelName === "zalouser" + ); } function allowsGroupAllowFromFallback(channelName?: string): boolean { diff --git a/src/commands/doctor/shared/open-policy-allowfrom.test.ts b/src/commands/doctor/shared/open-policy-allowfrom.test.ts index b7f75dc8b7a..7ae1553545a 100644 --- a/src/commands/doctor/shared/open-policy-allowfrom.test.ts +++ b/src/commands/doctor/shared/open-policy-allowfrom.test.ts @@ -37,6 +37,24 @@ describe("doctor open-policy allowFrom repair", () => { expect(result.config.channels?.googlechat?.dm?.allowFrom).toEqual(["*"]); }); + it("repairs nested-only matrix dm allowFrom", () => { + const result = maybeRepairOpenPolicyAllowFrom({ + channels: { + matrix: { + dm: { + policy: "open", + }, + }, + }, + }); + + expect(result.changes).toEqual([ + '- channels.matrix.dm.allowFrom: set to ["*"] (required by dmPolicy="open")', + ]); + expect(result.config.channels?.matrix?.dm?.allowFrom).toEqual(["*"]); + expect(result.config.channels?.matrix?.allowFrom).toBeUndefined(); + }); + it("appends wildcard to discord nested dm allowFrom when top-level is absent", () => { const result = maybeRepairOpenPolicyAllowFrom({ channels: {