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
4 changed files with 145 additions and 1 deletions

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,