fix(memory): warn when qmd binary is missing#57467
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b4e7dae0a8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Greptile SummaryThis PR adds an explicit Key findings:
Confidence Score: 4/5Safe to merge after resolving the cache-ordering regression; all other findings are minor. One P1 finding: the binary probe in
|
| Filename | Overview |
|---|---|
| extensions/memory-core/src/memory/search-manager.ts | Binary availability probe inserted before cache check, causing a subprocess fork on every memory search request even when a manager is already cached — a performance regression on the hot path. |
| packages/memory-host-sdk/src/host/qmd-process.ts | New checkQmdBinaryAvailability helper correctly probes binary via spawn/error/close events with a timeout and settled guard; minor edge-case fragility in the close handler if it fires before error on some platforms. |
| packages/memory-host-sdk/src/host/qmd-process.test.ts | Tests refactored to module-level scope and extended with two new cases covering successful spawn and ENOENT failure paths for checkQmdBinaryAvailability; existing resolveCliSpawnInvocation coverage preserved. |
| packages/memory-host-sdk/src/engine-qmd.ts | Exports checkQmdBinaryAvailability from the public SDK surface alongside the existing resolveCliSpawnInvocation and runCliCommand. |
| src/commands/doctor-memory-search.ts | Doctor command now probes QMD binary availability and emits an actionable warning with install/config recovery steps when the binary is missing; JSDoc comment is now stale. |
| src/commands/doctor-memory-search.test.ts | New test verifies that the doctor note fires with expected content (error string and install command) when checkQmdBinaryAvailability returns unavailable; existing QMD happy-path test updated to supply the qmd config field. |
| extensions/memory-core/src/memory/search-manager.test.ts | New regression test confirms that a missing binary causes immediate builtin fallback without calling QmdMemoryManager.create; mock wiring for checkQmdBinaryAvailability correctly added. |
| CHANGELOG.md | Changelog entry added for the QMD binary-missing warning fix. |
Comments Outside Diff (1)
-
src/commands/doctor-memory-search.ts, line 24-27 (link)Stale JSDoc comment — function now spawns a process
The JSDoc says "config-only, no network calls", but after this PR the function also calls
checkQmdBinaryAvailability, which forks a child process. The comment should be updated to reflect the new behaviour so callers aren't surprised.Prompt To Fix With AI
This is a comment left during a code review. Path: src/commands/doctor-memory-search.ts Line: 24-27 Comment: **Stale JSDoc comment — function now spawns a process** The JSDoc says _"config-only, no network calls"_, but after this PR the function also calls `checkQmdBinaryAvailability`, which forks a child process. The comment should be updated to reflect the new behaviour so callers aren't surprised. How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/memory-core/src/memory/search-manager.ts
Line: 51-65
Comment:
**Binary probe runs before cache check — subprocess spawned on every cache hit**
`checkQmdBinaryAvailability` is called unconditionally at line 51 before the `QMD_MANAGER_CACHE` lookup at line 63. This means every call to `getMemorySearchManager` — including the hot path where a manager is already cached — forks a new `qmd` child process, waits for the OS `spawn` event, and kills it, before ever consulting the cache.
The entire purpose of `QMD_MANAGER_CACHE` was to avoid repeated startup overhead on the critical search path. Before this PR, a cache hit returned in microseconds. Now every cache hit pays a subprocess-fork round-trip (typically 10–50 ms on Linux/macOS) plus the probe's 2-second worst-case timeout if the binary is slow to respond.
The fix is to move the cache check above the probe call so cached managers are returned immediately without any subprocess work, and the probe only runs when a new manager must actually be created.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: packages/memory-host-sdk/src/host/qmd-process.ts
Line: 89-91
Comment:
**`close` handler can race with the `error` path on some platforms**
In Node.js, when a binary cannot be found (ENOENT), the `error` event fires first and then the `close` event fires with `null` for both code and signal. The `settled` guard correctly prevents `finish({ available: true })` from being called a second time, so there is no *correctness* bug in the current code.
However, if the `close` event somehow fires first (theoretically possible in edge-cases of future Node.js versions or non-standard libuv implementations), it would incorrectly report `available: true` for a process that actually failed to start.
A safer pattern is to only treat `close` as success when it comes *after* a confirmed `spawn`:
```typescript
let didSpawn = false;
child.once("spawn", () => {
didSpawn = true;
child.kill();
finish({ available: true });
});
child.once("close", () => {
// Only use close as a success signal if spawn already fired.
if (didSpawn) {
finish({ available: true });
}
});
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/commands/doctor-memory-search.ts
Line: 24-27
Comment:
**Stale JSDoc comment — function now spawns a process**
The JSDoc says _"config-only, no network calls"_, but after this PR the function also calls `checkQmdBinaryAvailability`, which forks a child process. The comment should be updated to reflect the new behaviour so callers aren't surprised.
```suggestion
* Check whether memory search has a usable embedding provider.
* Runs as part of `openclaw doctor` — config-only checks where possible;
* may spawn a short-lived probe process when `memory.backend=qmd` to verify
* the configured `qmd` binary is available.
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "Merge branch 'main' into fix/qmd-binary-..." | Re-trigger Greptile
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9e28672c52
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 57b6f516cf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const qmdCheck = await checkQmdBinaryAvailability({ | ||
| command: backendConfig.qmd?.command ?? "qmd", | ||
| env: process.env, | ||
| }); |
There was a problem hiding this comment.
Probe QMD from agent workspace in doctor checks
The new openclaw doctor probe calls checkQmdBinaryAvailability without a cwd, so relative memory.qmd.command values are resolved against the shell’s current directory instead of the configured agent workspace. In runtime, QMD commands are executed from the agent workspace (see extensions/memory-core/src/memory/search-manager.ts), so a valid config like ./bin/qmd can be reported as “binary missing” when users run doctor outside that workspace, producing a false warning and incorrect remediation guidance.
Useful? React with 👍 / 👎.
🔒 Aisle Security AnalysisWe found 4 potential security issue(s) in this PR:
Vulnerabilities1. 🟠
|
| Property | Value |
|---|---|
| Severity | High |
| CWE | CWE-78 |
| Location | src/commands/doctor-memory-search.ts:57-61 |
Description
noteMemorySearchHealth (run as part of openclaw doctor) spawns a subprocess to probe the QMD backend by executing the configured command:
- Input:
backendConfig.qmd?.commandultimately comes from user/project configuration (memory.qmd.command). - Sink:
checkQmdBinaryAvailabilitycallschild_process.spawn()with that command. - Impact: if an attacker can influence the config in a repo/workspace (or via config precedence), convincing a user/CI/editor integration to run
openclaw doctorbecomes an arbitrary local code execution primitive (run attacker-chosen executable).
Vulnerable code:
const qmdCheck = await checkQmdBinaryAvailability({
command: backendConfig.qmd?.command ?? "qmd",
env: process.env,
});Notes:
memory.qmd.commandis not restricted to an allowlist or absolute path, and the probe executes the command (even though it is short-lived).- The implementation does not appear to use
shell: true(good), and args are not taken from config; however, selecting the executable alone is sufficient for code execution if config is untrusted.
Recommendation
Avoid executing configurable commands during diagnostics, or gate it behind explicit user intent.
Recommended mitigations (pick one or combine):
-
Do not spawn at all in
doctor; instead perform a non-executing check (best):- Require
memory.qmd.commandto be an absolute path and validate it withfs.existsSync()/ access checks, or - Resolve via PATH lookup without executing (platform-specific), then show guidance.
- Require
-
Require explicit opt-in to run probes:
- Add a flag like
openclaw doctor --allow-spawn(default false) and skip probe otherwise.
- Add a flag like
-
Constrain the command:
- Only allow the literal
qmd(or an absolute path), reject anything else.
- Only allow the literal
Example (absolute-path only, no spawn):
import fs from "node:fs";
import path from "node:path";
const cmd = backendConfig.qmd?.command ?? "qmd";
if (!path.isAbsolute(cmd) || !fs.existsSync(cmd)) {
note("QMD is configured but memory.qmd.command must be an existing absolute path; skipping execution in doctor.");
return;
}If you still need to execute, run only a fixed binary name and fixed args, and clearly warn users that doctor may execute local binaries.
2. 🟠 Windows untrusted search path execution when probing/spawning qmd from workspace/current directory
| Property | Value |
|---|---|
| Severity | High |
| CWE | CWE-426 |
| Location | packages/memory-host-sdk/src/host/qmd-process.ts:36-74 |
Description
The QMD availability probe can execute an attacker-controlled binary on Windows due to untrusted search path behavior.
checkQmdBinaryAvailability()spawnsparams.commandwith a caller-controlledcwd(agent workspace) or defaults toprocess.cwd().- When
memory.qmd.commandis configured as a bare name likeqmd(the documented default),resolveWindowsExecutablePath()only resolves to an absolute path if it finds a match in PATH. If not found, it returns the bare name. - On Windows,
CreateProcesssearch order can include the current working directory. That means if a user opens an untrusted repository/workspace and the processcwd(or agent workspace dir) contains a maliciousqmd.exe/qmd.cmd, it may be executed during the probe.
This turns a configuration/health check (and later QMD backend spawning which uses the same resolution path and cwd) into potential arbitrary code execution when operating in attacker-controlled directories.
Vulnerable code:
const child = spawn(spawnInvocation.command, spawnInvocation.argv, {
env: params.env,
cwd: params.cwd ?? process.cwd(),
shell: spawnInvocation.shell,
windowsHide: spawnInvocation.windowsHide,
stdio: "ignore",
});Recommendation
Prevent Windows current-directory binary hijacking by ensuring the spawned program is resolved to an absolute path (or fails) before calling spawn, and avoid using attacker-controlled cwd for binary resolution.
Suggested fix (fail closed if the command cannot be resolved via PATH when it is not an explicit path):
import path from "node:path";
function isExplicitPath(cmd: string) {
return cmd.includes("/") || cmd.includes("\\") || path.isAbsolute(cmd);
}
export async function checkQmdBinaryAvailability(params: { command: string; env: NodeJS.ProcessEnv; cwd?: string; timeoutMs?: number; }) {
const spawnInvocation = resolveCliSpawnInvocation({
command: params.command,
args: [],
env: params.env,
packageName: "qmd",
});
// On Windows, ensure resolution didn't fall back to a bare name.
if (process.platform === "win32" && !isExplicitPath(params.command) && !path.isAbsolute(spawnInvocation.command)) {
return { available: false, error: `qmd not found on PATH: ${params.command}` };
}
// Optionally, use a neutral cwd for the probe.
const safeCwd = process.platform === "win32" ? process.env.SystemRoot ?? process.cwd() : (params.cwd ?? process.cwd());
const child = spawn(spawnInvocation.command, spawnInvocation.argv, {
env: params.env,
cwd: safeCwd,
shell: spawnInvocation.shell,
windowsHide: spawnInvocation.windowsHide,
stdio: "ignore",
});
}Additionally:
- Consider requiring
memory.qmd.commandto be an absolute path on Windows, or explicitly document/validate it. - Apply the same absolute-resolution requirement in the actual QMD execution path (e.g., where
runCliCommandis used) to avoid executing from an untrusted workspace.
3. 🟡 Terminal escape/log injection via unsanitized qmd spawn error messages in user-facing output
| Property | Value |
|---|---|
| Severity | Medium |
| CWE | CWE-150 |
| Location | src/commands/doctor-memory-search.ts:63-67 |
Description
The CLI prints and logs raw error strings originating from process spawn failures without neutralizing control characters.
checkQmdBinaryAvailability()returnserr.messageverbatim (from Node spawn/validation errors).- These messages are interpolated directly into:
note(...)output shown to users duringopenclaw doctor.log.warn(...)messages in the memory search manager.
- If an attacker can influence the configured
memory.qmd.command(e.g., via a shared/malicious config file) or otherwise trigger crafted error text, they may inject terminal control sequences (ANSI escapes) or forge multi-line log entries. - Even without an active attacker, the raw error often includes sensitive local details such as absolute paths, usernames, or platform-specific spawn diagnostics.
Vulnerable code (user-facing note):
qmdCheck.error ? `Probe error: ${qmdCheck.error}` : null,Vulnerable code (logging):
`... falling back to builtin: ${qmdBinary.error ?? "unknown error"}`Recommendation
Neutralize control characters before writing untrusted strings to the terminal/logs, and consider hiding detailed spawn diagnostics unless --verbose is enabled.
Example mitigation:
function sanitizeForTerminal(input: string): string {
// Remove C0/C1 control chars and ESC to prevent ANSI/terminal injection
return input.replace(/[\u0000-\u001F\u007F-\u009F\u001B]/g, "");
}
const safeErr = qmdCheck.error ? sanitizeForTerminal(qmdCheck.error) : undefined;
note(
[
`QMD memory backend is configured, but the qmd binary could not be started (${cmd}).`,
safeErr ? `Probe error: ${safeErr}` : null,
].filter(Boolean).join("\n"),
"Memory search",
);Also apply similar sanitization to log.warn(...) messages that include qmdBinary.error / thrown err.message.
4. 🔵 Availability probe may delay for timeout when child emits close before spawn
| Property | Value |
|---|---|
| Severity | Low |
| CWE | CWE-400 |
| Location | packages/memory-host-sdk/src/host/qmd-process.ts:91-96 |
Description
checkQmdBinaryAvailability can take the full timeout (default 2000ms) to resolve in certain event orderings because the close handler returns early when didSpawn is false.
- The promise is only settled on
spawn,error, or the timeout. - If the
ChildProcessemitsclosewithout a precedingspawn(and without anerror), the handler returns early and does not resolve. - In that case the function will only resolve when the timeout fires, causing an avoidable delay.
- If this probe is invoked frequently (e.g., per agent or per command) and an attacker/user can influence conditions that trigger this event ordering (bad/slow binary path, platform quirks), it can be used to create a controllable slowdown (application-layer DoS).
Vulnerable code:
child.once("close", () => {
if (!didSpawn) {
return;
}
finish({ available: true });
});Recommendation
Ensure the promise always settles on close, and treat close before spawn as an immediate failure (or at least as unavailable) rather than waiting for the timeout.
Example:
child.once("close", (code, signal) => {
if (!didSpawn) {
finish({
available: false,
error: `qmd exited before spawn (code=${code}, signal=${signal ?? ""})`,
});
return;
}
finish({ available: true });
});Additionally, defensively wrap child.kill(...) in try/catch in the timeout handler to avoid unexpected exceptions preventing settlement.
Analyzed PR: #57467 at commit 57b6f51
Last updated on: 2026-03-30T09:55:31Z
* fix(memory): warn when qmd binary is missing * fix(memory): avoid probing cached qmd managers * docs(memory): clarify qmd doctor probe behavior * fix(memory): probe qmd from agent workspace
* fix(memory): warn when qmd binary is missing * fix(memory): avoid probing cached qmd managers * docs(memory): clarify qmd doctor probe behavior * fix(memory): probe qmd from agent workspace
* fix(memory): warn when qmd binary is missing * fix(memory): avoid probing cached qmd managers * docs(memory): clarify qmd doctor probe behavior * fix(memory): probe qmd from agent workspace
Summary
memory.backend=qmdstill silently downgraded to builtin memory when the configuredqmdcommand was missing or not spawnable.openclaw doctorskipped the exact missing-binary diagnosis entirely.doctor, and added regression coverage for both paths.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
Root Cause / Regression History (if applicable)
doctor-memory-searchexplicitly returned early for the QMD backend and never probed command availability.git blame, prior PR, issue, or refactor if known): fix(memory): detect missing qmd binary and warn user #41053 identified the same user pain on older code paths.Regression Test Plan (if applicable)
packages/memory-host-sdk/src/host/qmd-process.test.tsextensions/memory-core/src/memory/search-manager.test.tssrc/commands/doctor-memory-search.test.tsqmdbinary should produce an explicit doctor warning and immediate builtin fallback without attempting QMD manager creation.User-visible / Behavior Changes
openclaw doctornow warns whenmemory.backend=qmdis configured but theqmdbinary cannot be started.Diagram (if applicable)
Security Impact (required)
Yes, explain risk + mitigation:Repro + Verification
Environment
memory.backend=qmd, defaultmemory.qmd.command=qmdSteps
memory.backend=qmdwith no installedqmdbinary.openclaw doctoror trigger memory manager startup.Expected
Actual
Evidence
Human Verification (required)
qmdinstall outside the test harness.Review Conversations
Compatibility / Migration
Risks and Mitigations