From ddddb3dec1c67921ba340a3a2da9129253c1dfdb Mon Sep 17 00:00:00 2001 From: Huvee <67144910+huveewomg@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:14:21 +0800 Subject: [PATCH] fix(memory-wiki): drop malformed public artifacts and honor readMemoryArtifacts in status (#100900) Two crashes when a memory plugin misbehaves in bridge mode: - listActiveMemoryPublicArtifacts sorted plugin-returned artifacts without validating them; an artifact missing any of the string fields the comparator dereferences (kind, workspaceDir, relativePath, absolutePath, contentType) crashed wiki status and every other bridge consumer with "Cannot read properties of undefined (reading 'localeCompare')". @mem0/openclaw-mem0 <= 1.0.14 shipped record-shaped artifacts with none of those fields, typed against a drifted SDK stub. Validate the shape, drop malformed entries (and non-array listings), and warn once naming the offending plugin -- the same treatment agentIds already got. - resolveMemoryWikiStatus gated artifact counting on vaultMode/enabled but not bridge.readMemoryArtifacts, so the wiki.status gateway method still enumerated artifacts (and hit the crash above) with the flag off, even though the sync path (bridge.ts) and CLI gateway routing honor it. The documented workaround therefore never worked for the wiki_status agent tool. Add the flag to the gate; the count reports null when imports are disabled, matching non-bridge modes. --- extensions/memory-wiki/src/status.test.ts | 34 +++++++++++++++ extensions/memory-wiki/src/status.ts | 5 ++- src/plugins/memory-state.test.ts | 52 +++++++++++++++++++++++ src/plugins/memory-state.ts | 30 ++++++++++++- 4 files changed, 119 insertions(+), 2 deletions(-) diff --git a/extensions/memory-wiki/src/status.test.ts b/extensions/memory-wiki/src/status.test.ts index bbd2a5b71a4a..8089cb757e45 100644 --- a/extensions/memory-wiki/src/status.test.ts +++ b/extensions/memory-wiki/src/status.test.ts @@ -92,6 +92,40 @@ describe("resolveMemoryWikiStatus", () => { expect(status.warnings.map((warning) => warning.code)).toContain("bridge-artifacts-missing"); }); + it("skips artifact enumeration when readMemoryArtifacts is disabled", async () => { + const config = resolveMemoryWikiConfig( + { + vaultMode: "bridge", + bridge: { + enabled: true, + readMemoryArtifacts: false, + }, + }, + { homedir: "/Users/tester" }, + ); + + let listCalls = 0; + const status = await resolveMemoryWikiStatus(config, { + appConfig: { + agents: { + list: [{ id: "main", default: true, workspace: "/tmp/workspace" }], + }, + } as OpenClawConfig, + listPublicArtifacts: async () => { + listCalls += 1; + return []; + }, + pathExists: async () => true, + resolveCommand: async () => null, + }); + + expect(listCalls).toBe(0); + expect(status.bridgePublicArtifactCount).toBeNull(); + expect(status.warnings.map((warning) => warning.code)).not.toContain( + "bridge-artifacts-missing", + ); + }); + it("discovers pages in nested subdirectories", async () => { const { rootDir, config } = await createVault({ prefix: "memory-wiki-nested-", diff --git a/extensions/memory-wiki/src/status.ts b/extensions/memory-wiki/src/status.ts index 1d3908a28108..ef511eb6c209 100644 --- a/extensions/memory-wiki/src/status.ts +++ b/extensions/memory-wiki/src/status.ts @@ -211,7 +211,10 @@ export async function resolveMemoryWikiStatus( const exists = deps?.pathExists ?? pathExists; const vaultExists = await exists(config.vault.path); const bridgePublicArtifactCount = - deps?.appConfig && config.vaultMode === "bridge" && config.bridge.enabled + deps?.appConfig && + config.vaultMode === "bridge" && + config.bridge.enabled && + config.bridge.readMemoryArtifacts ? ( await (deps.listPublicArtifacts ?? listActiveMemoryPublicArtifacts)({ cfg: deps.appConfig, diff --git a/src/plugins/memory-state.test.ts b/src/plugins/memory-state.test.ts index 5382aa53b9c0..cf3973ed2c30 100644 --- a/src/plugins/memory-state.test.ts +++ b/src/plugins/memory-state.test.ts @@ -234,6 +234,58 @@ describe("memory plugin state", () => { ]); }); + it("drops malformed public memory artifacts instead of crashing the sort", async () => { + // Record-shaped artifact as shipped by @mem0/openclaw-mem0 <= 1.0.14 — + // none of the file-backed fields the sort dereferences. + const recordShapedArtifact = { + id: "mem0:memory:1", + type: "memory", + title: "A memory", + content: "memory text", + } as unknown as MemoryPluginPublicArtifact; + + registerMemoryCapability("openclaw-mem0", { + publicArtifacts: { + async listArtifacts() { + return [ + recordShapedArtifact, + { + kind: "memory-root", + workspaceDir: "/tmp/workspace", + relativePath: "MEMORY.md", + absolutePath: "/tmp/workspace/MEMORY.md", + agentIds: ["main"], + contentType: "markdown" as const, + }, + ]; + }, + }, + }); + + await expect(listActiveMemoryPublicArtifacts({ cfg: {} as never })).resolves.toEqual([ + { + kind: "memory-root", + workspaceDir: "/tmp/workspace", + relativePath: "MEMORY.md", + absolutePath: "/tmp/workspace/MEMORY.md", + agentIds: ["main"], + contentType: "markdown", + }, + ]); + }); + + it("ignores a non-array public artifact listing", async () => { + registerMemoryCapability("openclaw-mem0", { + publicArtifacts: { + async listArtifacts() { + return { artifacts: [] } as unknown as MemoryPluginPublicArtifact[]; + }, + }, + }); + + await expect(listActiveMemoryPublicArtifacts({ cfg: {} as never })).resolves.toEqual([]); + }); + it("preserves sidecar runtime fields when a memory plugin adds public artifacts only", async () => { const runtime = createMemoryRuntime(); const flushPlanResolver = () => createMemoryFlushPlan("memory/sidecar.md"); diff --git a/src/plugins/memory-state.ts b/src/plugins/memory-state.ts index 1c21db827b6c..121c7914f6cb 100644 --- a/src/plugins/memory-state.ts +++ b/src/plugins/memory-state.ts @@ -1,8 +1,11 @@ /** Registry state for plugin memory runtimes, prompt supplements, and flush planning. */ import type { MemoryCitationsMode } from "../config/types.memory.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; import type { MemorySearchManager } from "../memory-host-sdk/host/types.js"; +const log = createSubsystemLogger("plugins/memory-state"); + export type MemoryPromptSectionBuilder = (params: { availableTools: Set; citationsMode?: MemoryCitationsMode; @@ -317,11 +320,36 @@ function cloneMemoryPublicArtifact( }; } +// The sort below dereferences these fields, so a plugin-supplied artifact +// missing any of them would crash every status/bridge consumer. +function isValidMemoryPublicArtifact( + artifact: MemoryPluginPublicArtifact | null | undefined, +): artifact is MemoryPluginPublicArtifact { + return ( + typeof artifact?.kind === "string" && + typeof artifact.workspaceDir === "string" && + typeof artifact.relativePath === "string" && + typeof artifact.absolutePath === "string" && + typeof artifact.contentType === "string" + ); +} + export async function listActiveMemoryPublicArtifacts(params: { cfg: OpenClawConfig; }): Promise { - const artifacts = + const pluginId = memoryPluginState.capability?.pluginId; + const listed = (await memoryPluginState.capability?.capability.publicArtifacts?.listArtifacts(params)) ?? []; + if (!Array.isArray(listed)) { + log.warn(`ignoring public memory artifacts from plugin "${pluginId}": not an array`); + return []; + } + const artifacts = listed.filter(isValidMemoryPublicArtifact); + if (artifacts.length < listed.length) { + log.warn( + `ignoring ${listed.length - artifacts.length} malformed public memory artifact(s) from plugin "${pluginId}": artifacts must include string kind, workspaceDir, relativePath, absolutePath, and contentType`, + ); + } return artifacts.map(cloneMemoryPublicArtifact).toSorted((left, right) => { const workspaceOrder = left.workspaceDir.localeCompare(right.workspaceDir); if (workspaceOrder !== 0) {