From 30c0f7e89ff040a41a45b05316f60f30092dfa7e Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Thu, 5 Mar 2026 22:27:25 -0800 Subject: [PATCH] fix(memory): retry mcporter after Windows EINVAL spawn --- CHANGELOG.md | 1 + src/memory/qmd-manager.test.ts | 51 +++++++++++ src/memory/qmd-manager.ts | 151 ++++++++++++++++++++------------- 3 files changed, 144 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c43a94e2..383426803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Memory/QMD mcporter Windows spawn hardening: when `mcporter.cmd` launch fails with `spawn EINVAL`, retry via bare `mcporter` shell resolution so QMD recall can continue instead of falling back to builtin memory search. (#27402) Thanks @i0ivi0i. - Tools/web_search Brave language-code validation: align `search_lang` handling with Brave-supported codes (including `zh-hans`, `zh-hant`, `en-gb`, and `pt-br`), map common alias inputs (`zh`, `ja`) to valid Brave values, and reject unsupported codes before upstream requests to prevent 422 failures. (#37260) Thanks @heyanming. - Models/openai-completions streaming compatibility: force `compat.supportsUsageInStreaming=false` for non-native OpenAI-compatible endpoints during model normalization, preventing usage-only stream chunks from triggering `choices[0]` parser crashes in provider streams. (#8714) Thanks @nonanon1. - Tools/xAI native web-search collision guard: drop OpenClaw `web_search` from tool registration when routing to xAI/Grok model providers (including OpenRouter `x-ai/*`) to avoid duplicate tool-name request failures against provider-native `web_search`. (#14749) Thanks @realsamrat. diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index fa1323cda..603880bbf 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -1624,6 +1624,57 @@ describe("QmdMemoryManager", () => { } }); + it("retries mcporter search with bare command on Windows EINVAL cmd-shim failures", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + mcporter: { enabled: true, serverName: "qmd", startDaemon: false }, + }, + }, + } as OpenClawConfig; + + let sawRetry = false; + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (args[0] === "call" && typeof cmd === "string" && cmd.toLowerCase().endsWith(".cmd")) { + const child = createMockChild({ autoClose: false }); + queueMicrotask(() => { + const err = Object.assign(new Error("spawn EINVAL"), { code: "EINVAL" }); + child.emit("error", err); + }); + return child; + } + if (args[0] === "call" && cmd === "mcporter") { + sawRetry = true; + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", JSON.stringify({ results: [] })); + return child; + } + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", "[]"); + return child; + }); + + const { manager } = await createManager(); + await expect( + manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }), + ).resolves.toEqual([]); + expect(sawRetry).toBe(true); + expect(logWarnMock).toHaveBeenCalledWith( + expect.stringContaining("retrying with bare mcporter"), + ); + await manager.close(); + } finally { + platformSpy.mockRestore(); + } + }); + it("passes manager-scoped XDG env to mcporter commands", async () => { cfg = { ...cfg, diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index fb2deb975..b79a1fc57 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -87,6 +87,17 @@ function resolveSpawnInvocation(params: { return materializeWindowsSpawnProgram(program, params.args); } +function isWindowsCmdSpawnEinval(err: unknown, command: string): boolean { + if (process.platform !== "win32") { + return false; + } + const errno = err as NodeJS.ErrnoException | undefined; + if (errno?.code !== "EINVAL") { + return false; + } + return /(^|[\\/])mcporter\.cmd$/i.test(command); +} + function hasHanScript(value: string): boolean { return HAN_SCRIPT_RE.test(value); } @@ -1330,67 +1341,89 @@ export class QmdMemoryManager implements MemorySearchManager { args: string[], opts?: { timeoutMs?: number }, ): Promise<{ stdout: string; stderr: string }> { - return await new Promise((resolve, reject) => { - const spawnInvocation = resolveSpawnInvocation({ - command: "mcporter", - args, - env: this.env, - packageName: "mcporter", - }); - const child = spawn(spawnInvocation.command, spawnInvocation.argv, { - // Keep mcporter and direct qmd commands on the same agent-scoped XDG state. - env: this.env, - cwd: this.workspaceDir, - shell: spawnInvocation.shell, - windowsHide: spawnInvocation.windowsHide, - }); - let stdout = ""; - let stderr = ""; - let stdoutTruncated = false; - let stderrTruncated = false; - const timer = opts?.timeoutMs - ? setTimeout(() => { - child.kill("SIGKILL"); - reject(new Error(`mcporter ${args.join(" ")} timed out after ${opts.timeoutMs}ms`)); - }, opts.timeoutMs) - : null; - child.stdout.on("data", (data) => { - const next = appendOutputWithCap(stdout, data.toString("utf8"), this.maxQmdOutputChars); - stdout = next.text; - stdoutTruncated = stdoutTruncated || next.truncated; - }); - child.stderr.on("data", (data) => { - const next = appendOutputWithCap(stderr, data.toString("utf8"), this.maxQmdOutputChars); - stderr = next.text; - stderrTruncated = stderrTruncated || next.truncated; - }); - child.on("error", (err) => { - if (timer) { - clearTimeout(timer); - } - reject(err); - }); - child.on("close", (code) => { - if (timer) { - clearTimeout(timer); - } - if (stdoutTruncated || stderrTruncated) { - reject( - new Error( - `mcporter ${args.join(" ")} produced too much output (limit ${this.maxQmdOutputChars} chars)`, - ), - ); - return; - } - if (code === 0) { - resolve({ stdout, stderr }); - } else { - reject( - new Error(`mcporter ${args.join(" ")} failed (code ${code}): ${stderr || stdout}`), - ); - } + const runWithInvocation = async (spawnInvocation: { + command: string; + argv: string[]; + shell?: boolean; + windowsHide?: boolean; + }): Promise<{ stdout: string; stderr: string }> => + await new Promise((resolve, reject) => { + const commandSummary = `${spawnInvocation.command} ${spawnInvocation.argv.join(" ")}`; + const child = spawn(spawnInvocation.command, spawnInvocation.argv, { + // Keep mcporter and direct qmd commands on the same agent-scoped XDG state. + env: this.env, + cwd: this.workspaceDir, + shell: spawnInvocation.shell, + windowsHide: spawnInvocation.windowsHide, + }); + let stdout = ""; + let stderr = ""; + let stdoutTruncated = false; + let stderrTruncated = false; + const timer = opts?.timeoutMs + ? setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`mcporter ${args.join(" ")} timed out after ${opts.timeoutMs}ms`)); + }, opts.timeoutMs) + : null; + child.stdout.on("data", (data) => { + const next = appendOutputWithCap(stdout, data.toString("utf8"), this.maxQmdOutputChars); + stdout = next.text; + stdoutTruncated = stdoutTruncated || next.truncated; + }); + child.stderr.on("data", (data) => { + const next = appendOutputWithCap(stderr, data.toString("utf8"), this.maxQmdOutputChars); + stderr = next.text; + stderrTruncated = stderrTruncated || next.truncated; + }); + child.on("error", (err) => { + if (timer) { + clearTimeout(timer); + } + reject(err); + }); + child.on("close", (code) => { + if (timer) { + clearTimeout(timer); + } + if (stdoutTruncated || stderrTruncated) { + reject( + new Error( + `mcporter ${args.join(" ")} produced too much output (limit ${this.maxQmdOutputChars} chars)`, + ), + ); + return; + } + if (code === 0) { + resolve({ stdout, stderr }); + } else { + reject(new Error(`${commandSummary} failed (code ${code}): ${stderr || stdout}`)); + } + }); }); + + const primaryInvocation = resolveSpawnInvocation({ + command: "mcporter", + args, + env: this.env, + packageName: "mcporter", }); + try { + return await runWithInvocation(primaryInvocation); + } catch (err) { + if (!isWindowsCmdSpawnEinval(err, primaryInvocation.command)) { + throw err; + } + // Some Windows npm cmd shims can still throw EINVAL on spawn; retry through + // shell command resolution so PATH/PATHEXT can select a runnable entrypoint. + log.warn("mcporter.cmd spawn returned EINVAL on Windows; retrying with bare mcporter"); + return await runWithInvocation({ + command: "mcporter", + argv: args, + shell: true, + windowsHide: true, + }); + } } private async runQmdSearchViaMcporter(params: {