mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-08 21:09:23 +08:00
Memory/QMD: warn when scope denies search
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user