fix(store): harden JSON-file store — unique staging, list shape-guard, .. partition escape (#502, #501, #499) - #513
Draft
TYRMars wants to merge 2 commits into
Draft
fix(store): harden JSON-file store — unique staging, list shape-guard, .. partition escape (#502, #501, #499)#513TYRMars wants to merge 2 commits into
.. partition escape (#502, #501, #499)#513TYRMars wants to merge 2 commits into
Conversation
json-file.ts backs every JSON-file store plus @jarvis/observability and @jarvis/learning, so both bugs here are broadly reachable. #502 — atomicWrite derived its staging file solely from the target path, so all concurrent writers of one id shared a single `<path>.tmp`. Writer B truncates A's tmp and whoever renames second finds nothing and rejects with ENOENT, silently dropping a write (the REST message routes catch-and-ignore the failure). Stage to a per-writer `<path>.<pid>.<uuid>.tmp` and clean it up in `finally`; the name still ends in `.tmp` (not `.json`) so every directory scanner keeps skipping leftover litter. Semantics become honest last-writer-wins. #501 — JsonFileConversationStore.list blind-cast every parsed `.json` and dereferenced `stored.messages.length` with no shape guard and no per-file try/catch. One foreign-but-well-formed file (e.g. workspaces.json flushed into the same dir) threw a TypeError that escaped list() and permanently 500'd GET /v1/conversations. Skip records whose `messages` isn't an array and wrap the per-file body in try/catch, matching activity-store / observability/json-file. Adds regression tests for both: concurrent same-id saves settle with no ENOENT and no tmp litter, and list survives a foreign wrong-shape JSON file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UEK9SFSjSnan63Nc4NspB
#499) `encodeId` treats `.` as a safe byte, so `encodeId("..") === ".."`. As a filename the trailing `.json` neutralises it, but `JsonFileRequirementStore .#projectDir` and `JsonFileCommentStore.#requirementDir` join it as a bare *directory* component — so a `project_id`/`requirement_id` of `..` escapes the partition into the sibling conversation dir (both stores open off the same base). A `requirement.create { project_id: ".." }` then drops a requirement row (no `messages`) into the conversation dir, which — combined with the missing list() shape-guard — permanently 500s GET /v1/conversations, and `requirements.list("..")` returns every conversation coerced into a Requirement. Reachable from both the agent tool surface and the REST API (labelled security). Add `encodePartition`: same as `encodeId` for real ids, but throws StoreError when the encoded segment is ``, `.`, or `..` (the only encodeId outputs that can traverse — `/`/`\` are already percent-encoded). Route both partition-dir helpers through it, so the guard sits at the single storage choke point every read/write/list/delete already passes through. `encodeId` itself is unchanged, so the `__memory__.summary:` filename layout and on-disk data are untouched. Regression tests: a `..` project_id / requirement_id is rejected (never writes into the base dir) on both stores. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UEK9SFSjSnan63Nc4NspB
.. partition escape (#502, #501, #499)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Auto-resolves three related open bug reports that all harden the JSON-file store family against foreign / escaped files silently breaking data or permanently 500-ing
GET /v1/conversations. #501 and #499 both stem from the same root (files landing in the flat conversation dir), and #501 explicitly cross-references the #499..escape, so they're fixed together.#502 — shared
.tmpdrops concurrent writes to one idatomicWritederived its staging file solely from the target path, so all concurrent writers of one id shared a single<path>.tmp. Writer B truncates A's tmp; whoever renames second finds nothing and rejects withENOENT, silently dropping a write. Reachable without a second socket:POST /v1/conversations/:id/messages(+/stream) have no in-flight guard and both catch-and-ignore the save failure, so a double-submit / client retry / two open tabs loses an entire turn with nothing shown to the user.Fix: stage to a per-writer
<path>.<pid>.<uuid>.tmpand clean it up infinally. The name still ends in.tmp(not.json), so every directory-scanninglist()keeps skipping leftover litter. Semantics become honest last-writer-wins.#501 — one foreign JSON file permanently 500s the conversation list
JsonFileConversationStore.listblind-cast every parsed.jsonand dereferencedstored.messages.lengthwith no shape guard and no per-filetry/catch. One foreign-but-well-formed file —workspaces.jsonflushed into the same base dir, or a row written by the #499 escape — threw aTypeErrorthat escapedlist()and the endpoint; the sidebar went empty and stayed empty.Fix: skip records whose
messagesisn't an array and wrap the per-file body intry/catch, matchingactivity-store.tsandobservability/json-file.ts.#499 —
..project_id escapes the storage partition (security)encodeIdtreats.as safe, soencodeId("..") === "..". As a filename the.jsonsuffix neutralises it, butJsonFileRequirementStore.#projectDirandJsonFileCommentStore.#requirementDirjoin it as a bare directory component, so aproject_id/requirement_idof..escapes into the sibling conversation dir (both stores open off the same base).requirement.create { project_id: ".." }drops amessages-less row into the conversation dir (the visible symptom is the #501 outage), andrequirements.list("..")returns every conversation coerced into aRequirement. Reachable from both the agent tool surface and the REST API.Fix: add
encodePartition— identical toencodeIdfor real ids, but throwsStoreErrorwhen the encoded segment is ``,., or `..` (the only `encodeId` outputs that can traverse; `/` and `` are already percent-encoded). Route both partition-dir helpers through it — the single storage choke point every read/write/list/delete passes through. `encodeId` itself is unchanged, so the `memory.summary:` filename layout and existing on-disk data are untouched.Changes
packages/store/src/json-file.ts— unique per-writer staging +finallycleanup inatomicWrite; shape guard + per-filetry/catchinlist(); newencodePartition.packages/store/src/index.ts— exportencodePartition.packages/store/src/requirement-store.ts,comment-store.ts— route partition-dir helpers throughencodePartition.store.test.ts,requirement-store.test.ts,comment-store.test.ts— regression tests for all three.Testing
pnpm --filter @jarvis/store typecheck— clean.store(21),requirement-store(16),comment-store(21),learning(84),todo(30),observability(31),workspace-store(15). CIbuildwas green on the first commit.Closes #502
Closes #501
Closes #499
🤖 Generated with Claude Code
https://claude.ai/code/session_018UEK9SFSjSnan63Nc4NspB