diff --git a/CHANGELOG.md b/CHANGELOG.md index b7eba597089..88a129e1d68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai - Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as `guild=dm`. Thanks @thewilloftheshadow. - TUI: refactor searchable select list description layout and add regression coverage for ANSI-highlight width bounds. - Memory/QMD: cap QMD command output buffering to prevent memory exhaustion from pathological `qmd` command output. +- Memory/QMD: parse qmd scope keys once per request to avoid repeated parsing in scope checks. - Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency. - Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy `stdout`. - Memory/QMD: pass result limits to `search`/`vsearch` commands so QMD can cap results earlier. diff --git a/src/memory/qmd-scope.test.ts b/src/memory/qmd-scope.test.ts new file mode 100644 index 00000000000..8aa1f176f58 --- /dev/null +++ b/src/memory/qmd-scope.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import type { ResolvedQmdConfig } from "./backend-config.js"; +import { deriveQmdScopeChannel, deriveQmdScopeChatType, isQmdScopeAllowed } from "./qmd-scope.js"; + +describe("qmd scope", () => { + const allowDirect: ResolvedQmdConfig["scope"] = { + default: "deny", + rules: [{ action: "allow", match: { chatType: "direct" } }], + }; + + it("derives channel and chat type from canonical keys once", () => { + expect(deriveQmdScopeChannel("Workspace:group:123")).toBe("workspace"); + expect(deriveQmdScopeChatType("Workspace:group:123")).toBe("group"); + }); + + it("derives channel and chat type from stored key suffixes", () => { + expect(deriveQmdScopeChannel("agent:agent-1:workspace:channel:chan-123")).toBe("workspace"); + expect(deriveQmdScopeChatType("agent:agent-1:workspace:channel:chan-123")).toBe("channel"); + }); + + it("treats parsed keys with no chat prefix as direct", () => { + expect(deriveQmdScopeChannel("agent:agent-1:peer-direct")).toBeUndefined(); + expect(deriveQmdScopeChatType("agent:agent-1:peer-direct")).toBe("direct"); + expect(isQmdScopeAllowed(allowDirect, "agent:agent-1:peer-direct")).toBe(true); + expect(isQmdScopeAllowed(allowDirect, "agent:agent-1:peer:group:abc")).toBe(false); + }); + + it("applies scoped key-prefix checks against normalized key", () => { + const scope: ResolvedQmdConfig["scope"] = { + default: "deny", + rules: [{ action: "allow", match: { keyPrefix: "workspace:" } }], + }; + expect(isQmdScopeAllowed(scope, "agent:agent-1:workspace:group:123")).toBe(true); + expect(isQmdScopeAllowed(scope, "agent:agent-1:other:group:123")).toBe(false); + }); +}); diff --git a/src/memory/qmd-scope.ts b/src/memory/qmd-scope.ts index 9fc03abf03e..da1bdcfc0e6 100644 --- a/src/memory/qmd-scope.ts +++ b/src/memory/qmd-scope.ts @@ -1,13 +1,20 @@ import type { ResolvedQmdConfig } from "./backend-config.js"; import { parseAgentSessionKey } from "../sessions/session-key-utils.js"; +type ParsedQmdSessionScope = { + channel?: string; + chatType?: "channel" | "group" | "direct"; + normalizedKey?: string; +}; + export function isQmdScopeAllowed(scope: ResolvedQmdConfig["scope"], sessionKey?: string): boolean { if (!scope) { return true; } - const channel = deriveQmdScopeChannel(sessionKey); - const chatType = deriveQmdScopeChatType(sessionKey); - const normalizedKey = sessionKey ?? ""; + const parsed = parseQmdSessionScope(sessionKey); + const channel = parsed.channel; + const chatType = parsed.chatType; + const normalizedKey = parsed.normalizedKey ?? ""; for (const rule of scope.rules ?? []) { if (!rule) { continue; @@ -29,38 +36,42 @@ export function isQmdScopeAllowed(scope: ResolvedQmdConfig["scope"], sessionKey? } export function deriveQmdScopeChannel(key?: string): string | undefined { - if (!key) { - return undefined; - } + return parseQmdSessionScope(key).channel; +} + +export function deriveQmdScopeChatType(key?: string): "channel" | "group" | "direct" | undefined { + return parseQmdSessionScope(key).chatType; +} + +function parseQmdSessionScope(key?: string): ParsedQmdSessionScope { const normalized = normalizeQmdSessionKey(key); if (!normalized) { - return undefined; + return {}; } const parts = normalized.split(":").filter(Boolean); + let chatType: ParsedQmdSessionScope["chatType"]; if ( parts.length >= 2 && (parts[1] === "group" || parts[1] === "channel" || parts[1] === "direct" || parts[1] === "dm") ) { - return parts[0]?.toLowerCase(); - } - return undefined; -} - -export function deriveQmdScopeChatType(key?: string): "channel" | "group" | "direct" | undefined { - if (!key) { - return undefined; - } - const normalized = normalizeQmdSessionKey(key); - if (!normalized) { - return undefined; + if (parts.includes("group")) { + chatType = "group"; + } else if (parts.includes("channel")) { + chatType = "channel"; + } + return { + normalizedKey: normalized, + channel: parts[0]?.toLowerCase(), + chatType: chatType ?? "direct", + }; } if (normalized.includes(":group:")) { - return "group"; + return { normalizedKey: normalized, chatType: "group" }; } if (normalized.includes(":channel:")) { - return "channel"; + return { normalizedKey: normalized, chatType: "channel" }; } - return "direct"; + return { normalizedKey: normalized, chatType: "direct" }; } function normalizeQmdSessionKey(key: string): string | undefined {