fix(security): harden hooks, quarantine corrupt JSON, test locks, fix promotion dedupe

- Wrap hooks with try/catch to prevent OpenCode disruption
- Add warnMemoryHook() for safe error logging
- Quarantine corrupt JSON files before fallback
- Add cross-process lock safety tests
- Fix pending promotion same-batch dedupe
- Update docs/architecture.md with lock semantics
- 242 tests passing
This commit is contained in:
Ralph Chang
2026-04-30 11:52:01 +08:00
parent 20a6cfe1a6
commit c0ebd84d7e
6 changed files with 317 additions and 132 deletions
+69 -1
View File
@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { MemoryV2Plugin } from "../src/plugin.ts";
@@ -450,6 +450,33 @@ test("chat system transform reuses frozen rendered workspace snapshot", async ()
}
});
test("chat system transform degrades gracefully when workspace memory JSON is corrupt", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const path = await workspaceMemoryPath(tmpDir);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, "{ invalid json", "utf8");
const plugin = await MemoryV2Plugin({
directory: tmpDir,
client: mockRootClient(),
});
const output = { system: ["base header"] };
await assert.doesNotReject(async () => {
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "corrupt-json-session", model: {} },
output,
);
});
assert.deepEqual(output.system, ["base header"]);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("no compaction: owned explicit memory is not promoted by unrelated next session start", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
@@ -876,6 +903,47 @@ test("integration: explicit memory flows from user message through pending journ
}
});
test("session.compacted promotes first-time explicit memory without self-reinforcement", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
try {
const plugin = await MemoryV2Plugin({
directory: tmpDir,
client: mockClientWithLatestUser(
"remember this: Prefer no self reinforcement during first promotion.",
"msg-no-self-reinforce",
),
});
await (plugin as Record<string, Function>)["experimental.chat.system.transform"](
{ sessionID: "no-self-reinforce-session", model: {} },
{ system: ["base header"] },
);
await (plugin as Record<string, Function>)["event"]({
event: {
type: "session.compacted",
properties: { sessionID: "no-self-reinforce-session" },
},
});
const workspace = await loadWorkspaceMemory(tmpDir);
const promoted = workspace.entries.find(entry =>
/Prefer no self reinforcement/.test(entry.text)
);
assert.ok(promoted, "explicit memory should be promoted");
assert.equal(promoted.reinforcementCount ?? 0, 0);
assert.equal(promoted.lastReinforcedSessionID, undefined);
const state = await loadSessionState(tmpDir, "no-self-reinforce-session");
assert.equal(state.pendingMemories.length, 0);
assert.equal((await loadPendingJournal(tmpDir)).entries.length, 0);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
test("integration: compaction candidate flows through journal promotion and clears pending journal", async () => {
const tmpDir = await mkdtemp(join(tmpdir(), "memory-plugin-test-"));
+61 -2
View File
@@ -1,11 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { spawn } from "node:child_process";
import { updateJSON } from "../src/storage.ts";
import { readJSON, updateJSON } from "../src/storage.ts";
test("updateJSON serializes concurrent increments", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-storage-"));
@@ -37,6 +37,25 @@ test("updateJSON does not replace corrupt JSON with fallback", async () => {
}
});
test("readJSON quarantines corrupt JSON and returns fallback", async () => {
const dir = await mkdtemp(join(tmpdir(), "wm-storage-corrupt-"));
const path = join(dir, "store.json");
try {
await writeFile(path, "{ invalid json", "utf8");
const loaded = await readJSON(path, () => ({ ok: true }));
assert.deepEqual(loaded, { ok: true });
const files = await readdir(dir);
assert.equal(files.includes("store.json"), false);
assert.equal(files.some(file => file.startsWith("store.json.corrupt-")), true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("updateJSON recovers stale lock files left by crashed process", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-storage-stale-lock-"));
try {
@@ -81,3 +100,43 @@ test("updateJSON serializes writes across separate node processes", async () =>
await rm(root, { recursive: true, force: true });
}
});
test("updateJSON waits for a live cross-process lock and preserves both updates", async () => {
const root = await mkdtemp(join(tmpdir(), "wm-storage-live-lock-"));
try {
const path = join(root, "counter.json");
const worker = `
import { updateJSON } from ${JSON.stringify(new URL("../src/storage.ts", import.meta.url).href)};
const path = process.argv[1];
await updateJSON(path, () => ({ count: 0, order: [] }), async current => {
await new Promise(resolve => setTimeout(resolve, 250));
return { count: current.count + 1, order: [...current.order, "child"] };
});
`;
const child = spawn(
process.execPath,
["--experimental-strip-types", "--input-type=module", "-e", worker, path],
{ stdio: "inherit" },
);
await new Promise(resolve => setTimeout(resolve, 50));
await updateJSON(path, () => ({ count: 0, order: [] as string[] }), current => ({
count: current.count + 1,
order: [...current.order, "parent"],
}));
await new Promise<void>((resolve, reject) => {
child.on("exit", code => code === 0 ? resolve() : reject(new Error(`child exited ${code}`)));
child.on("error", reject);
});
const final = await updateJSON(path, () => ({ count: 0, order: [] as string[] }), current => current);
assert.equal(final.count, 2);
assert.deepEqual(new Set(final.order), new Set(["child", "parent"]));
assert.equal(existsSync(`${path}.lock`), false);
} finally {
await rm(root, { recursive: true, force: true });
}
});