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. - 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. - 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: 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: 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: 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. - 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 type { ResolvedQmdConfig } from "./backend-config.js";
import { parseAgentSessionKey } from "../sessions/session-key-utils.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 { export function isQmdScopeAllowed(scope: ResolvedQmdConfig["scope"], sessionKey?: string): boolean {
if (!scope) { if (!scope) {
return true; return true;
} }
const channel = deriveQmdScopeChannel(sessionKey); const parsed = parseQmdSessionScope(sessionKey);
const chatType = deriveQmdScopeChatType(sessionKey); const channel = parsed.channel;
const normalizedKey = sessionKey ?? ""; const chatType = parsed.chatType;
const normalizedKey = parsed.normalizedKey ?? "";
for (const rule of scope.rules ?? []) { for (const rule of scope.rules ?? []) {
if (!rule) { if (!rule) {
continue; continue;
@@ -29,38 +36,42 @@ export function isQmdScopeAllowed(scope: ResolvedQmdConfig["scope"], sessionKey?
} }
export function deriveQmdScopeChannel(key?: string): string | undefined { export function deriveQmdScopeChannel(key?: string): string | undefined {
if (!key) { return parseQmdSessionScope(key).channel;
return undefined; }
}
export function deriveQmdScopeChatType(key?: string): "channel" | "group" | "direct" | undefined {
return parseQmdSessionScope(key).chatType;
}
function parseQmdSessionScope(key?: string): ParsedQmdSessionScope {
const normalized = normalizeQmdSessionKey(key); const normalized = normalizeQmdSessionKey(key);
if (!normalized) { if (!normalized) {
return undefined; return {};
} }
const parts = normalized.split(":").filter(Boolean); const parts = normalized.split(":").filter(Boolean);
let chatType: ParsedQmdSessionScope["chatType"];
if ( if (
parts.length >= 2 && parts.length >= 2 &&
(parts[1] === "group" || parts[1] === "channel" || parts[1] === "direct" || parts[1] === "dm") (parts[1] === "group" || parts[1] === "channel" || parts[1] === "direct" || parts[1] === "dm")
) { ) {
return parts[0]?.toLowerCase(); if (parts.includes("group")) {
} chatType = "group";
return undefined; } else if (parts.includes("channel")) {
} chatType = "channel";
}
export function deriveQmdScopeChatType(key?: string): "channel" | "group" | "direct" | undefined { return {
if (!key) { normalizedKey: normalized,
return undefined; channel: parts[0]?.toLowerCase(),
} chatType: chatType ?? "direct",
const normalized = normalizeQmdSessionKey(key); };
if (!normalized) {
return undefined;
} }
if (normalized.includes(":group:")) { if (normalized.includes(":group:")) {
return "group"; return { normalizedKey: normalized, chatType: "group" };
} }
if (normalized.includes(":channel:")) { if (normalized.includes(":channel:")) {
return "channel"; return { normalizedKey: normalized, chatType: "channel" };
} }
return "direct"; return { normalizedKey: normalized, chatType: "direct" };
} }
function normalizeQmdSessionKey(key: string): string | undefined { function normalizeQmdSessionKey(key: string): string | undefined {