diff --git a/CHANGELOG.md b/CHANGELOG.md index 5015c07d07c..5367cde6f9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ Docs: https://docs.openclaw.ai - Cron: repair missing/corrupt `nextRunAtMs` for the updated job without globally recomputing unrelated due jobs during `cron update`. (#15750) - Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as `guild=dm`. Thanks @thewilloftheshadow. - TUI: refactor searchable select list description layout and add regression coverage for ANSI-highlight width bounds. +- Memory/QMD: cap QMD command output buffering to prevent memory exhaustion from pathological `qmd` command output. - Models/CLI: guard `models status` string trimming paths to prevent crashes from malformed non-string config values. (#16395) Thanks @BinHPdev. ## 2026.2.14 diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index e8b78a586e6..3fef9fed37c 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -829,6 +829,30 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("errors when qmd output exceeds command output safety cap", async () => { + const noisyPayload = "x".repeat(240_000); + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "search") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", noisyPayload); + return child; + } + return createMockChild(); + }); + + const resolved = resolveMemoryBackendConfig({ cfg, agentId }); + const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); + expect(manager).toBeTruthy(); + if (!manager) { + throw new Error("manager missing"); + } + + await expect( + manager.search("noise", { sessionKey: "agent:main:slack:dm:u123" }), + ).rejects.toThrow(/too much output/); + await manager.close(); + }); + it("treats plain-text no-results stdout as an empty result set", async () => { spawnMock.mockImplementation((_cmd: string, args: string[]) => { if (args[0] === "search") { diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 86015bf50ab..46764d2337f 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -30,6 +30,7 @@ const log = createSubsystemLogger("memory"); const SNIPPET_HEADER_RE = /@@\s*-([0-9]+),([0-9]+)/; const SEARCH_PENDING_UPDATE_WAIT_MS = 500; +const MAX_QMD_OUTPUT_CHARS = 200_000; type CollectionRoot = { path: string; @@ -74,6 +75,7 @@ export class QmdMemoryManager implements MemorySearchManager { string, { rel: string; abs: string; source: MemorySource } >(); + private readonly maxQmdOutputChars = MAX_QMD_OUTPUT_CHARS; private readonly sessionExporter: SessionExporterConfig | null; private updateTimer: NodeJS.Timeout | null = null; private pendingUpdate: Promise | null = null; @@ -562,6 +564,8 @@ export class QmdMemoryManager implements MemorySearchManager { }); let stdout = ""; let stderr = ""; + let stdoutTruncated = false; + let stderrTruncated = false; const timer = opts?.timeoutMs ? setTimeout(() => { child.kill("SIGKILL"); @@ -569,10 +573,14 @@ export class QmdMemoryManager implements MemorySearchManager { }, opts.timeoutMs) : null; child.stdout.on("data", (data) => { - stdout += data.toString(); + const next = appendOutputWithCap(stdout, data.toString("utf8"), this.maxQmdOutputChars); + stdout = next.text; + stdoutTruncated = stdoutTruncated || next.truncated; }); child.stderr.on("data", (data) => { - stderr += data.toString(); + const next = appendOutputWithCap(stderr, data.toString("utf8"), this.maxQmdOutputChars); + stderr = next.text; + stderrTruncated = stderrTruncated || next.truncated; }); child.on("error", (err) => { if (timer) { @@ -584,6 +592,14 @@ export class QmdMemoryManager implements MemorySearchManager { if (timer) { clearTimeout(timer); } + if (stdoutTruncated || stderrTruncated) { + reject( + new Error( + `qmd ${args.join(" ")} produced too much output (limit ${this.maxQmdOutputChars} chars)`, + ), + ); + return; + } if (code === 0) { resolve({ stdout, stderr }); } else { @@ -951,3 +967,15 @@ export class QmdMemoryManager implements MemorySearchManager { return [command, query, "--json"]; } } + +function appendOutputWithCap( + current: string, + chunk: string, + maxChars: number, +): { text: string; truncated: boolean } { + const appended = current + chunk; + if (appended.length <= maxChars) { + return { text: appended, truncated: false }; + } + return { text: appended.slice(-maxChars), truncated: true }; +}