mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 13:41:12 +00:00
* refactor: migrate runtime JSONL state to SQLite * chore: refresh SQLite migration validation * fix: preserve safe audit migration order * fix: sanitize retired audit archives safely * fix: satisfy SQLite storage architecture gates * fix: serialize plugin doctor state migrations * fix: bound memory event SQLite projections * fix: rotate memory event projections safely * fix: preserve imported event retention age * fix: harden memory event export projection * fix: canonicalize memory event migrations * fix: harden legacy journal migrations * fix: report unsafe legacy journal paths * fix: preserve journal ownership during migration * fix: keep legacy file imports doctor-only * fix: align SQLite audit CI coverage * refactor: keep state helpers module-private * fix: harden legacy state migration recovery * fix: reconcile SQLite state audit migration * test: use neutral audit redaction fixtures * fix: close SQLite migration recovery gaps * refactor: split audit migration recovery helpers * test: prove completed audit pads stay out of backups * fix: preserve audit record ordinals across blank lines * fix: align SQLite audit CI contracts * build: package SQLite audit E2E entries * fix: preserve audit ordinals in backup snapshots
1002 lines
38 KiB
TypeScript
1002 lines
38 KiB
TypeScript
/**
|
|
* Tests memory host core public artifact discovery and workspace handling.
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
createPluginStateKeyedStore,
|
|
resetPluginStateStoreForTests,
|
|
} from "../plugin-state/plugin-state-store.js";
|
|
import {
|
|
clearMemoryPluginState,
|
|
registerMemoryCapability,
|
|
registerMemoryPromptSection,
|
|
} from "../plugins/memory-state.test-fixtures.js";
|
|
import * as memoryCoreAlias from "./memory-core.js";
|
|
import {
|
|
buildActiveMemoryPromptSection,
|
|
listMemoryHostPublicArtifacts,
|
|
listActiveMemoryPublicArtifacts,
|
|
} from "./memory-host-core.js";
|
|
import { appendMemoryHostEvent } from "./memory-host-events.js";
|
|
|
|
describe("memory-host-core helpers", () => {
|
|
afterEach(() => {
|
|
clearMemoryPluginState();
|
|
resetPluginStateStoreForTests();
|
|
vi.unstubAllEnvs();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("exposes the active memory prompt guidance builder for context engines", () => {
|
|
registerMemoryPromptSection(({ citationsMode }) => [
|
|
"## Memory Recall",
|
|
`citations=${citationsMode ?? "default"}`,
|
|
"",
|
|
]);
|
|
|
|
expect(
|
|
buildActiveMemoryPromptSection({
|
|
availableTools: new Set(["memory_search"]),
|
|
citationsMode: "off",
|
|
}),
|
|
).toEqual(["## Memory Recall", "citations=off", ""]);
|
|
});
|
|
|
|
it("exposes active memory public artifacts for companion plugins", async () => {
|
|
registerMemoryCapability("memory-core", {
|
|
publicArtifacts: {
|
|
async listArtifacts() {
|
|
return [
|
|
{
|
|
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("propagates workspace inspection failures", async () => {
|
|
vi.spyOn(fs, "stat").mockRejectedValueOnce(
|
|
Object.assign(new Error("permission denied"), { code: "EACCES" }),
|
|
);
|
|
|
|
await expect(
|
|
listMemoryHostPublicArtifacts({
|
|
cfg: {
|
|
agents: {
|
|
list: [{ id: "main", default: true, workspace: "/protected/workspace" }],
|
|
},
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({ code: "EACCES" });
|
|
});
|
|
|
|
it("lists readable workspaces without requiring workspace-root writes", async () => {
|
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-host-readonly-workspace-"));
|
|
const workspaceDir = path.join(fixtureRoot, "workspace");
|
|
try {
|
|
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(fixtureRoot, "state"));
|
|
await fs.mkdir(workspaceDir);
|
|
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# Read-only memory\n", "utf8");
|
|
await appendMemoryHostEvent(workspaceDir, {
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:00:00.000Z",
|
|
query: "read-only",
|
|
resultCount: 0,
|
|
results: [],
|
|
});
|
|
await fs.chmod(workspaceDir, 0o500);
|
|
|
|
await expect(
|
|
listMemoryHostPublicArtifacts({
|
|
cfg: {
|
|
agents: {
|
|
list: [{ id: "main", default: true, workspace: workspaceDir }],
|
|
},
|
|
},
|
|
}),
|
|
).resolves.toMatchObject([
|
|
{
|
|
kind: "memory-root",
|
|
workspaceDir,
|
|
relativePath: "MEMORY.md",
|
|
},
|
|
]);
|
|
} finally {
|
|
await fs.chmod(workspaceDir, 0o700).catch(() => undefined);
|
|
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"propagates failures reading an existing event export",
|
|
async () => {
|
|
const fixtureRoot = await fs.mkdtemp(
|
|
path.join(os.tmpdir(), "memory-host-unreadable-export-"),
|
|
);
|
|
const workspaceDir = path.join(fixtureRoot, "workspace");
|
|
let eventExportPath: string | undefined;
|
|
try {
|
|
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(fixtureRoot, "state"));
|
|
await fs.mkdir(workspaceDir);
|
|
await appendMemoryHostEvent(workspaceDir, {
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:00:00.000Z",
|
|
query: "unreadable export",
|
|
resultCount: 0,
|
|
results: [],
|
|
});
|
|
const firstListing = await listMemoryHostPublicArtifacts({
|
|
cfg: {
|
|
agents: {
|
|
list: [{ id: "main", default: true, workspace: workspaceDir }],
|
|
},
|
|
},
|
|
});
|
|
eventExportPath = firstListing.find(
|
|
(artifact) => artifact.kind === "event-log",
|
|
)?.absolutePath;
|
|
if (!eventExportPath) {
|
|
throw new Error("expected memory event export");
|
|
}
|
|
await fs.chmod(eventExportPath, 0o000);
|
|
|
|
await expect(
|
|
listMemoryHostPublicArtifacts({
|
|
cfg: {
|
|
agents: {
|
|
list: [{ id: "main", default: true, workspace: workspaceDir }],
|
|
},
|
|
},
|
|
}),
|
|
).rejects.toThrow();
|
|
} finally {
|
|
if (eventExportPath) {
|
|
await fs.chmod(eventExportPath, 0o600).catch(() => undefined);
|
|
}
|
|
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"does not delete an event export through a symlinked parent",
|
|
async () => {
|
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-host-export-symlink-"));
|
|
const workspaceDir = path.join(fixtureRoot, "workspace");
|
|
const externalMemoryDir = path.join(fixtureRoot, "external-memory");
|
|
const stateDir = path.join(fixtureRoot, "state");
|
|
try {
|
|
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
|
await fs.mkdir(workspaceDir);
|
|
await fs.mkdir(stateDir);
|
|
const stateHash = createHash("sha256")
|
|
.update(await fs.realpath(stateDir))
|
|
.digest("hex")
|
|
.slice(0, 32);
|
|
const externalExport = path.join(
|
|
externalMemoryDir,
|
|
"events",
|
|
stateHash,
|
|
"memory-host-events.jsonl",
|
|
);
|
|
const externalOwner = path.join(
|
|
path.dirname(externalExport),
|
|
".openclaw-memory-host-events-owner.json",
|
|
);
|
|
await fs.mkdir(path.dirname(externalExport), { recursive: true });
|
|
await fs.writeFile(externalExport, '{"type":"external"}\n', "utf8");
|
|
await fs.writeFile(externalOwner, '{"kind":"external"}\n', "utf8");
|
|
await fs.symlink(externalMemoryDir, path.join(workspaceDir, "memory"));
|
|
|
|
await expect(
|
|
listMemoryHostPublicArtifacts({
|
|
cfg: {
|
|
agents: {
|
|
list: [{ id: "main", default: true, workspace: workspaceDir }],
|
|
},
|
|
},
|
|
}),
|
|
).resolves.toEqual([]);
|
|
await expect(fs.readFile(externalExport, "utf8")).resolves.toBe('{"type":"external"}\n');
|
|
await expect(fs.readFile(externalOwner, "utf8")).resolves.toBe('{"kind":"external"}\n');
|
|
} finally {
|
|
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it("does not replace or delete an unowned event export path", async () => {
|
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-host-unowned-export-"));
|
|
const workspaceDir = path.join(fixtureRoot, "workspace");
|
|
try {
|
|
vi.stubEnv("OPENCLAW_STATE_DIR", fixtureRoot);
|
|
await fs.mkdir(workspaceDir);
|
|
await appendMemoryHostEvent(workspaceDir, {
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:00:00.000Z",
|
|
query: "must not replace user file",
|
|
resultCount: 0,
|
|
results: [],
|
|
});
|
|
const stateHash = createHash("sha256")
|
|
.update(await fs.realpath(fixtureRoot))
|
|
.digest("hex")
|
|
.slice(0, 32);
|
|
const exportPath = path.join(
|
|
workspaceDir,
|
|
"memory",
|
|
"events",
|
|
stateHash,
|
|
"memory-host-events.jsonl",
|
|
);
|
|
await fs.mkdir(path.dirname(exportPath), { recursive: true });
|
|
await fs.writeFile(exportPath, '{"owner":"user"}\n', "utf8");
|
|
|
|
const listed = await listMemoryHostPublicArtifacts({
|
|
cfg: { agents: { list: [{ id: "main", default: true, workspace: workspaceDir }] } },
|
|
});
|
|
expect(listed.some((artifact) => artifact.kind === "event-log")).toBe(false);
|
|
await expect(fs.readFile(exportPath, "utf8")).resolves.toBe('{"owner":"user"}\n');
|
|
|
|
await createPluginStateKeyedStore("memory-core", {
|
|
namespace: "memory-host.events",
|
|
maxEntries: 10_000,
|
|
env: { ...process.env, OPENCLAW_STATE_DIR: fixtureRoot },
|
|
}).clear();
|
|
await listMemoryHostPublicArtifacts({
|
|
cfg: { agents: { list: [{ id: "main", default: true, workspace: workspaceDir }] } },
|
|
});
|
|
await expect(fs.readFile(exportPath, "utf8")).resolves.toBe('{"owner":"user"}\n');
|
|
} finally {
|
|
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not let an orphaned owner marker authorize a later workspace file", async () => {
|
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-host-orphan-owner-"));
|
|
const stateDir = path.join(fixtureRoot, "state");
|
|
const workspaceDir = path.join(fixtureRoot, "workspace");
|
|
try {
|
|
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
|
await fs.mkdir(workspaceDir);
|
|
await appendMemoryHostEvent(workspaceDir, {
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:00:00.000Z",
|
|
query: "expected export",
|
|
resultCount: 0,
|
|
results: [],
|
|
});
|
|
const stateHash = createHash("sha256")
|
|
.update(await fs.realpath(stateDir))
|
|
.digest("hex")
|
|
.slice(0, 32);
|
|
const workspaceHash = createHash("sha256")
|
|
.update(await fs.realpath(workspaceDir))
|
|
.digest("hex")
|
|
.slice(0, 32);
|
|
const exportDir = path.join(workspaceDir, "memory", "events", stateHash);
|
|
const exportPath = path.join(exportDir, "memory-host-events.jsonl");
|
|
const ownerPath = path.join(exportDir, ".openclaw-memory-host-events-owner.json");
|
|
const expectedContent = `${JSON.stringify({
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:00:00.000Z",
|
|
query: "expected export",
|
|
resultCount: 0,
|
|
results: [],
|
|
})}\n`;
|
|
await fs.mkdir(exportDir, { recursive: true });
|
|
await fs.writeFile(
|
|
ownerPath,
|
|
`${JSON.stringify({
|
|
schemaVersion: 3,
|
|
kind: "openclaw-memory-host-events-export",
|
|
stateHash,
|
|
workspaceHash,
|
|
pendingContentSha256: createHash("sha256").update(expectedContent).digest("hex"),
|
|
})}\n`,
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(exportPath, '{"owner":"user after crash"}\n', "utf8");
|
|
|
|
const listed = await listMemoryHostPublicArtifacts({
|
|
cfg: { agents: { list: [{ id: "main", default: true, workspace: workspaceDir }] } },
|
|
});
|
|
|
|
expect(listed.some((artifact) => artifact.kind === "event-log")).toBe(false);
|
|
await expect(fs.readFile(exportPath, "utf8")).resolves.toBe('{"owner":"user after crash"}\n');
|
|
} finally {
|
|
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not claim a same-content inode that replaces an exclusive export", async () => {
|
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-host-create-replace-"));
|
|
const workspaceDir = path.join(fixtureRoot, "workspace");
|
|
const cfg = {
|
|
agents: { list: [{ id: "main", default: true, workspace: workspaceDir }] },
|
|
};
|
|
const event = {
|
|
type: "memory.recall.recorded" as const,
|
|
timestamp: "2026-05-18T12:00:00.000Z",
|
|
query: "exclusive create",
|
|
resultCount: 0,
|
|
results: [],
|
|
};
|
|
const expectedContent = `${JSON.stringify(event)}\n`;
|
|
const originalOpen = fs.open.bind(fs);
|
|
let exportOpenCount = 0;
|
|
try {
|
|
vi.stubEnv("OPENCLAW_STATE_DIR", fixtureRoot);
|
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
await appendMemoryHostEvent(workspaceDir, event);
|
|
const stateHash = createHash("sha256")
|
|
.update(await fs.realpath(fixtureRoot))
|
|
.digest("hex")
|
|
.slice(0, 32);
|
|
const exportPath = path.join(
|
|
workspaceDir,
|
|
"memory",
|
|
"events",
|
|
stateHash,
|
|
"memory-host-events.jsonl",
|
|
);
|
|
vi.spyOn(fs, "open").mockImplementation(async (...args: Parameters<typeof fs.open>) => {
|
|
const target = args[0];
|
|
if (typeof target === "string" && path.resolve(target) === path.resolve(exportPath)) {
|
|
exportOpenCount += 1;
|
|
if (exportOpenCount === 4) {
|
|
await fs.rename(exportPath, `${exportPath}.openclaw-created`);
|
|
const replacement = await originalOpen(exportPath, "wx", 0o600);
|
|
try {
|
|
await replacement.writeFile(expectedContent, "utf8");
|
|
} finally {
|
|
await replacement.close();
|
|
}
|
|
}
|
|
}
|
|
return await originalOpen(...args);
|
|
});
|
|
|
|
const racedArtifacts = await listMemoryHostPublicArtifacts({ cfg });
|
|
expect(racedArtifacts.some((artifact) => artifact.kind === "event-log")).toBe(false);
|
|
await expect(fs.readFile(exportPath, "utf8")).resolves.toBe(expectedContent);
|
|
|
|
vi.restoreAllMocks();
|
|
await appendMemoryHostEvent(workspaceDir, { ...event, query: "must stay foreign" });
|
|
const retriedArtifacts = await listMemoryHostPublicArtifacts({ cfg });
|
|
expect(retriedArtifacts.some((artifact) => artifact.kind === "event-log")).toBe(false);
|
|
await expect(fs.readFile(exportPath, "utf8")).resolves.toBe(expectedContent);
|
|
} finally {
|
|
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("does not claim a hash-only pending event export after interruption", async () => {
|
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-host-pending-owner-"));
|
|
const stateDir = path.join(fixtureRoot, "state");
|
|
const workspaceDir = path.join(fixtureRoot, "workspace");
|
|
const firstEvent = {
|
|
type: "memory.recall.recorded" as const,
|
|
timestamp: "2026-05-18T12:00:00.000Z",
|
|
query: "pending export",
|
|
resultCount: 0,
|
|
results: [],
|
|
};
|
|
try {
|
|
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
|
await fs.mkdir(workspaceDir);
|
|
await appendMemoryHostEvent(workspaceDir, firstEvent);
|
|
const stateHash = createHash("sha256")
|
|
.update(await fs.realpath(stateDir))
|
|
.digest("hex")
|
|
.slice(0, 32);
|
|
const workspaceHash = createHash("sha256")
|
|
.update(await fs.realpath(workspaceDir))
|
|
.digest("hex")
|
|
.slice(0, 32);
|
|
const exportDir = path.join(workspaceDir, "memory", "events", stateHash);
|
|
const exportPath = path.join(exportDir, "memory-host-events.jsonl");
|
|
const ownerPath = path.join(exportDir, ".openclaw-memory-host-events-owner.json");
|
|
const pendingContent = `${JSON.stringify(firstEvent)}\n`;
|
|
await fs.mkdir(exportDir, { recursive: true });
|
|
await fs.writeFile(exportPath, pendingContent, "utf8");
|
|
await fs.writeFile(
|
|
ownerPath,
|
|
`${JSON.stringify({
|
|
schemaVersion: 3,
|
|
kind: "openclaw-memory-host-events-export",
|
|
stateHash,
|
|
workspaceHash,
|
|
pendingContentSha256: createHash("sha256").update(pendingContent).digest("hex"),
|
|
})}\n`,
|
|
"utf8",
|
|
);
|
|
await appendMemoryHostEvent(workspaceDir, {
|
|
...firstEvent,
|
|
timestamp: "2026-05-18T12:01:00.000Z",
|
|
query: "after interruption",
|
|
});
|
|
|
|
const listed = await listMemoryHostPublicArtifacts({
|
|
cfg: { agents: { list: [{ id: "main", default: true, workspace: workspaceDir }] } },
|
|
});
|
|
|
|
expect(listed.some((artifact) => artifact.kind === "event-log")).toBe(false);
|
|
await expect(fs.readFile(exportPath, "utf8")).resolves.toBe(pendingContent);
|
|
const owner = JSON.parse(await fs.readFile(ownerPath, "utf8")) as {
|
|
contentSha256?: string;
|
|
fileDev?: string;
|
|
fileIno?: string;
|
|
pendingContentSha256?: string;
|
|
};
|
|
expect(owner.pendingContentSha256).toBe(
|
|
createHash("sha256")
|
|
.update(await fs.readFile(exportPath))
|
|
.digest("hex"),
|
|
);
|
|
expect(owner.contentSha256).toBeUndefined();
|
|
expect(owner.fileDev).toBeUndefined();
|
|
expect(owner.fileIno).toBeUndefined();
|
|
} finally {
|
|
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("omits the event export when a workspace path component is a file", async () => {
|
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-host-blocked-export-"));
|
|
const workspaceDir = path.join(fixtureRoot, "workspace");
|
|
try {
|
|
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(fixtureRoot, "state"));
|
|
await fs.mkdir(workspaceDir);
|
|
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# Still visible\n", "utf8");
|
|
await fs.writeFile(path.join(workspaceDir, "memory"), "user file\n", "utf8");
|
|
await appendMemoryHostEvent(workspaceDir, {
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:00:00.000Z",
|
|
query: "blocked export",
|
|
resultCount: 0,
|
|
results: [],
|
|
});
|
|
|
|
await expect(
|
|
listMemoryHostPublicArtifacts({
|
|
cfg: { agents: { list: [{ id: "main", default: true, workspace: workspaceDir }] } },
|
|
}),
|
|
).resolves.toEqual([
|
|
expect.objectContaining({ kind: "memory-root", relativePath: "MEMORY.md" }),
|
|
]);
|
|
await expect(fs.readFile(path.join(workspaceDir, "memory"), "utf8")).resolves.toBe(
|
|
"user file\n",
|
|
);
|
|
} finally {
|
|
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("lists shared public artifacts from memory workspaces", async () => {
|
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-host-public-artifacts-"));
|
|
try {
|
|
vi.stubEnv("OPENCLAW_STATE_DIR", fixtureRoot);
|
|
const workspaceDir = path.join(fixtureRoot, "workspace");
|
|
await fs.mkdir(path.join(workspaceDir, "memory", "dreaming"), { recursive: true });
|
|
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# Durable Memory\n", "utf8");
|
|
await fs.writeFile(
|
|
path.join(workspaceDir, "memory", "2026-05-18.md"),
|
|
"# Daily Note\n",
|
|
"utf8",
|
|
);
|
|
await fs.writeFile(
|
|
path.join(workspaceDir, "memory", "dreaming", "2026-05-18.md"),
|
|
"# Dream Report\n",
|
|
"utf8",
|
|
);
|
|
const eventStoredAt = Date.parse("2026-05-19T09:30:00.000Z");
|
|
vi.spyOn(Date, "now").mockReturnValue(eventStoredAt);
|
|
await appendMemoryHostEvent(workspaceDir, {
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:00:00.000Z",
|
|
query: "bridge",
|
|
resultCount: 0,
|
|
results: [],
|
|
});
|
|
|
|
const artifacts = await listMemoryHostPublicArtifacts({
|
|
cfg: {
|
|
agents: {
|
|
list: [{ id: "main", default: true, workspace: workspaceDir }],
|
|
},
|
|
},
|
|
});
|
|
const eventArtifact = artifacts.find((artifact) => artifact.kind === "event-log");
|
|
if (!eventArtifact) {
|
|
throw new Error("expected memory event export");
|
|
}
|
|
expect(artifacts.filter((artifact) => artifact.kind !== "event-log")).toEqual([
|
|
{
|
|
kind: "memory-root",
|
|
workspaceDir,
|
|
relativePath: "MEMORY.md",
|
|
absolutePath: path.join(workspaceDir, "MEMORY.md"),
|
|
agentIds: ["main"],
|
|
contentType: "markdown",
|
|
},
|
|
{
|
|
kind: "daily-note",
|
|
workspaceDir,
|
|
relativePath: "memory/2026-05-18.md",
|
|
absolutePath: path.join(workspaceDir, "memory", "2026-05-18.md"),
|
|
agentIds: ["main"],
|
|
contentType: "markdown",
|
|
},
|
|
{
|
|
kind: "dream-report",
|
|
workspaceDir,
|
|
relativePath: "memory/dreaming/2026-05-18.md",
|
|
absolutePath: path.join(workspaceDir, "memory", "dreaming", "2026-05-18.md"),
|
|
agentIds: ["main"],
|
|
contentType: "markdown",
|
|
},
|
|
]);
|
|
expect(eventArtifact).toMatchObject({
|
|
kind: "event-log",
|
|
workspaceDir,
|
|
agentIds: ["main"],
|
|
contentType: "json",
|
|
});
|
|
expect(eventArtifact.relativePath).toMatch(
|
|
/^memory\/events\/[a-f0-9]{32}\/memory-host-events\.jsonl$/u,
|
|
);
|
|
expect(eventArtifact.absolutePath).toBe(
|
|
path.join(workspaceDir, ...eventArtifact.relativePath.split("/")),
|
|
);
|
|
const eventExportPath = eventArtifact.absolutePath;
|
|
await expect(fs.readFile(eventExportPath, "utf8")).resolves.toBe(
|
|
`${JSON.stringify({
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:00:00.000Z",
|
|
query: "bridge",
|
|
resultCount: 0,
|
|
results: [],
|
|
})}\n`,
|
|
);
|
|
const exportStat = await fs.stat(eventExportPath);
|
|
const exportOwner = JSON.parse(
|
|
await fs.readFile(
|
|
path.join(path.dirname(eventExportPath), ".openclaw-memory-host-events-owner.json"),
|
|
"utf8",
|
|
),
|
|
) as { fileDev?: string; fileIno?: string };
|
|
expect(exportOwner.fileDev).toBe(String(exportStat.dev));
|
|
expect(exportOwner.fileIno).toBe(String(exportStat.ino));
|
|
await expect(
|
|
fs.access(path.join(workspaceDir, ".memory-host-events-export.lock")),
|
|
).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(
|
|
(await fs.readdir(fixtureRoot)).some((entry) =>
|
|
entry.startsWith(".memory-host-events-export-"),
|
|
),
|
|
).toBe(false);
|
|
|
|
await createPluginStateKeyedStore("memory-core", {
|
|
namespace: "memory-host.events",
|
|
maxEntries: 10_000,
|
|
env: { ...process.env, OPENCLAW_STATE_DIR: fixtureRoot },
|
|
}).clear();
|
|
const afterRetention = await listMemoryHostPublicArtifacts({
|
|
cfg: {
|
|
agents: {
|
|
list: [{ id: "main", default: true, workspace: workspaceDir }],
|
|
},
|
|
},
|
|
});
|
|
expect(afterRetention.some((artifact) => artifact.kind === "event-log")).toBe(false);
|
|
await expect(fs.readFile(eventExportPath, "utf8")).resolves.toBe("");
|
|
} finally {
|
|
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"does not let a workspace alias overwrite a newer event export",
|
|
async () => {
|
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-host-export-race-"));
|
|
const workspaceDir = path.join(fixtureRoot, "workspace");
|
|
const workspaceAlias = path.join(fixtureRoot, "workspace-alias");
|
|
const cfg = {
|
|
agents: { list: [{ id: "main", default: true, workspace: workspaceDir }] },
|
|
};
|
|
const aliasCfg = {
|
|
agents: { list: [{ id: "main", default: true, workspace: workspaceAlias }] },
|
|
};
|
|
const originalOpen = fs.open.bind(fs);
|
|
let releaseFirstRead: (() => void) | undefined;
|
|
let signalFirstRead: (() => void) | undefined;
|
|
let shouldBlockFirstRead = true;
|
|
const firstReadStarted = new Promise<void>((resolve) => {
|
|
signalFirstRead = resolve;
|
|
});
|
|
const openSpy = vi
|
|
.spyOn(fs, "open")
|
|
.mockImplementation(async (...args: Parameters<typeof fs.open>) => {
|
|
const target = args[0];
|
|
if (
|
|
shouldBlockFirstRead &&
|
|
typeof target === "string" &&
|
|
path.basename(target) === "memory-host-events.jsonl" &&
|
|
path.resolve(target).startsWith(`${path.resolve(workspaceDir)}${path.sep}`)
|
|
) {
|
|
shouldBlockFirstRead = false;
|
|
signalFirstRead?.();
|
|
await new Promise<void>((resolve) => {
|
|
releaseFirstRead = resolve;
|
|
});
|
|
}
|
|
return await originalOpen(...args);
|
|
});
|
|
|
|
try {
|
|
vi.stubEnv("OPENCLAW_STATE_DIR", fixtureRoot);
|
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
await fs.symlink(workspaceDir, workspaceAlias);
|
|
await appendMemoryHostEvent(workspaceAlias, {
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:00:00.000Z",
|
|
query: "older",
|
|
resultCount: 0,
|
|
results: [],
|
|
});
|
|
const olderListing = listMemoryHostPublicArtifacts({ cfg });
|
|
await firstReadStarted;
|
|
|
|
await appendMemoryHostEvent(workspaceDir, {
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:01:00.000Z",
|
|
query: "newer",
|
|
resultCount: 0,
|
|
results: [],
|
|
});
|
|
const newerListing = listMemoryHostPublicArtifacts({ cfg: aliasCfg });
|
|
await Promise.race([
|
|
newerListing,
|
|
new Promise<void>((resolve) => {
|
|
setTimeout(resolve, 250);
|
|
}),
|
|
]);
|
|
releaseFirstRead?.();
|
|
const [olderArtifacts, newerArtifacts] = await Promise.all([olderListing, newerListing]);
|
|
const olderExport = olderArtifacts.find((artifact) => artifact.kind === "event-log");
|
|
const newerExport = newerArtifacts.find((artifact) => artifact.kind === "event-log");
|
|
expect(olderExport?.absolutePath).toBe(newerExport?.absolutePath);
|
|
const eventExportPath = newerExport?.absolutePath;
|
|
if (!eventExportPath) {
|
|
throw new Error("expected memory event export");
|
|
}
|
|
|
|
const exported = (await fs.readFile(eventExportPath, "utf8"))
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => JSON.parse(line) as { query: string });
|
|
expect(exported.map((event) => event.query)).toEqual(["older", "newer"]);
|
|
} finally {
|
|
releaseFirstRead?.();
|
|
openSpy.mockRestore();
|
|
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ name: "refresh", clearEvents: false },
|
|
{ name: "retirement", clearEvents: true },
|
|
])("preserves a replacement installed during event export $name", async ({ clearEvents }) => {
|
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-host-export-replace-"));
|
|
const workspaceDir = path.join(fixtureRoot, "workspace");
|
|
const cfg = {
|
|
agents: { list: [{ id: "main", default: true, workspace: workspaceDir }] },
|
|
};
|
|
const replacement = '{"owner":"workspace"}\n';
|
|
const originalOpen = fs.open.bind(fs);
|
|
let exportOpenCount = 0;
|
|
let eventExportPath: string | undefined;
|
|
try {
|
|
vi.stubEnv("OPENCLAW_STATE_DIR", fixtureRoot);
|
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
await appendMemoryHostEvent(workspaceDir, {
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:00:00.000Z",
|
|
query: "first",
|
|
resultCount: 0,
|
|
results: [],
|
|
});
|
|
eventExportPath = (await listMemoryHostPublicArtifacts({ cfg })).find(
|
|
(artifact) => artifact.kind === "event-log",
|
|
)?.absolutePath;
|
|
if (!eventExportPath) {
|
|
throw new Error("expected memory event export");
|
|
}
|
|
if (clearEvents) {
|
|
await createPluginStateKeyedStore("memory-core", {
|
|
namespace: "memory-host.events",
|
|
maxEntries: 10_000,
|
|
env: { ...process.env, OPENCLAW_STATE_DIR: fixtureRoot },
|
|
}).clear();
|
|
} else {
|
|
await appendMemoryHostEvent(workspaceDir, {
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:01:00.000Z",
|
|
query: "second",
|
|
resultCount: 0,
|
|
results: [],
|
|
});
|
|
}
|
|
|
|
const expectedExportPath = path.resolve(eventExportPath);
|
|
vi.spyOn(fs, "open").mockImplementation(async (...args: Parameters<typeof fs.open>) => {
|
|
const target = args[0];
|
|
if (typeof target === "string" && path.resolve(target) === expectedExportPath) {
|
|
exportOpenCount += 1;
|
|
if (exportOpenCount === 2) {
|
|
await fs.rename(expectedExportPath, `${expectedExportPath}.openclaw-owned`);
|
|
await fs.writeFile(expectedExportPath, replacement, "utf8");
|
|
}
|
|
}
|
|
return await originalOpen(...args);
|
|
});
|
|
|
|
const artifacts = await listMemoryHostPublicArtifacts({ cfg });
|
|
expect(artifacts.some((artifact) => artifact.kind === "event-log")).toBe(false);
|
|
await expect(fs.readFile(expectedExportPath, "utf8")).resolves.toBe(replacement);
|
|
} finally {
|
|
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ name: "refresh", clearEvents: false },
|
|
{ name: "retirement", clearEvents: true },
|
|
])("keeps ownership through same-inode event export $name", async ({ clearEvents }) => {
|
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-host-export-inode-"));
|
|
const workspaceDir = path.join(fixtureRoot, "workspace");
|
|
const cfg = {
|
|
agents: { list: [{ id: "main", default: true, workspace: workspaceDir }] },
|
|
};
|
|
const originalOpen = fs.open.bind(fs);
|
|
let exportOpenCount = 0;
|
|
try {
|
|
vi.stubEnv("OPENCLAW_STATE_DIR", fixtureRoot);
|
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
await appendMemoryHostEvent(workspaceDir, {
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:00:00.000Z",
|
|
query: "first",
|
|
resultCount: 0,
|
|
results: [],
|
|
});
|
|
const eventExportPath = (await listMemoryHostPublicArtifacts({ cfg })).find(
|
|
(artifact) => artifact.kind === "event-log",
|
|
)?.absolutePath;
|
|
if (!eventExportPath) {
|
|
throw new Error("expected memory event export");
|
|
}
|
|
if (clearEvents) {
|
|
await createPluginStateKeyedStore("memory-core", {
|
|
namespace: "memory-host.events",
|
|
maxEntries: 10_000,
|
|
env: { ...process.env, OPENCLAW_STATE_DIR: fixtureRoot },
|
|
}).clear();
|
|
} else {
|
|
await appendMemoryHostEvent(workspaceDir, {
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:01:00.000Z",
|
|
query: "second",
|
|
resultCount: 0,
|
|
results: [],
|
|
});
|
|
}
|
|
|
|
const expectedExportPath = path.resolve(eventExportPath);
|
|
const originalStat = await fs.stat(expectedExportPath);
|
|
vi.spyOn(fs, "open").mockImplementation(async (...args: Parameters<typeof fs.open>) => {
|
|
const target = args[0];
|
|
if (typeof target === "string" && path.resolve(target) === expectedExportPath) {
|
|
exportOpenCount += 1;
|
|
if (exportOpenCount === 2) {
|
|
const writer = await originalOpen(expectedExportPath, "w");
|
|
try {
|
|
await writer.writeFile('{"owner":"same inode"}\n', "utf8");
|
|
} finally {
|
|
await writer.close();
|
|
}
|
|
}
|
|
}
|
|
return await originalOpen(...args);
|
|
});
|
|
|
|
const artifacts = await listMemoryHostPublicArtifacts({ cfg });
|
|
expect((await fs.stat(expectedExportPath)).ino).toBe(originalStat.ino);
|
|
if (clearEvents) {
|
|
expect(artifacts.some((artifact) => artifact.kind === "event-log")).toBe(false);
|
|
await expect(fs.readFile(expectedExportPath, "utf8")).resolves.toBe("");
|
|
} else {
|
|
expect(artifacts.some((artifact) => artifact.kind === "event-log")).toBe(true);
|
|
await expect(fs.readFile(expectedExportPath, "utf8")).resolves.toContain("second");
|
|
}
|
|
} finally {
|
|
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("retries an owned event export after a same-inode post-write race", async () => {
|
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-host-export-retry-"));
|
|
const workspaceDir = path.join(fixtureRoot, "workspace");
|
|
const cfg = {
|
|
agents: { list: [{ id: "main", default: true, workspace: workspaceDir }] },
|
|
};
|
|
const originalOpen = fs.open.bind(fs);
|
|
let exportOpenCount = 0;
|
|
try {
|
|
vi.stubEnv("OPENCLAW_STATE_DIR", fixtureRoot);
|
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
await appendMemoryHostEvent(workspaceDir, {
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:00:00.000Z",
|
|
query: "first",
|
|
resultCount: 0,
|
|
results: [],
|
|
});
|
|
const eventExportPath = (await listMemoryHostPublicArtifacts({ cfg })).find(
|
|
(artifact) => artifact.kind === "event-log",
|
|
)?.absolutePath;
|
|
if (!eventExportPath) {
|
|
throw new Error("expected memory event export");
|
|
}
|
|
await appendMemoryHostEvent(workspaceDir, {
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:01:00.000Z",
|
|
query: "second",
|
|
resultCount: 0,
|
|
results: [],
|
|
});
|
|
|
|
const expectedExportPath = path.resolve(eventExportPath);
|
|
const openSpy = vi
|
|
.spyOn(fs, "open")
|
|
.mockImplementation(async (...args: Parameters<typeof fs.open>) => {
|
|
const target = args[0];
|
|
if (typeof target === "string" && path.resolve(target) === expectedExportPath) {
|
|
exportOpenCount += 1;
|
|
if (exportOpenCount === 4) {
|
|
const writer = await originalOpen(expectedExportPath, "w");
|
|
try {
|
|
await writer.writeFile('{"owner":"post-write race"}\n', "utf8");
|
|
} finally {
|
|
await writer.close();
|
|
}
|
|
}
|
|
}
|
|
return await originalOpen(...args);
|
|
});
|
|
|
|
const racedArtifacts = await listMemoryHostPublicArtifacts({ cfg });
|
|
expect(racedArtifacts.some((artifact) => artifact.kind === "event-log")).toBe(false);
|
|
openSpy.mockRestore();
|
|
|
|
const retriedArtifacts = await listMemoryHostPublicArtifacts({ cfg });
|
|
expect(retriedArtifacts.some((artifact) => artifact.kind === "event-log")).toBe(true);
|
|
await expect(fs.readFile(expectedExportPath, "utf8")).resolves.toContain("second");
|
|
} finally {
|
|
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("repairs an oversized owned event export by inode", async () => {
|
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-host-export-oversized-"));
|
|
const workspaceDir = path.join(fixtureRoot, "workspace");
|
|
const cfg = {
|
|
agents: { list: [{ id: "main", default: true, workspace: workspaceDir }] },
|
|
};
|
|
try {
|
|
vi.stubEnv("OPENCLAW_STATE_DIR", fixtureRoot);
|
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
await appendMemoryHostEvent(workspaceDir, {
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:00:00.000Z",
|
|
query: "first",
|
|
resultCount: 0,
|
|
results: [],
|
|
});
|
|
const eventExportPath = (await listMemoryHostPublicArtifacts({ cfg })).find(
|
|
(artifact) => artifact.kind === "event-log",
|
|
)?.absolutePath;
|
|
if (!eventExportPath) {
|
|
throw new Error("expected memory event export");
|
|
}
|
|
const originalStat = await fs.stat(eventExportPath);
|
|
await fs.appendFile(eventExportPath, "x".repeat(1024 * 1024 + 1), "utf8");
|
|
await appendMemoryHostEvent(workspaceDir, {
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:01:00.000Z",
|
|
query: "second",
|
|
resultCount: 0,
|
|
results: [],
|
|
});
|
|
|
|
const artifacts = await listMemoryHostPublicArtifacts({ cfg });
|
|
expect(artifacts.some((artifact) => artifact.kind === "event-log")).toBe(true);
|
|
expect((await fs.stat(eventExportPath)).ino).toBe(originalStat.ino);
|
|
expect((await fs.stat(eventExportPath)).size).toBeLessThan(1024 * 1024);
|
|
await expect(fs.readFile(eventExportPath, "utf8")).resolves.toContain("second");
|
|
} finally {
|
|
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps public event exports isolated across state directories", async () => {
|
|
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-host-export-owner-"));
|
|
const workspaceDir = path.join(fixtureRoot, "workspace");
|
|
const cfg = {
|
|
agents: { list: [{ id: "main", default: true, workspace: workspaceDir }] },
|
|
};
|
|
try {
|
|
await fs.mkdir(workspaceDir, { recursive: true });
|
|
|
|
const exports: string[] = [];
|
|
for (const profile of ["profile-a", "profile-b"]) {
|
|
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(fixtureRoot, profile));
|
|
await appendMemoryHostEvent(workspaceDir, {
|
|
type: "memory.recall.recorded",
|
|
timestamp: "2026-05-18T12:00:00.000Z",
|
|
query: profile,
|
|
resultCount: 0,
|
|
results: [],
|
|
});
|
|
const artifact = (await listMemoryHostPublicArtifacts({ cfg })).find(
|
|
(candidate) => candidate.kind === "event-log",
|
|
);
|
|
if (!artifact) {
|
|
throw new Error("expected memory event export");
|
|
}
|
|
exports.push(artifact.absolutePath);
|
|
await expect(fs.readFile(artifact.absolutePath, "utf8")).resolves.toContain(profile);
|
|
}
|
|
|
|
expect(new Set(exports).size).toBe(2);
|
|
const [profileAExport, profileBExport] = exports;
|
|
if (!profileAExport || !profileBExport) {
|
|
throw new Error("expected state-qualified memory event exports");
|
|
}
|
|
await expect(fs.readFile(profileAExport, "utf8")).resolves.toContain("profile-a");
|
|
await expect(fs.readFile(profileBExport, "utf8")).resolves.toContain("profile-b");
|
|
} finally {
|
|
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps the deprecated memory-core alias wired to memory-host-core", () => {
|
|
expect(memoryCoreAlias.buildActiveMemoryPromptSection).toBe(buildActiveMemoryPromptSection);
|
|
expect(memoryCoreAlias.listActiveMemoryPublicArtifacts).toBe(listActiveMemoryPublicArtifacts);
|
|
});
|
|
});
|