Memory/QMD: parse scope once in qmd scope checks

This commit is contained in:
Vignesh Natarajan
2026-02-14 14:58:51 -08:00
parent 0fdcb3be43
commit c0bf6bc24f
3 changed files with 70 additions and 22 deletions

View File

@@ -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.

View File

@@ -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);
});
});

View File

@@ -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 {