fix(memory): preserve shared qmd collection names (#57628)

* fix(memory): preserve shared qmd collection names

* fix(memory): canonicalize qmd path containment
This commit is contained in:
Vincent Koc 2026-03-30 19:29:35 +09:00 committed by GitHub
parent 85f3136cfc
commit b7de04f23f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 145 additions and 1 deletions

View File

@ -316,6 +316,7 @@ Docs: https://docs.openclaw.ai
- Runtime/install: lower the supported Node 22 floor to `22.14+` while continuing to recommend Node 24, so npm installs and self-updates do not strand Node 22.14 users on older releases.
- CLI/update: preflight the target npm package `engines.node` before `openclaw update` runs a global package install, so outdated Node runtimes fail with a clear upgrade message instead of attempting an unsupported latest release.
- Tests/security audit: isolate audit-test home and personal skill resolution so local `~/.agents/skills` installs no longer make maintainer prep runs fail nondeterministically. (#54473) thanks @huntharo
- Memory/QMD: preserve explicit custom collection names for shared paths outside the agent workspace so `memory_search` stops appending `-<agentId>` to externally managed QMD collections. (#52539) Thanks @lobsrice and @vincentkoc.
## 2026.3.24-beta.1

View File

@ -1747,6 +1747,46 @@ describe("QmdMemoryManager", () => {
await manager.close();
});
it("uses explicit external custom collection names verbatim at query time", async () => {
const sharedMirrorDir = path.join(tmpRoot, "shared-notion-mirror");
await fs.mkdir(sharedMirrorDir);
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [{ path: sharedMirrorDir, pattern: "**/*.md", name: "notion-mirror" }],
},
},
} as OpenClawConfig;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "search") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
return createMockChild();
});
const { manager, resolved } = await createManager();
await manager.search("test", { sessionKey: "agent:main:slack:dm:u123" });
const maxResults = resolved.qmd?.limits.maxResults;
if (!maxResults) {
throw new Error("qmd maxResults missing");
}
const searchCalls = spawnMock.mock.calls
.map((call: unknown[]) => call[1] as string[])
.filter((args: string[]) => args[0] === "search");
expect(searchCalls).toEqual([
["search", "test", "--json", "-n", String(maxResults), "-c", "notion-mirror"],
]);
await manager.close();
});
it("runs qmd query per collection when query mode has multiple collection filters", async () => {
cfg = {
...cfg,

View File

@ -1,3 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveAgentWorkspaceDir } from "../../../../src/agents/agent-scope.js";
@ -111,6 +113,66 @@ describe("resolveMemoryBackendConfig", () => {
expect(devNames.has("workspace-dev")).toBe(true);
});
it("preserves explicit custom collection names for paths outside the workspace", () => {
const cfg = {
agents: {
defaults: { workspace: "/workspace/root" },
list: [
{ id: "main", default: true, workspace: "/workspace/root" },
{ id: "dev", workspace: "/workspace/dev" },
],
},
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: true,
paths: [{ path: "/shared/notion-mirror", name: "notion-mirror", pattern: "**/*.md" }],
},
},
} as OpenClawConfig;
const mainResolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
const devResolved = resolveMemoryBackendConfig({ cfg, agentId: "dev" });
const mainNames = new Set(
(mainResolved.qmd?.collections ?? []).map((collection) => collection.name),
);
const devNames = new Set(
(devResolved.qmd?.collections ?? []).map((collection) => collection.name),
);
expect(mainNames.has("memory-dir-main")).toBe(true);
expect(devNames.has("memory-dir-dev")).toBe(true);
expect(mainNames.has("notion-mirror")).toBe(true);
expect(devNames.has("notion-mirror")).toBe(true);
});
it("keeps symlinked workspace paths agent-scoped when deciding custom collection names", async () => {
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qmd-backend-config-"));
const workspaceDir = path.join(tmpRoot, "workspace");
const workspaceAliasDir = path.join(tmpRoot, "workspace-alias");
try {
await fs.mkdir(workspaceDir, { recursive: true });
await fs.symlink(workspaceDir, workspaceAliasDir);
const cfg = {
agents: {
defaults: { workspace: workspaceDir },
list: [{ id: "main", default: true, workspace: workspaceDir }],
},
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
paths: [{ path: workspaceAliasDir, name: "workspace", pattern: "**/*.md" }],
},
},
} as OpenClawConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
const names = new Set((resolved.qmd?.collections ?? []).map((collection) => collection.name));
expect(names.has("workspace-main")).toBe(true);
expect(names.has("workspace")).toBe(false);
} finally {
await fs.rm(tmpRoot, { recursive: true, force: true });
}
});
it("resolves qmd update timeout overrides", () => {
const cfg = {
agents: { defaults: { workspace: "/tmp/memory-test" } },
@ -283,6 +345,25 @@ describe("memorySearch.extraPaths integration", () => {
expect(paths).toContain(resolveComparablePath("/agent-only"));
});
it("keeps unnamed extra paths agent-scoped even when they resolve outside the workspace", () => {
const cfg = {
memory: { backend: "qmd" },
agents: {
defaults: {
workspace: "/workspace/root",
memorySearch: {
extraPaths: ["/shared/path"],
},
},
},
} as OpenClawConfig;
const result = resolveMemoryBackendConfig({ cfg, agentId: "my-agent" });
const customCollections = (result.qmd?.collections ?? []).filter(
(collection) => collection.kind === "custom",
);
expect(customCollections.map((collection) => collection.name)).toContain("custom-1-my-agent");
});
it("matches per-agent memorySearch.extraPaths using normalized agent ids", () => {
const cfg = {
memory: { backend: "qmd" },

View File

@ -1,3 +1,4 @@
import fs from "node:fs";
import path from "node:path";
import { resolveAgentWorkspaceDir } from "../../../../src/agents/agent-scope.js";
import { parseDurationMs } from "../../../../src/cli/parse-duration.js";
@ -115,6 +116,23 @@ function scopeCollectionBase(base: string, agentId: string): string {
return `${base}-${sanitizeName(agentId)}`;
}
function canonicalizePathForContainment(rawPath: string): string {
const resolved = path.resolve(rawPath);
try {
return path.normalize(fs.realpathSync.native(resolved));
} catch {
return path.normalize(resolved);
}
}
function isPathInsideRoot(candidatePath: string, rootPath: string): boolean {
const relative = path.relative(
canonicalizePathForContainment(rootPath),
canonicalizePathForContainment(candidatePath),
);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function ensureUniqueName(base: string, existing: Set<string>): string {
let name = sanitizeName(base);
if (!existing.has(name)) {
@ -252,7 +270,11 @@ function resolveCustomPaths(
return;
}
seenRoots.add(dedupeKey);
const baseName = scopeCollectionBase(entry.name?.trim() || `custom-${index + 1}`, agentId);
const explicitName = entry.name?.trim();
const baseName =
explicitName && !isPathInsideRoot(resolved, workspaceDir)
? explicitName
: scopeCollectionBase(explicitName || `custom-${index + 1}`, agentId);
const name = ensureUniqueName(baseName, existing);
collections.push({
name,