fix(qmd): normalize direct file collection paths (#65212)

* fix(qmd): normalize direct file collection paths

Port fix from PR #65212 to new package location.

When a QMD custom collection path config entry points directly to a file
instead of a directory, normalize into:
- path = parent directory
- pattern = exact filename

This ensures direct file targets are handled correctly regardless of any
user-supplied glob pattern.

Original commit: 3570aa55a7 (fix/flow-runs-legacy-migration)

* fix(qmd): escape direct file collection patterns

* fix(qmd): escape direct file collection masks
This commit is contained in:
Syu
2026-05-23 09:16:53 +09:00
committed by GitHub
parent 58e9628300
commit 227b4bffee
2 changed files with 48 additions and 4 deletions

View File

@@ -194,6 +194,32 @@ describe("resolveMemoryBackendConfig", () => {
expect(custom.path).toBe(path.resolve("/workspace/root", "notes"));
});
it("normalizes direct file qmd paths to escaped exact-file patterns", async () => {
const workspaceDir = await createFixtureDir("direct-file-path");
const notesPath = path.join(workspaceDir, "notes{a,b}[1].md");
await fs.writeFile(notesPath, "# Notes\n", "utf8");
const cfg = {
agents: {
defaults: { workspace: workspaceDir },
list: [{ id: "main", workspace: workspaceDir }],
},
memory: {
backend: "qmd",
qmd: {
paths: [{ path: "notes{a,b}[1].md", name: "direct-note", pattern: "**/*.md" }],
},
},
} as OpenClawConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
const custom = resolved.qmd?.collections.find((c) => c.name.startsWith("direct-note"));
expect(custom).toMatchObject({
path: workspaceDir,
pattern: String.raw`notes\{a,b\}\[1\].md`,
});
});
it("scopes qmd collection names per agent", () => {
const cfg = {
agents: {

View File

@@ -20,6 +20,10 @@ import {
import { isPathInside } from "./fs-utils.js";
import { normalizeLowercaseStringOrEmpty } from "./string-utils.js";
function escapeQmdExactFilePattern(fileName: string): string {
return fileName.replace(/[\\*?[\]{}()!+@]/g, "\\$&");
}
export type ResolvedMemoryBackendConfig = {
backend: MemoryBackend;
citations: MemoryCitationsMode;
@@ -291,26 +295,40 @@ function resolveCustomPaths(
return;
}
let resolved: string;
let collectionPath: string;
try {
resolved = resolvePath(trimmedPath, workspaceDir);
} catch {
return;
}
const pattern = entry.pattern?.trim() || "**/*.md";
const dedupeKey = `${resolved}\u0000${pattern}`;
collectionPath = resolved;
let pattern = entry.pattern?.trim() || "**/*.md";
try {
const stat = fs.statSync(resolved);
if (stat.isFile()) {
// When the configured path points directly to a file, normalize into a
// parent-directory collection with an exact-filename pattern, regardless
// of any user-supplied glob (a glob does not apply to a single file).
collectionPath = path.dirname(resolved);
pattern = escapeQmdExactFilePattern(path.basename(resolved));
}
} catch {
// not a file or can't stat, use as-is
}
const dedupeKey = `${collectionPath}\u0000${pattern}`;
if (seenRoots.has(dedupeKey)) {
return;
}
seenRoots.add(dedupeKey);
const explicitName = entry.name?.trim();
const baseName =
explicitName && !isPathInsideRoot(resolved, workspaceDir)
explicitName && !isPathInsideRoot(collectionPath, workspaceDir)
? explicitName
: scopeCollectionBase(explicitName || `custom-${index + 1}`, agentId);
const name = ensureUniqueName(baseName, existing);
collections.push({
name,
path: resolved,
path: collectionPath,
pattern,
kind: "custom",
});