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)
- 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

View File

@ -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") {

View File

@ -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<void> | 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 };
}