Skip to content

Commit c0ebd84

Browse files
committed
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
1 parent 20a6cfe commit c0ebd84

6 files changed

Lines changed: 314 additions & 129 deletions

File tree

RELEASE_NOTES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ Retention monitoring:
9696
### Validation
9797

9898
- `npm run typecheck`
99-
- `npm test`237 tests passing
99+
- `npm test`242 tests passing
100100
- `bun scripts/memory-diag.ts health`
101101

102102
---

docs/architecture.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,10 @@ canonical("Use npm cache for plugins") === "use npm cache for plugins"
343343
const workspaceKey = sha256(realpath(workspaceRoot)).slice(0, 16)
344344
```
345345

346+
### Storage Safety
347+
348+
All read-modify-write updates go through `updateJSON()`, which combines an in-process promise queue with an on-disk `.lock` file. The lock file uses exclusive creation, heartbeat refreshes while held, stale-lock recovery after 30 seconds, and a 5 second wait timeout for live contention. Direct `readJSON()` is fallback-oriented and does not mutate data except when corrupt JSON is quarantined.
349+
346350
## Performance Considerations
347351

348352
### Memory Budgets

src/plugin.ts

Lines changed: 157 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ import {
6363
pendingTodos,
6464
} from "./opencode.ts";
6565
import { accountPendingPromotions } from "./promotion-accounting.ts";
66-
import { WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts";
66+
import { type LongTermMemoryEntry, WORKSPACE_MEMORY_CACHE_LIMITS } from "./types.ts";
6767

6868
/**
6969
* Build the complete compaction prompt.
@@ -162,6 +162,11 @@ function renderTodosForCompaction(todos: Array<{ content: string; status: string
162162
return lines.join("\n");
163163
}
164164

165+
function warnMemoryHook(scope: string, error: unknown): void {
166+
const message = error instanceof Error ? error.message : String(error);
167+
console.error(`[memory] ${scope} failed: ${message}`);
168+
}
169+
165170
export const MemoryV2Plugin: Plugin = async (input) => {
166171
const { directory, client } = input;
167172

@@ -285,6 +290,22 @@ export const MemoryV2Plugin: Plugin = async (input) => {
285290
rememberProcessedUserMessage(sessionID, latestMessage.id, processedForSession);
286291
}
287292

293+
function dedupePendingPromotionMemories(
294+
memories: LongTermMemoryEntry[],
295+
): LongTermMemoryEntry[] {
296+
const seen = new Set<string>();
297+
const deduped: LongTermMemoryEntry[] = [];
298+
299+
for (const memory of memories) {
300+
const key = memoryKey(memory);
301+
if (seen.has(key)) continue;
302+
seen.add(key);
303+
deduped.push(memory);
304+
}
305+
306+
return deduped;
307+
}
308+
288309
async function promotePendingMemories(
289310
sessionID?: string,
290311
options: { includeUnownedJournal?: boolean; includeOwnedJournal?: boolean } = {},
@@ -302,10 +323,10 @@ export const MemoryV2Plugin: Plugin = async (input) => {
302323
return false;
303324
});
304325

305-
const pending = [
326+
const pending = dedupePendingPromotionMemories([
306327
...(sessionState?.pendingMemories ?? []),
307328
...journalPending,
308-
];
329+
]);
309330
if (pending.length === 0) return;
310331

311332
let beforeEntries: Awaited<ReturnType<typeof loadWorkspaceMemory>>["entries"] = [];
@@ -462,38 +483,42 @@ export const MemoryV2Plugin: Plugin = async (input) => {
462483
const { sessionID } = hookInput;
463484
if (!sessionID) return;
464485

465-
pruneFrozenWorkspaceMemoryCache();
466-
pruneProcessedUserMessagesCache();
486+
try {
487+
pruneFrozenWorkspaceMemoryCache();
488+
pruneProcessedUserMessagesCache();
467489

468-
// Sub-agents are short-lived - skip memory system
469-
if (await isSubAgent(sessionID)) return;
490+
// Sub-agents are short-lived - skip memory system
491+
if (await isSubAgent(sessionID)) return;
470492

471-
// Process explicit user memory even on no-tool turns. Keep this after the
472-
// sub-agent guard so child sessions never append to the parent journal.
473-
await processLatestUserMessage(sessionID);
493+
// Process explicit user memory even on no-tool turns. Keep this after the
494+
// sub-agent guard so child sessions never append to the parent journal.
495+
await processLatestUserMessage(sessionID);
474496

475-
// Before first snapshot in this session, promote durable unowned backlog from
476-
// prior sessions. Current-turn owned explicit memory remains pending and only
477-
// appears in hot state for this transform.
478-
if (!frozenWorkspaceMemoryCache.has(sessionID) && await hasPendingJournalEntries(directory)) {
479-
await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false });
480-
}
497+
// Before first snapshot in this session, promote durable unowned backlog from
498+
// prior sessions. Current-turn owned explicit memory remains pending and only
499+
// appears in hot state for this transform.
500+
if (!frozenWorkspaceMemoryCache.has(sessionID) && await hasPendingJournalEntries(directory)) {
501+
await promotePendingMemories(undefined, { includeUnownedJournal: true, includeOwnedJournal: false });
502+
}
481503

482-
// Get frozen workspace memory snapshot (loaded and rendered once per session)
483-
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
504+
// Get frozen workspace memory snapshot (loaded and rendered once per session)
505+
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
484506

485-
// Get current hot session state
486-
const sessionState = await loadSessionState(directory, sessionID);
507+
// Get current hot session state
508+
const sessionState = await loadSessionState(directory, sessionID);
487509

488-
// Inject frozen workspace memory snapshot
489-
if (workspaceSnapshot.renderedPrompt) {
490-
output.system.push(workspaceSnapshot.renderedPrompt);
491-
}
510+
// Inject frozen workspace memory snapshot
511+
if (workspaceSnapshot.renderedPrompt) {
512+
output.system.push(workspaceSnapshot.renderedPrompt);
513+
}
492514

493-
// Render and inject hot session state
494-
const hotPrompt = renderHotSessionState(sessionState, directory);
495-
if (hotPrompt) {
496-
output.system.push(hotPrompt);
515+
// Render and inject hot session state
516+
const hotPrompt = renderHotSessionState(sessionState, directory);
517+
if (hotPrompt) {
518+
output.system.push(hotPrompt);
519+
}
520+
} catch (error) {
521+
warnMemoryHook("chat.system.transform", error);
497522
}
498523
},
499524

@@ -506,50 +531,54 @@ export const MemoryV2Plugin: Plugin = async (input) => {
506531
// Sub-agents don't need memory tracking
507532
if (await isSubAgent(sessionID)) return;
508533

509-
await updateSessionState(directory, sessionID, state => {
510-
// Track active files from tool usage
511-
if (toolName === "read" || toolName === "edit" || toolName === "write" || toolName === "grep") {
512-
const files = extractActiveFiles(
513-
toolName,
514-
args as Record<string, unknown>,
515-
toolOutput ?? ""
516-
);
517-
for (const { path, action } of files) {
518-
touchActiveFile(state, path, action);
519-
if (action === "edit" || action === "write") {
520-
markErrorsMaybeFixedForFile(state, path, directory);
534+
try {
535+
await updateSessionState(directory, sessionID, state => {
536+
// Track active files from tool usage
537+
if (toolName === "read" || toolName === "edit" || toolName === "write" || toolName === "grep") {
538+
const files = extractActiveFiles(
539+
toolName,
540+
args as Record<string, unknown>,
541+
toolOutput ?? ""
542+
);
543+
for (const { path, action } of files) {
544+
touchActiveFile(state, path, action);
545+
if (action === "edit" || action === "write") {
546+
markErrorsMaybeFixedForFile(state, path, directory);
547+
}
521548
}
522549
}
523-
}
524550

525-
// Track errors from failed bash commands
526-
if (toolName === "bash") {
527-
const argsRecord = args as Record<string, unknown>;
528-
const command: string = typeof argsRecord?.command === "string"
529-
? argsRecord.command
530-
: "";
531-
const outputText: string = toolOutput ?? "";
532-
533-
// Check if command succeeded - clear errors for that category
534-
const exitCode = bashExitCode(hookOutput);
535-
if (typeof exitCode !== "number") {
536-
// Unknown exit status: do not extract and do not clear
537-
} else if (exitCode === 0 && command) {
538-
clearErrorsForSuccessfulCommand(state, command);
539-
} else if (command) {
540-
// Only extract errors for commands with explicit non-zero exit
541-
const errors = extractErrorsFromBash(command, outputText);
542-
for (const error of errors) {
543-
upsertOpenError(state, error);
551+
// Track errors from failed bash commands
552+
if (toolName === "bash") {
553+
const argsRecord = args as Record<string, unknown>;
554+
const command: string = typeof argsRecord?.command === "string"
555+
? argsRecord.command
556+
: "";
557+
const outputText: string = toolOutput ?? "";
558+
559+
// Check if command succeeded - clear errors for that category
560+
const exitCode = bashExitCode(hookOutput);
561+
if (typeof exitCode !== "number") {
562+
// Unknown exit status: do not extract and do not clear
563+
} else if (exitCode === 0 && command) {
564+
clearErrorsForSuccessfulCommand(state, command);
565+
} else if (command) {
566+
// Only extract errors for commands with explicit non-zero exit
567+
const errors = extractErrorsFromBash(command, outputText);
568+
for (const error of errors) {
569+
upsertOpenError(state, error);
570+
}
544571
}
545572
}
546-
}
547-
return state;
548-
});
573+
return state;
574+
});
549575

550-
// Process explicit memory from latest user message
551-
// Only process once per message ID
552-
await processLatestUserMessage(sessionID);
576+
// Process explicit memory from latest user message
577+
// Only process once per message ID
578+
await processLatestUserMessage(sessionID);
579+
} catch (error) {
580+
warnMemoryHook("tool.execute.after", error);
581+
}
553582
},
554583

555584
/**
@@ -567,95 +596,99 @@ export const MemoryV2Plugin: Plugin = async (input) => {
567596
const { sessionID } = hookInput;
568597
if (!sessionID) return;
569598

570-
// Sub-agents don't need compaction support
571-
if (await isSubAgent(sessionID)) return;
599+
try {
600+
// Sub-agents don't need compaction support
601+
if (await isSubAgent(sessionID)) return;
572602

573-
// Preserve context injected by other plugins that ran before us.
574-
// Setting output.prompt bypasses the default prompt + context join,
575-
// so we must explicitly carry forward any existing output.context.
576-
const otherContext = output.context.filter(Boolean).join("\n\n");
603+
// Preserve context injected by other plugins that ran before us.
604+
// Setting output.prompt bypasses the default prompt + context join,
605+
// so we must explicitly carry forward any existing output.context.
606+
const otherContext = output.context.filter(Boolean).join("\n\n");
577607

578-
// Build our private context (workspace memory, hot state, todos)
579-
const contextParts: string[] = [];
608+
// Build our private context (workspace memory, hot state, todos)
609+
const contextParts: string[] = [];
580610

581611
// 1. Frozen workspace memory snapshot
582-
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
583-
if (workspaceSnapshot.renderedPrompt) {
584-
contextParts.push(workspaceSnapshot.renderedPrompt);
585-
}
612+
const workspaceSnapshot = await getFrozenWorkspaceMemorySnapshot(directory, sessionID);
613+
if (workspaceSnapshot.renderedPrompt) {
614+
contextParts.push(workspaceSnapshot.renderedPrompt);
615+
}
586616

587-
// 2. Hot session state
588-
const sessionState = await loadSessionState(directory, sessionID);
589-
const hotPrompt = renderHotSessionState(sessionState, directory);
590-
if (hotPrompt) {
591-
contextParts.push(hotPrompt);
592-
}
617+
// 2. Hot session state
618+
const sessionState = await loadSessionState(directory, sessionID);
619+
const hotPrompt = renderHotSessionState(sessionState, directory);
620+
if (hotPrompt) {
621+
contextParts.push(hotPrompt);
622+
}
593623

594-
// 3. Pending todos from OpenCode
595-
const todos = await pendingTodos(client, sessionID);
596-
const todosPrompt = renderTodosForCompaction(todos);
597-
if (todosPrompt) {
598-
contextParts.push(todosPrompt);
599-
}
624+
// 3. Pending todos from OpenCode
625+
const todos = await pendingTodos(client, sessionID);
626+
const todosPrompt = renderTodosForCompaction(todos);
627+
if (todosPrompt) {
628+
contextParts.push(todosPrompt);
629+
}
600630

601-
// Combine: other plugins' context first, then our private context
602-
const privateContext = [otherContext, ...contextParts]
603-
.filter(Boolean)
604-
.join("\n\n");
631+
// Combine: other plugins' context first, then our private context
632+
const privateContext = [otherContext, ...contextParts]
633+
.filter(Boolean)
634+
.join("\n\n");
605635

606-
// Replace the default prompt entirely with our ---free template
607-
output.prompt = buildCompactionPrompt(privateContext);
636+
// Replace the default prompt entirely with our ---free template
637+
output.prompt = buildCompactionPrompt(privateContext);
608638

609-
// Clear context array since we consumed it into output.prompt.
610-
// Subsequent plugins that set output.prompt will also need to check
611-
// output.context if they want to preserve other plugin contributions.
612-
output.context.length = 0;
639+
// Clear context array since we consumed it into output.prompt.
640+
// Subsequent plugins that set output.prompt will also need to check
641+
// output.context if they want to preserve other plugin contributions.
642+
output.context.length = 0;
643+
} catch (error) {
644+
warnMemoryHook("session.compacting", error);
645+
}
613646
},
614647

615648
// Handle session events
616649
event: async ({ event }) => {
617650
if (event.type === "session.compacted") {
618-
const sessionID = sessionIDFromEventProperties(event.properties);
619-
if (!sessionID) return;
620-
621-
// Sub-agents don't need post-compaction processing
622-
if (await isSubAgent(sessionID)) return;
623-
624-
// Parse latest compaction summary for memory candidates, stage them into
625-
// durable pending journal, then promote pending memories.
626-
const summary = await latestCompactionSummary(client, sessionID);
627-
const candidates = summary ? parseWorkspaceMemoryCandidates(summary) : [];
628-
if (candidates.length > 0) {
629-
await appendPendingMemories(directory, candidates);
630-
}
631-
632651
try {
652+
const sessionID = sessionIDFromEventProperties(event.properties);
653+
if (!sessionID) return;
654+
655+
// Sub-agents don't need post-compaction processing
656+
if (await isSubAgent(sessionID)) return;
657+
658+
// Parse latest compaction summary for memory candidates, stage them into
659+
// durable pending journal, then promote pending memories.
660+
const summary = await latestCompactionSummary(client, sessionID);
661+
const candidates = summary ? parseWorkspaceMemoryCandidates(summary) : [];
662+
if (candidates.length > 0) {
663+
await appendPendingMemories(directory, candidates);
664+
}
665+
633666
await promotePendingMemories(sessionID, { includeUnownedJournal: true });
634-
} catch {
667+
} catch (error) {
635668
// Keep pending memories in session/journal for retry on next event/session.
669+
warnMemoryHook("event.session.compacted", error);
636670
}
637671
}
638672

639673
if (event.type === "session.deleted") {
640-
const sessionID = sessionIDFromEventProperties(event.properties);
641-
if (sessionID) {
674+
try {
675+
const sessionID = sessionIDFromEventProperties(event.properties);
676+
if (sessionID) {
642677
// Promote pending memories before deleting per-session state.
643678
// If promotion fails, leave session state and journal intact.
644-
let promoted = false;
645-
try {
679+
let promoted = false;
646680
await promotePendingMemories(sessionID, { includeOwnedJournal: true, includeUnownedJournal: false });
647681
promoted = true;
648-
} catch {
649-
return;
650-
} finally {
651682
if (promoted) {
652683
frozenWorkspaceMemoryCache.delete(sessionID);
653684
processedUserMessages.delete(sessionID);
654685
sessionParentCache.delete(sessionID);
655686
}
656-
}
657687

658-
await rm(await sessionStatePath(directory, sessionID), { force: true });
688+
await rm(await sessionStatePath(directory, sessionID), { force: true });
689+
}
690+
} catch (error) {
691+
warnMemoryHook("event.session.deleted", error);
659692
}
660693
}
661694
},

0 commit comments

Comments
 (0)