mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:30:44 +00:00
Align QMD memory reads with canonical memory paths (#66026)
* fix(memory): align qmd read paths Co-authored-by: zsx <git@zsxsoft.com> * fix(memory): add qmd exact-path read fast path * fix(memory): tighten qmd read-path guards * changelog: note QMD memory_get canonical-path restriction (#66026) --------- Co-authored-by: zsx <git@zsxsoft.com> Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Video generation/live tests: bound provider polling for live video smoke, default to the fast non-FAL text-to-video path, and use a one-second lobster prompt so release validation no longer waits indefinitely on slow provider queues.
|
||||
- Memory-core/QMD `memory_get`: reject reads of arbitrary workspace markdown paths and only allow canonical memory files (`MEMORY.md`, `memory.md`, `DREAMS.md`, `dreams.md`, `memory/**`) plus exact paths of active indexed QMD workspace documents, so the QMD memory backend can no longer be used as a generic workspace-file read shim that bypasses `read` tool-policy denials. (#66026) Thanks @eleqtrizit.
|
||||
|
||||
## 2026.4.14
|
||||
|
||||
|
||||
@@ -11,17 +11,17 @@ const { logWarnMock, logDebugMock, logInfoMock } = vi.hoisted(() => ({
|
||||
logInfoMock: vi.fn(),
|
||||
}));
|
||||
|
||||
type MockChild = EventEmitter & {
|
||||
interface MockChild extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
kill: (signal?: NodeJS.Signals) => void;
|
||||
closeWith: (code?: number | null) => void;
|
||||
};
|
||||
}
|
||||
|
||||
function createMockChild(params?: { autoClose?: boolean }): MockChild {
|
||||
const stdout = new EventEmitter();
|
||||
const stderr = new EventEmitter();
|
||||
const child = new EventEmitter() as MockChild;
|
||||
const child = new EventEmitter() as unknown as MockChild;
|
||||
child.stdout = stdout;
|
||||
child.stderr = stderr;
|
||||
child.closeWith = (code = 0) => {
|
||||
@@ -123,14 +123,32 @@ describe("QmdMemoryManager slugified path resolution", () => {
|
||||
}) {
|
||||
const inner = params.manager as unknown as {
|
||||
db: {
|
||||
prepare: (query: string) => { all: (...args: unknown[]) => unknown };
|
||||
prepare: (query: string) => {
|
||||
get: (...args: unknown[]) => unknown;
|
||||
all: (...args: unknown[]) => unknown;
|
||||
};
|
||||
close: () => void;
|
||||
};
|
||||
};
|
||||
inner.db = {
|
||||
prepare: (query: string) => ({
|
||||
get: (...args: unknown[]) => {
|
||||
if (query.includes("collection = ? AND active = 1 AND path = ?")) {
|
||||
expect(args[0]).toBe(params.collection);
|
||||
const requestedPath = args[1];
|
||||
expect(typeof requestedPath).toBe("string");
|
||||
const exactCandidates = new Set([
|
||||
...(params.exactPaths ?? []),
|
||||
...(params.actualPath ? [params.actualPath] : []),
|
||||
]);
|
||||
return typeof requestedPath === "string" && exactCandidates.has(requestedPath)
|
||||
? { path: requestedPath }
|
||||
: undefined;
|
||||
}
|
||||
throw new Error(`unexpected sqlite query: ${query}`);
|
||||
},
|
||||
all: (...args: unknown[]) => {
|
||||
if (query.includes("collection = ? AND path = ?")) {
|
||||
if (query.includes("collection = ? AND path = ? AND active = 1")) {
|
||||
expect(args).toEqual([params.collection, params.normalizedPath]);
|
||||
return (params.exactPaths ?? []).map((pathValue) => ({ path: pathValue }));
|
||||
}
|
||||
|
||||
@@ -3559,14 +3559,31 @@ describe("QmdMemoryManager", () => {
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("reads only requested line ranges without loading the whole file", async () => {
|
||||
const readFileSpy = vi.spyOn(fs, "readFile");
|
||||
const text = Array.from({ length: 50 }, (_, index) => `line-${index + 1}`).join("\n");
|
||||
await fs.writeFile(path.join(workspaceDir, "window.md"), text, "utf-8");
|
||||
it("rejects non-memory workspace markdown reads", async () => {
|
||||
await fs.writeFile(path.join(workspaceDir, "window.md"), "secret", "utf-8");
|
||||
await fs.mkdir(path.join(workspaceDir, ".memory"), { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, ".memory", "hidden.md"), "secret", "utf-8");
|
||||
|
||||
const { manager } = await createManager();
|
||||
|
||||
const result = await manager.readFile({ relPath: "window.md", from: 10, lines: 3 });
|
||||
await expect(manager.readFile({ relPath: "window.md" })).rejects.toThrow("path required");
|
||||
await expect(manager.readFile({ relPath: ".memory/hidden.md" })).rejects.toThrow(
|
||||
"path required",
|
||||
);
|
||||
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("reads only requested line ranges from canonical memory files without loading the whole file", async () => {
|
||||
const readFileSpy = vi.spyOn(fs, "readFile");
|
||||
const text = Array.from({ length: 50 }, (_, index) => `line-${index + 1}`).join("\n");
|
||||
const relPath = path.join("memory", "window.md");
|
||||
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, relPath), text, "utf-8");
|
||||
|
||||
const { manager } = await createManager();
|
||||
|
||||
const result = await manager.readFile({ relPath, from: 10, lines: 3 });
|
||||
expect(result.text).toBe("line-10\nline-11\nline-12");
|
||||
expect(readFileSpy).not.toHaveBeenCalled();
|
||||
|
||||
@@ -3575,15 +3592,16 @@ describe("QmdMemoryManager", () => {
|
||||
});
|
||||
|
||||
it("returns empty text when qmd files are missing before or during read", async () => {
|
||||
const relPath = "qmd-window.md";
|
||||
const relPath = path.join("memory", "qmd-window.md");
|
||||
const absPath = path.join(workspaceDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, "one\ntwo\nthree", "utf-8");
|
||||
|
||||
const cases = [
|
||||
{
|
||||
name: "missing before read",
|
||||
request: { relPath: "ghost.md" },
|
||||
expectedPath: "ghost.md",
|
||||
request: { relPath: path.join("memory", "ghost.md") },
|
||||
expectedPath: path.join("memory", "ghost.md"),
|
||||
},
|
||||
{
|
||||
name: "disappears before partial read",
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
resolveStateDir,
|
||||
writeFileWithinRoot,
|
||||
type OpenClawConfig,
|
||||
type ResolvedMemorySearchSyncConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
|
||||
import {
|
||||
buildSessionEntry,
|
||||
@@ -82,6 +81,22 @@ const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([
|
||||
"__pycache__",
|
||||
]);
|
||||
|
||||
function isDefaultMemoryPath(relPath: string): boolean {
|
||||
const normalized = relPath.trim().replace(/^\.\//, "").replace(/\\/g, "/");
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
normalized === "MEMORY.md" ||
|
||||
normalized === "memory.md" ||
|
||||
normalized === "DREAMS.md" ||
|
||||
normalized === "dreams.md"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return normalized.startsWith("memory/");
|
||||
}
|
||||
|
||||
function buildQmdProcessPath(rawPath: string | undefined): string {
|
||||
const nodeBinDir = path.dirname(process.execPath);
|
||||
const entries = rawPath?.split(path.delimiter).filter(Boolean) ?? [];
|
||||
@@ -256,7 +271,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
private readonly xdgCacheHome: string;
|
||||
private readonly indexPath: string;
|
||||
private readonly env: NodeJS.ProcessEnv;
|
||||
private readonly syncSettings: ResolvedMemorySearchSyncConfig | null;
|
||||
private readonly syncSettings: ReturnType<typeof resolveMemorySearchSyncConfig>;
|
||||
private readonly managedCollectionNames: string[];
|
||||
private readonly collectionRoots = new Map<string, CollectionRoot>();
|
||||
private readonly sources = new Set<MemorySource>();
|
||||
@@ -1189,14 +1204,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
if (full.missing) {
|
||||
return { text: "", path: relPath };
|
||||
}
|
||||
if (!params.from && !params.lines) {
|
||||
return { text: full.text, path: relPath };
|
||||
}
|
||||
const lines = full.text.split("\n");
|
||||
const start = Math.max(1, params.from ?? 1);
|
||||
const count = Math.max(1, params.lines ?? lines.length);
|
||||
const slice = lines.slice(start - 1, start - 1 + count);
|
||||
return { text: slice.join("\n"), path: relPath };
|
||||
return { text: full.text, path: relPath };
|
||||
}
|
||||
|
||||
status(): MemoryProviderStatus {
|
||||
@@ -2518,9 +2526,55 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
if (!this.isWithinWorkspace(absPath)) {
|
||||
throw new Error("path escapes workspace");
|
||||
}
|
||||
const workspaceRel = path.relative(this.workspaceDir, absPath).replace(/\\/g, "/");
|
||||
if (!isDefaultMemoryPath(workspaceRel) && !this.isIndexedWorkspaceReadPath(absPath)) {
|
||||
throw new Error("path required");
|
||||
}
|
||||
return absPath;
|
||||
}
|
||||
|
||||
private isIndexedWorkspaceReadPath(absPath: string): boolean {
|
||||
const normalizedAbsPath = path.normalize(absPath);
|
||||
for (const [collection, root] of this.collectionRoots.entries()) {
|
||||
if (!this.isWithinRoot(root.path, normalizedAbsPath)) {
|
||||
continue;
|
||||
}
|
||||
const collectionRelativePath = path
|
||||
.relative(root.path, normalizedAbsPath)
|
||||
.replace(/\\/g, "/");
|
||||
if (!collectionRelativePath || collectionRelativePath.startsWith("..")) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const exactRow = this.ensureDb()
|
||||
.prepare("SELECT path FROM documents WHERE collection = ? AND active = 1 AND path = ?")
|
||||
.get(collection, collectionRelativePath) as { path: string } | undefined;
|
||||
if (
|
||||
exactRow &&
|
||||
path.normalize(path.resolve(root.path, exactRow.path)) === normalizedAbsPath
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const rows = this.ensureDb()
|
||||
.prepare("SELECT path FROM documents WHERE collection = ? AND active = 1")
|
||||
.all(collection) as Array<{ path: string }>;
|
||||
const match = rows.find((row) =>
|
||||
this.matchesPreferredFileHint(row.path, collectionRelativePath),
|
||||
);
|
||||
if (match && path.normalize(path.resolve(root.path, match.path)) === normalizedAbsPath) {
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.isSqliteBusyError(err)) {
|
||||
log.debug(`qmd index is busy while checking read path: ${String(err)}`);
|
||||
throw this.createQmdBusyError(err);
|
||||
}
|
||||
log.debug(`qmd indexed read-path lookup skipped: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private isWithinWorkspace(absPath: string): boolean {
|
||||
const normalizedWorkspace = this.workspaceDir.endsWith(path.sep)
|
||||
? this.workspaceDir
|
||||
|
||||
Reference in New Issue
Block a user