From ec4198954ab291f4db2c1fc10c3963f91a6f196d Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Thu, 19 Feb 2026 22:21:17 -0800 Subject: [PATCH] Memory: harden readFile ENOENT handling --- src/agents/tools/memory-tool.e2e.test.ts | 15 +++-- src/memory/manager.read-file.test.ts | 77 ++++++++++++++++++++++++ src/memory/manager.ts | 54 +++++++++++------ 3 files changed, 121 insertions(+), 25 deletions(-) create mode 100644 src/memory/manager.read-file.test.ts diff --git a/src/agents/tools/memory-tool.e2e.test.ts b/src/agents/tools/memory-tool.e2e.test.ts index b5dc52b769a..3566ab98f90 100644 --- a/src/agents/tools/memory-tool.e2e.test.ts +++ b/src/agents/tools/memory-tool.e2e.test.ts @@ -12,11 +12,16 @@ let searchImpl: () => Promise = async () => [ source: "memory" as const, }, ]; -let readFileImpl: () => Promise = async () => ""; +type MemoryReadParams = { relPath: string; from?: number; lines?: number }; +type MemoryReadResult = { text: string; path: string }; +let readFileImpl: (params: MemoryReadParams) => Promise = async (params) => ({ + text: "", + path: params.relPath, +}); const stubManager = { search: vi.fn(async () => await searchImpl()), - readFile: vi.fn(async () => await readFileImpl()), + readFile: vi.fn(async (params: MemoryReadParams) => await readFileImpl(params)), status: () => ({ backend, files: 1, @@ -59,7 +64,7 @@ beforeEach(() => { source: "memory" as const, }, ]; - readFileImpl = async () => ""; // default: return empty string + readFileImpl = async (params: MemoryReadParams) => ({ text: "", path: params.relPath }); vi.clearAllMocks(); }); @@ -170,7 +175,7 @@ describe("memory tools", () => { }); it("does not throw when memory_get fails", async () => { - readFileImpl = async () => { + readFileImpl = async (_params: MemoryReadParams) => { throw new Error("path required"); }; @@ -191,7 +196,7 @@ describe("memory tools", () => { }); it("returns empty text without error when file does not exist (ENOENT)", async () => { - readFileImpl = async () => { + readFileImpl = async (_params: MemoryReadParams) => { return { text: "", path: "memory/2026-02-19.md" }; }; diff --git a/src/memory/manager.read-file.test.ts b/src/memory/manager.read-file.test.ts new file mode 100644 index 00000000000..03e6649680b --- /dev/null +++ b/src/memory/manager.read-file.test.ts @@ -0,0 +1,77 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { MemoryIndexManager } from "./index.js"; +import { resetEmbeddingMocks } from "./embedding.test-mocks.js"; +import { getRequiredMemoryIndexManager } from "./test-manager-helpers.js"; + +function createMemorySearchCfg(options: { + workspaceDir: string; + indexPath: string; +}): OpenClawConfig { + return { + agents: { + defaults: { + workspace: options.workspaceDir, + memorySearch: { + provider: "openai", + model: "mock-embed", + store: { path: options.indexPath, vector: { enabled: false } }, + cache: { enabled: false }, + query: { minScore: 0, hybrid: { enabled: false } }, + sync: { watch: false, onSessionStart: false, onSearch: false }, + }, + }, + list: [{ id: "main", default: true }], + }, + } as OpenClawConfig; +} + +describe("MemoryIndexManager.readFile", () => { + let workspaceDir: string; + let indexPath: string; + let manager: MemoryIndexManager | null = null; + + beforeEach(async () => { + resetEmbeddingMocks(); + workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-read-")); + indexPath = path.join(workspaceDir, "index.sqlite"); + await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true }); + }); + + afterEach(async () => { + if (manager) { + await manager.close(); + manager = null; + } + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + it("returns empty text when the requested file does not exist", async () => { + manager = await getRequiredMemoryIndexManager({ + cfg: createMemorySearchCfg({ workspaceDir, indexPath }), + agentId: "main", + }); + + const relPath = "memory/2099-01-01.md"; + const result = await manager.readFile({ relPath }); + expect(result).toEqual({ text: "", path: relPath }); + }); + + it("returns content slices when the file exists", async () => { + const relPath = "memory/2026-02-20.md"; + const absPath = path.join(workspaceDir, relPath); + await fs.mkdir(path.dirname(absPath), { recursive: true }); + await fs.writeFile(absPath, ["line 1", "line 2", "line 3"].join("\n"), "utf-8"); + + manager = await getRequiredMemoryIndexManager({ + cfg: createMemorySearchCfg({ workspaceDir, indexPath }), + agentId: "main", + }); + + const result = await manager.readFile({ relPath, from: 2, lines: 1 }); + expect(result).toEqual({ text: "line 2", path: relPath }); + }); +}); diff --git a/src/memory/manager.ts b/src/memory/manager.ts index dc159f2afc5..1424a7144e2 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -1,11 +1,20 @@ -import fs from "node:fs/promises"; -import path from "node:path"; +import type { Stats } from "node:fs"; import type { DatabaseSync } from "node:sqlite"; import { type FSWatcher } from "chokidar"; -import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; +import fs from "node:fs/promises"; +import path from "node:path"; import type { ResolvedMemorySearchConfig } from "../agents/memory-search.js"; -import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import type { OpenClawConfig } from "../config/config.js"; +import type { + MemoryEmbeddingProbeResult, + MemoryProviderStatus, + MemorySearchManager, + MemorySearchResult, + MemorySource, + MemorySyncProgressUpdate, +} from "./types.js"; +import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; +import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { createEmbeddingProvider, @@ -20,14 +29,6 @@ import { isMemoryPath, normalizeExtraMemoryPaths } from "./internal.js"; import { MemoryManagerEmbeddingOps } from "./manager-embedding-ops.js"; import { searchKeyword, searchVector } from "./manager-search.js"; import { extractKeywords } from "./query-expansion.js"; -import type { - MemoryEmbeddingProbeResult, - MemoryProviderStatus, - MemorySearchManager, - MemorySearchResult, - MemorySource, - MemorySyncProgressUpdate, -} from "./types.js"; const SNIPPET_MAX_CHARS = 700; const VECTOR_TABLE = "chunks_vec"; const FTS_TABLE = "chunks_fts"; @@ -36,6 +37,15 @@ const BATCH_FAILURE_LIMIT = 2; const log = createSubsystemLogger("memory"); +function isFileMissingError(err: unknown): err is NodeJS.ErrnoException & { code: "ENOENT" } { + return Boolean( + err && + typeof err === "object" && + "code" in err && + (err as Partial).code === "ENOENT", + ); +} + const INDEX_CACHE = new Map(); export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements MemorySearchManager { @@ -437,15 +447,11 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem if (!absPath.endsWith(".md")) { throw new Error("path required"); } - let stat; + let stat: Stats; try { stat = await fs.lstat(absPath); - } catch (err: unknown) { - if ( - err instanceof Error && - "code" in err && - (err as NodeJS.ErrnoException).code === "ENOENT" - ) { + } catch (err) { + if (isFileMissingError(err)) { return { text: "", path: relPath }; } throw err; @@ -453,7 +459,15 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem if (stat.isSymbolicLink() || !stat.isFile()) { throw new Error("path required"); } - const content = await fs.readFile(absPath, "utf-8"); + let content: string; + try { + content = await fs.readFile(absPath, "utf-8"); + } catch (err) { + if (isFileMissingError(err)) { + return { text: "", path: relPath }; + } + throw err; + } if (!params.from && !params.lines) { return { text: content, path: relPath }; }