Skip to content

fix(store,server): resolve four store data-loss/traversal bugs (#499 #500 #501 #502) - #520

Draft
TYRMars wants to merge 1 commit into
mainfrom
claude/vibrant-dijkstra-q1b5qv
Draft

fix(store,server): resolve four store data-loss/traversal bugs (#499 #500 #501 #502)#520
TYRMars wants to merge 1 commit into
mainfrom
claude/vibrant-dijkstra-q1b5qv

Conversation

@TYRMars

@TYRMars TYRMars commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Auto-resolves a batch of related JSON-file store findings filed by the scheduled cr code-review task. All four live in packages/store/src/ (plus one WS call site in packages/server) and interlink, so they're fixed together.

Fixes

#502atomicWrite shared .tmp → silent turn loss on concurrent writes

All concurrent writers of one target shared a single <path>.tmp; whoever renamed first consumed it and the loser's rename hit ENOENT, silently dropping a turn (reachable via double-submit / two tabs on POST /v1/conversations/:id/messages, which catch-and-ignore the save error).

  • Stage to a unique per-write name <path>.<pid>.<uuid>.tmp with best-effort cleanup on failure. Last-writer-wins becomes the real semantics; no more ENOENT.

#501JsonFileConversationStore.list unguarded shape → permanent 500

One foreign-but-parseable .json in the base dir (e.g. workspaces.json from JsonFileWorkspaceStore) dereferenced stored.messages.length, throwing a TypeError that escaped list() and permanently 500'd GET /v1/conversations.

  • Guard Array.isArray(stored.messages) and wrap the per-file body in try/catch, so a bad entry is skipped rather than fatal — matching activity-store / observability.

#499encodeId .. partition escape (security)

encodeId keeps . in its safe set, so encodeId("..") === ".."; joined as a directory component it climbs out of the store partition into the sibling conversation dir (a project_id of .. corrupts the conversation listing).

  • Add encodeSegment, which neutralises all-dots segments (.%2E, ..%2E%2E), and use it in every directory-component call site: requirement / requirement-run / comment / activity / doc stores. Filename call sites (with the .json suffix) are unaffected.

#500 — WS chat save() erases conversation metadata

The WS turn-completion handler called store.save(), which hard-codes defaultMetadata(), so every chat turn wiped the conversation's project_id and reset lifecycle to active (requirement-run conversations lost their project link the first time a user chatted over the WS).

  • Track ConversationMetadata across the socket (seeded from loadEnvelope on resume, reset on reset/new) and round-trip it via saveEnvelope, matching the REST message routes.

Tests

Added regression tests covering: concurrent same-id writes no longer ENOENT and leave no .tmp litter; list() skips a foreign JSON with no messages array; encodeSegment neutralises ./.. while leaving normal ids intact; a project_id of .. stays inside the requirements/ partition and never lands in the base dir.

Verification

  • pnpm --filter @jarvis/store typecheck — clean
  • pnpm --filter @jarvis/server typecheck — clean
  • pnpm lint — 0 errors (1 pre-existing warning in an unrelated test file)
  • Store & server test suites: all new tests pass; the only failures are pre-existing/environmental (better-sqlite3 and node-pty native binaries can't compile, web dist/ absent) and are identical with the changes stashed.

Generated by Claude Code

…500 #501 #502)

Auto-resolves a batch of related code-review findings in the JSON-file
store layer plus the WS chat save path.

#502 atomicWrite shared `.tmp`: all concurrent writers of one target shared
  a single staging file, so the loser's rename hit ENOENT and its turn was
  silently dropped. Stage to a unique per-write name
  (`<path>.<pid>.<uuid>.tmp`) with best-effort cleanup on failure, making
  last-writer-wins the real semantics.

#501 JsonFileConversationStore.list unguarded shape: one foreign-but-parseable
  `.json` in the base dir (e.g. workspaces.json) dereferenced
  `stored.messages.length` and threw a TypeError that permanently 500'd
  GET /v1/conversations. Guard `Array.isArray(messages)` and wrap the
  per-file body in try/catch so a bad entry is skipped, not fatal — matching
  activity-store / observability behaviour.

#499 encodeId `..` partition escape: `encodeId` keeps `.` in its safe set, so
  a `project_id`/`requirement_id` of `..` joined as a directory component
  climbed out of the store partition into the sibling conversation dir. Add
  `encodeSegment`, which neutralises all-dots segments, and use it in every
  directory-component call site (requirement / requirement-run / comment /
  activity / doc stores). Filename call sites (with the `.json` suffix) are
  unaffected.

#500 WS chat save erased metadata: the WS turn-completion handler called
  `store.save()`, which hard-codes `defaultMetadata()`, so every chat turn
  wiped the conversation's `project_id` and un-archived it. Track
  `ConversationMetadata` across the socket (seeded from `loadEnvelope` on
  resume, reset on reset/new) and round-trip it via `saveEnvelope`, matching
  the REST message routes.

Adds regression tests for the concurrent-write, foreign-file, encodeSegment,
and partition-containment cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NKLVVpJ3ckDHgYMrP867bd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants