Memory/QMD: cap qmd command output buffering

This commit is contained in:
Vignesh Natarajan
2026-02-14 14:55:59 -08:00
parent 9b9dc65a22
commit f9f816d139
3 changed files with 55 additions and 2 deletions

View File

@@ -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) - 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. - 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. - 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. - Models/CLI: guard `models status` string trimming paths to prevent crashes from malformed non-string config values. (#16395) Thanks @BinHPdev.
## 2026.2.14 ## 2026.2.14

View File

@@ -829,6 +829,30 @@ describe("QmdMemoryManager", () => {
await manager.close(); 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 () => { it("treats plain-text no-results stdout as an empty result set", async () => {
spawnMock.mockImplementation((_cmd: string, args: string[]) => { spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "search") { if (args[0] === "search") {

View File

@@ -30,6 +30,7 @@ const log = createSubsystemLogger("memory");
const SNIPPET_HEADER_RE = /@@\s*-([0-9]+),([0-9]+)/; const SNIPPET_HEADER_RE = /@@\s*-([0-9]+),([0-9]+)/;
const SEARCH_PENDING_UPDATE_WAIT_MS = 500; const SEARCH_PENDING_UPDATE_WAIT_MS = 500;
const MAX_QMD_OUTPUT_CHARS = 200_000;
type CollectionRoot = { type CollectionRoot = {
path: string; path: string;
@@ -74,6 +75,7 @@ export class QmdMemoryManager implements MemorySearchManager {
string, string,
{ rel: string; abs: string; source: MemorySource } { rel: string; abs: string; source: MemorySource }
>(); >();
private readonly maxQmdOutputChars = MAX_QMD_OUTPUT_CHARS;
private readonly sessionExporter: SessionExporterConfig | null; private readonly sessionExporter: SessionExporterConfig | null;
private updateTimer: NodeJS.Timeout | null = null; private updateTimer: NodeJS.Timeout | null = null;
private pendingUpdate: Promise<void> | null = null; private pendingUpdate: Promise<void> | null = null;
@@ -562,6 +564,8 @@ export class QmdMemoryManager implements MemorySearchManager {
}); });
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
let stdoutTruncated = false;
let stderrTruncated = false;
const timer = opts?.timeoutMs const timer = opts?.timeoutMs
? setTimeout(() => { ? setTimeout(() => {
child.kill("SIGKILL"); child.kill("SIGKILL");
@@ -569,10 +573,14 @@ export class QmdMemoryManager implements MemorySearchManager {
}, opts.timeoutMs) }, opts.timeoutMs)
: null; : null;
child.stdout.on("data", (data) => { 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) => { 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) => { child.on("error", (err) => {
if (timer) { if (timer) {
@@ -584,6 +592,14 @@ export class QmdMemoryManager implements MemorySearchManager {
if (timer) { if (timer) {
clearTimeout(timer); clearTimeout(timer);
} }
if (stdoutTruncated || stderrTruncated) {
reject(
new Error(
`qmd ${args.join(" ")} produced too much output (limit ${this.maxQmdOutputChars} chars)`,
),
);
return;
}
if (code === 0) { if (code === 0) {
resolve({ stdout, stderr }); resolve({ stdout, stderr });
} else { } else {
@@ -951,3 +967,15 @@ export class QmdMemoryManager implements MemorySearchManager {
return [command, query, "--json"]; 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 };
}