Memory/QMD: warn when scope denies search

This commit is contained in:
Vignesh Natarajan
2026-02-07 19:42:34 -08:00
parent ebe5730401
commit d4f3ea0fdc
4 changed files with 70 additions and 0 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
- Gateway/CLI: when `gateway.bind=lan`, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6.
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, and retry QMD after fallback failures. (#9690, #9705)
- Memory/QMD: log explicit warnings when `memory.qmd.scope` blocks a search request. (#10191)
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
- State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy.
- Tests: harden flaky hotspots by removing timer sleeps, consolidating onboarding provider-auth coverage, and improving memory test realism. (#11598) Thanks @gumadeiras.

View File

@@ -184,6 +184,8 @@ out to QMD for retrieval. Key points:
- `scope`: same schema as [`session.sendPolicy`](/gateway/configuration#session).
Default is DM-only (`deny` all, `allow` direct chats); loosen it to surface QMD
hits in groups/channels.
- When `scope` denies a search, OpenClaw logs a warning with the derived
`channel`/`chatType` so empty results are easier to debug.
- Snippets sourced outside the workspace show up as
`qmd/<collection>/<relative-path>` in `memory_search` results; `memory_get`
understands that prefix and reads from the configured QMD collection root.

View File

@@ -4,6 +4,12 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { logWarnMock, logDebugMock, logInfoMock } = vi.hoisted(() => ({
logWarnMock: vi.fn(),
logDebugMock: vi.fn(),
logInfoMock: vi.fn(),
}));
type MockChild = EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
@@ -38,6 +44,18 @@ function createMockChild(params?: { autoClose?: boolean; closeDelayMs?: number }
return child;
}
vi.mock("../logging/subsystem.js", () => ({
createSubsystemLogger: () => {
const logger = {
warn: logWarnMock,
debug: logDebugMock,
info: logInfoMock,
child: () => logger,
};
return logger;
},
}));
vi.mock("node:child_process", () => ({ spawn: vi.fn() }));
import { spawn as mockedSpawn } from "node:child_process";
@@ -57,6 +75,9 @@ describe("QmdMemoryManager", () => {
beforeEach(async () => {
spawnMock.mockReset();
spawnMock.mockImplementation(() => createMockChild());
logWarnMock.mockReset();
logDebugMock.mockReset();
logInfoMock.mockReset();
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qmd-manager-test-"));
workspaceDir = path.join(tmpRoot, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
@@ -459,6 +480,42 @@ describe("QmdMemoryManager", () => {
await manager.close();
});
it("logs when qmd scope denies search", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
scope: {
default: "deny",
rules: [{ action: "allow", match: { chatType: "direct" } }],
},
},
},
} as OpenClawConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });
expect(manager).toBeTruthy();
if (!manager) {
throw new Error("manager missing");
}
logWarnMock.mockClear();
const beforeCalls = spawnMock.mock.calls.length;
await expect(
manager.search("blocked", { sessionKey: "agent:main:discord:channel:c123" }),
).resolves.toEqual([]);
expect(spawnMock.mock.calls.length).toBe(beforeCalls);
expect(logWarnMock).toHaveBeenCalledWith(expect.stringContaining("qmd search denied by scope"));
expect(logWarnMock).toHaveBeenCalledWith(expect.stringContaining("chatType=channel"));
await manager.close();
});
it("blocks non-markdown or symlink reads for qmd paths", async () => {
const resolved = resolveMemoryBackendConfig({ cfg, agentId });
const manager = await QmdMemoryManager.create({ cfg, agentId, resolved });

View File

@@ -242,6 +242,7 @@ export class QmdMemoryManager implements MemorySearchManager {
opts?: { maxResults?: number; minScore?: number; sessionKey?: string },
): Promise<MemorySearchResult[]> {
if (!this.isScopeAllowed(opts?.sessionKey)) {
this.logScopeDenied(opts?.sessionKey);
return [];
}
const trimmed = query.trim();
@@ -693,6 +694,15 @@ export class QmdMemoryManager implements MemorySearchManager {
return fallback === "allow";
}
private logScopeDenied(sessionKey?: string): void {
const channel = this.deriveChannelFromKey(sessionKey) ?? "unknown";
const chatType = this.deriveChatTypeFromKey(sessionKey) ?? "unknown";
const key = sessionKey?.trim() || "<none>";
log.warn(
`qmd search denied by scope (channel=${channel}, chatType=${chatType}, session=${key})`,
);
}
private deriveChannelFromKey(key?: string) {
if (!key) {
return undefined;