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.
This commit is contained in:
Huvee
2026-07-07 00:14:21 +08:00
committed by GitHub
parent f36d170bc6
commit ddddb3dec1
4 changed files with 119 additions and 2 deletions

View File

@@ -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-",

View File

@@ -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,

View File

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

View File

@@ -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<string>;
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<MemoryPluginPublicArtifact[]> {
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) {