Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ Docs: https://docs.openclaw.ai

- Plugins/Discovery precedence: load bundled plugins before auto-discovered global extensions so bundled channel plugins win duplicate-ID resolution by default (explicit `plugins.load.paths` overrides remain highest precedence), with loader regression coverage. Landed from contributor PR #29710 by @Sid-Qin. Thanks @Sid-Qin.
- Discord/Reconnect integrity: release Discord message listener lane immediately while preserving serialized handler execution, add HELLO-stall resume-first recovery with bounded fresh-identify fallback after repeated stalls, and extend lifecycle/listener regression coverage for forced reconnect scenarios. Landed from contributor PR #29508 by @cgdusek. Thanks @cgdusek.
- Matrix/Conduit compatibility: avoid blocking startup on non-resolving Matrix sync start, preserve startup error propagation, prevent duplicate monitor listener registration, remove unreliable 2-member DM heuristics, accept `!room` IDs without alias resolution, and add matrix monitor/client regression coverage. Landed from contributor PR #31023 by @efe-arv. Thanks @efe-arv.
- Security/Skills: harden skill installer metadata parsing by rejecting unsafe installer specs (brew/node/go/uv/download) and constrain plugin-declared skill directories to the plugin root (including symlink-escape checks), with regression coverage.
- Discord/DM command auth: unify DM allowlist + pairing-store authorization across message preflight and native command interactions so DM command gating is consistent for `open`/`pairing`/`allowlist` policies.
- ACP/Harness thread spawn routing: force ACP harness thread creation through `sessions_spawn` (`runtime: "acp"`, `thread: true`) and explicitly forbid `message action=thread-create` for ACP harness requests, avoiding misrouted `Unknown channel` errors. (#30957) Thanks @dutifulbob.
Expand Down
85 changes: 85 additions & 0 deletions extensions/matrix/src/matrix/client/shared.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveSharedMatrixClient, stopSharedClient } from "./shared.js";
import type { MatrixAuth } from "./types.js";

const createMatrixClientMock = vi.hoisted(() => vi.fn());

vi.mock("./create-client.js", () => ({
createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args),
}));

function makeAuth(suffix: string): MatrixAuth {
return {
homeserver: "https://matrix.example.org",
userId: `@bot-${suffix}:example.org`,
accessToken: `token-${suffix}`,
encryption: false,
};
}

function createMockClient(startImpl: () => Promise<void>): MatrixClient {
return {
start: vi.fn(startImpl),
stop: vi.fn(),
getJoinedRooms: vi.fn().mockResolvedValue([]),
crypto: undefined,
} as unknown as MatrixClient;
}

describe("resolveSharedMatrixClient startup behavior", () => {
afterEach(() => {
stopSharedClient();
createMatrixClientMock.mockReset();
vi.useRealTimers();
});

it("propagates the original start error during initialization", async () => {
vi.useFakeTimers();
const startError = new Error("bad token");
const client = createMockClient(
() =>
new Promise<void>((_resolve, reject) => {
setTimeout(() => reject(startError), 1);
}),
);
createMatrixClientMock.mockResolvedValue(client);

const startPromise = resolveSharedMatrixClient({
auth: makeAuth("start-error"),
});
const startExpectation = expect(startPromise).rejects.toBe(startError);

await vi.advanceTimersByTimeAsync(2001);
await startExpectation;
});

it("retries start after a late start-loop failure", async () => {
vi.useFakeTimers();
let rejectFirstStart: ((err: unknown) => void) | undefined;
const firstStart = new Promise<void>((_resolve, reject) => {
rejectFirstStart = reject;
});
const secondStart = new Promise<void>(() => {});
const startMock = vi.fn().mockReturnValueOnce(firstStart).mockReturnValueOnce(secondStart);
const client = createMockClient(startMock);
createMatrixClientMock.mockResolvedValue(client);

const firstResolve = resolveSharedMatrixClient({
auth: makeAuth("late-failure"),
});
await vi.advanceTimersByTimeAsync(2000);
await expect(firstResolve).resolves.toBe(client);
expect(startMock).toHaveBeenCalledTimes(1);

rejectFirstStart?.(new Error("late failure"));
await Promise.resolve();

const secondResolve = resolveSharedMatrixClient({
auth: makeAuth("late-failure"),
});
await vi.advanceTimersByTimeAsync(2000);
await expect(secondResolve).resolves.toBe(client);
expect(startMock).toHaveBeenCalledTimes(2);
});
});
21 changes: 20 additions & 1 deletion extensions/matrix/src/matrix/client/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,26 @@ async function ensureSharedClientStarted(params: {
}
}

await client.start();
// bot-sdk start() returns a promise that never resolves on success
// (infinite sync loop), so we must not await it or startup hangs forever.
// However, it DOES reject on errors (bad token, unreachable homeserver).
// Strategy: race client.start() against a grace timer. If start() rejects
// during or after the window, mark the client as failed so subsequent
// resolveSharedMatrixClient() calls know to retry.
const startPromiseInner = client.start();
let settled = false;
let startError: unknown = undefined;
startPromiseInner.catch((err: unknown) => {
settled = true;
startError = err;
params.state.started = false;
LogService.error("MatrixClientLite", "client.start() error:", err);
});
// Give the sync loop a moment to initialize before marking ready
await new Promise((resolve) => setTimeout(resolve, 2000));
if (settled) {
throw startError;
}
params.state.started = true;
})();
sharedClientStartPromises.set(key, startPromise);
Expand Down
63 changes: 63 additions & 0 deletions extensions/matrix/src/matrix/monitor/direct.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { describe, expect, it, vi } from "vitest";
import { createDirectRoomTracker } from "./direct.js";

function createMockClient(params: {
isDm?: boolean;
senderDirect?: boolean;
selfDirect?: boolean;
members?: string[];
}) {
const members = params.members ?? ["@alice:example.org", "@bot:example.org"];
return {
dms: {
update: vi.fn().mockResolvedValue(undefined),
isDm: vi.fn().mockReturnValue(params.isDm === true),
},
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
getJoinedRoomMembers: vi.fn().mockResolvedValue(members),
getRoomStateEvent: vi
.fn()
.mockImplementation(async (_roomId: string, _event: string, stateKey: string) => {
if (stateKey === "@alice:example.org") {
return { is_direct: params.senderDirect === true };
}
if (stateKey === "@bot:example.org") {
return { is_direct: params.selfDirect === true };
}
return {};
}),
} as unknown as MatrixClient;
}

describe("createDirectRoomTracker", () => {
it("treats m.direct rooms as DMs", async () => {
const tracker = createDirectRoomTracker(createMockClient({ isDm: true }));
await expect(
tracker.isDirectMessage({
roomId: "!room:example.org",
senderId: "@alice:example.org",
}),
).resolves.toBe(true);
});

it("does not classify 2-member rooms as DMs without direct flags", async () => {
const tracker = createDirectRoomTracker(createMockClient({ isDm: false }));
await expect(
tracker.isDirectMessage({
roomId: "!room:example.org",
senderId: "@alice:example.org",
}),
).resolves.toBe(false);
});

it("uses is_direct member flags when present", async () => {
const tracker = createDirectRoomTracker(createMockClient({ senderDirect: true }));
await expect(
tracker.isDirectMessage({
roomId: "!room:example.org",
senderId: "@alice:example.org",
}),
).resolves.toBe(true);
});
});
14 changes: 8 additions & 6 deletions extensions/matrix/src/matrix/monitor/direct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,13 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
const { roomId, senderId } = params;
await refreshDmCache();

// Check m.direct account data (most authoritative)
if (client.dms.isDm(roomId)) {
log(`matrix: dm detected via m.direct room=${roomId}`);
return true;
}

const memberCount = await resolveMemberCount(roomId);
if (memberCount === 2) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

log(`matrix: dm detected via member count room=${roomId} members=${memberCount}`);
return true;
}

// Check m.room.member state for is_direct flag
const selfUserId = params.selfUserId ?? (await ensureSelfUserId());
const directViaState =
(await hasDirectFlag(roomId, senderId)) || (await hasDirectFlag(roomId, selfUserId ?? ""));
Expand All @@ -97,6 +93,12 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
return true;
}

// Member count alone is NOT a reliable DM indicator.
// Explicitly configured group rooms with 2 members (e.g. bot + one user)
// were being misclassified as DMs, causing messages to be routed through
// DM policy instead of group policy and silently dropped.
// See: https://github.com/openclaw/openclaw/issues/20145
const memberCount = await resolveMemberCount(roomId);
log(`matrix: dm check room=${roomId} result=group members=${memberCount ?? "unknown"}`);
return false;
Comment on lines +101 to 103
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep a fallback path for unflagged 1:1 rooms

This change now returns false for any room where both m.direct and is_direct are missing, even when the room only has two members. In Matrix setups where direct metadata is absent (for example, bridged or manually created 1:1 rooms), those DMs are reclassified as group rooms and then dropped by the room allowlist gate in extensions/matrix/src/matrix/monitor/handler.ts (isRoom && groupPolicy === "allowlist"), while Matrix defaults that policy to allowlist via resolveAllowlistProviderRuntimeGroupPolicy in extensions/matrix/src/matrix/monitor/index.ts. A fallback heuristic is still needed to avoid silently blocking DM delivery in that scenario.

Useful? React with πŸ‘Β / πŸ‘Ž.

},
Expand Down
34 changes: 34 additions & 0 deletions extensions/matrix/src/matrix/monitor/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,38 @@ describe("registerMatrixMonitorEvents", () => {
expect(getUserId).toHaveBeenCalledTimes(1);
expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled();
});

it("skips duplicate listener registration for the same client", () => {
const handlers = new Map<string, (...args: unknown[]) => void>();
const onMock = vi.fn((event: string, handler: (...args: unknown[]) => void) => {
handlers.set(event, handler);
});
const client = {
on: onMock,
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
crypto: undefined,
} as unknown as MatrixClient;
const params = {
client,
auth: { encryption: false } as MatrixAuth,
logVerboseMessage: vi.fn(),
warnedEncryptedRooms: new Set<string>(),
warnedCryptoMissingRooms: new Set<string>(),
logger: { warn: vi.fn() } as unknown as RuntimeLogger,
formatNativeDependencyHint: (() =>
"") as PluginRuntime["system"]["formatNativeDependencyHint"],
onRoomMessage: vi.fn(),
};
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});

registerMatrixMonitorEvents(params);
const initialCallCount = onMock.mock.calls.length;
registerMatrixMonitorEvents(params);

expect(onMock).toHaveBeenCalledTimes(initialCallCount);
expect(errorSpy).toHaveBeenCalledWith(
"[matrix] skipping duplicate listener registration for client",
);
errorSpy.mockRestore();
});
});
12 changes: 12 additions & 0 deletions extensions/matrix/src/matrix/monitor/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import { sendReadReceiptMatrix } from "../send.js";
import type { MatrixRawEvent } from "./types.js";
import { EventType } from "./types.js";

// Track which clients have had monitor events registered to prevent
// duplicate listener registration when the plugin loads twice
// (e.g. bundled channel + extension both try to start).
// See: https://github.com/openclaw/openclaw/issues/18330
const registeredClients = new WeakSet<object>();

function createSelfUserIdResolver(client: Pick<MatrixClient, "getUserId">) {
let selfUserId: string | undefined;
let selfUserIdLookup: Promise<string | undefined> | undefined;
Expand Down Expand Up @@ -41,6 +47,12 @@ export function registerMatrixMonitorEvents(params: {
formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"];
onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise<void>;
}): void {
if (registeredClients.has(params.client)) {
console.error("[matrix] skipping duplicate listener registration for client");
return;
}
registeredClients.add(params.client);

const {
client,
auth,
Expand Down
18 changes: 18 additions & 0 deletions extensions/matrix/src/matrix/monitor/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_STARTUP_GRACE_MS, isConfiguredMatrixRoomEntry } from "./index.js";

describe("monitorMatrixProvider helpers", () => {
it("treats !-prefixed room IDs as configured room entries", () => {
expect(isConfiguredMatrixRoomEntry("!abc123")).toBe(true);
expect(isConfiguredMatrixRoomEntry("!RoomMixedCase")).toBe(true);
});

it("requires a homeserver suffix for # aliases", () => {
expect(isConfiguredMatrixRoomEntry("#alias:example.org")).toBe(true);
expect(isConfiguredMatrixRoomEntry("#alias")).toBe(false);
});

it("uses a non-zero startup grace window", () => {
expect(DEFAULT_STARTUP_GRACE_MS).toBe(5000);
});
});
9 changes: 7 additions & 2 deletions extensions/matrix/src/matrix/monitor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export type MonitorMatrixOpts = {
};

const DEFAULT_MEDIA_MAX_MB = 20;
export const DEFAULT_STARTUP_GRACE_MS = 5000;

export function isConfiguredMatrixRoomEntry(entry: string): boolean {
return entry.startsWith("!") || (entry.startsWith("#") && entry.includes(":"));
}

export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promise<void> {
if (isBunRuntime()) {
Expand Down Expand Up @@ -153,7 +158,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
continue;
}
const cleaned = normalizeRoomEntry(trimmed);
if ((cleaned.startsWith("!") || cleaned.startsWith("#")) && cleaned.includes(":")) {
if (isConfiguredMatrixRoomEntry(cleaned)) {
if (!nextRooms[cleaned]) {
nextRooms[cleaned] = roomConfig;
}
Expand Down Expand Up @@ -268,7 +273,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
const mediaMaxMb = opts.mediaMaxMb ?? accountConfig.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
const startupMs = Date.now();
const startupGraceMs = 0;
const startupGraceMs = DEFAULT_STARTUP_GRACE_MS;
const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage });
registerMatrixAutoJoin({ client, cfg, runtime });
const warnedEncryptedRooms = new Set<string>();
Expand Down
Loading