diff --git a/CHANGELOG.md b/CHANGELOG.md index 003761246f4..ba6aef977db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,7 @@ Docs: https://docs.openclaw.ai - Memory/Remote HTTP: centralize remote memory HTTP calls behind a shared guarded helper (`withRemoteHttpResponse`) so embeddings and batch flows use one request/release path. - Memory/Embeddings: apply configured remote-base host pinning (`allowedHostnames`) across OpenAI/Voyage/Gemini embedding requests to keep private/self-hosted endpoints working without cross-host drift. (#18198) Thanks @ianpcook. - Memory/Batch: route OpenAI/Voyage/Gemini batch upload/create/status/download requests through the same guarded HTTP path for consistent SSRF policy enforcement. +- Memory/QMD: on Windows, resolve bare `qmd`/`mcporter` command names to npm shim executables (`.cmd`) before spawning, so qmd boot updates and mcporter-backed searches no longer fail with `spawn ... ENOENT` on default npm installs. (#23899) Thanks @arcbuilder-ai. - Install/Discord Voice: make `@discordjs/opus` an optional dependency so `openclaw` install/update no longer hard-fails when native Opus builds fail, while keeping `opusscript` as the runtime fallback decoder for Discord voice flows. (#23737, #23733, #23703) Thanks @jeadland, @Sheetaa, and @Breakyman. - Slack/Upload: resolve bare user IDs (U-prefix) to DM channel IDs via `conversations.open` before calling `files.uploadV2`, which rejects non-channel IDs. `chat.postMessage` tolerates user IDs directly, but `files.uploadV2` → `completeUploadExternal` validates `channel_id` against `^[CGDZ][A-Z0-9]{8,}$`, causing `invalid_arguments` when agents reply with media to DM conversations. - Browser/Relay: treat extension websocket as connected only when `OPEN`, allow reconnect when a stale `CLOSING/CLOSED` extension socket lingers, and guard stale socket message/close handlers so late events cannot clear active relay state; includes regression coverage for live-duplicate `409` rejection and immediate reconnect-after-close races. (#15099, #18698, #20688) diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 6fb36e5fc2e..53ab9bbb733 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -729,6 +729,27 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("uses qmd.cmd on Windows when qmd command is bare", async () => { + const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + try { + const { manager } = await createManager({ mode: "status" }); + await manager.sync({ reason: "manual" }); + + const qmdCalls = spawnMock.mock.calls.filter((call: unknown[]) => { + const args = call[1] as string[] | undefined; + return Array.isArray(args) && args.length > 0; + }); + expect(qmdCalls.length).toBeGreaterThan(0); + for (const call of qmdCalls) { + expect(call[0]).toBe("qmd.cmd"); + } + + await manager.close(); + } finally { + platformSpy.mockRestore(); + } + }); + it("normalizes mixed Han-script BM25 queries before qmd search", async () => { cfg = { ...cfg, @@ -1194,6 +1215,47 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("uses mcporter.cmd on Windows when mcporter bridge is enabled", 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; + + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + const child = createMockChild({ autoClose: false }); + if (args[0] === "call") { + emitAndClose(child, "stdout", JSON.stringify({ results: [] })); + return child; + } + emitAndClose(child, "stdout", "[]"); + return child; + }); + + const { manager } = await createManager(); + await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" }); + + const mcporterCall = spawnMock.mock.calls.find( + (call: unknown[]) => (call[1] as string[] | undefined)?.[0] === "call", + ); + expect(mcporterCall).toBeDefined(); + expect(mcporterCall?.[0]).toBe("mcporter.cmd"); + + 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 bb921522406..388a63080be 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -46,6 +46,25 @@ const QMD_BM25_HAN_KEYWORD_LIMIT = 12; let qmdEmbedQueueTail: Promise = Promise.resolve(); +function resolveWindowsCommandShim(command: string): string { + if (process.platform !== "win32") { + return command; + } + const trimmed = command.trim(); + if (!trimmed) { + return command; + } + const ext = path.extname(trimmed).toLowerCase(); + if (ext === ".cmd" || ext === ".exe" || ext === ".bat") { + return command; + } + const base = path.basename(trimmed).toLowerCase(); + if (base === "qmd" || base === "mcporter") { + return `${trimmed}.cmd`; + } + return command; +} + function hasHanScript(value: string): boolean { return HAN_SCRIPT_RE.test(value); } @@ -943,7 +962,7 @@ export class QmdMemoryManager implements MemorySearchManager { opts?: { timeoutMs?: number }, ): Promise<{ stdout: string; stderr: string }> { return await new Promise((resolve, reject) => { - const child = spawn(this.qmd.command, args, { + const child = spawn(resolveWindowsCommandShim(this.qmd.command), args, { env: this.env, cwd: this.workspaceDir, }); @@ -1034,7 +1053,7 @@ export class QmdMemoryManager implements MemorySearchManager { opts?: { timeoutMs?: number }, ): Promise<{ stdout: string; stderr: string }> { return await new Promise((resolve, reject) => { - const child = spawn("mcporter", args, { + const child = spawn(resolveWindowsCommandShim("mcporter"), args, { // Keep mcporter and direct qmd commands on the same agent-scoped XDG state. env: this.env, cwd: this.workspaceDir,