mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
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:
+69
-1
@@ -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
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user