Skip to content

Expose cross-project session listing and session deletion over RPC#315

Merged
m-aebrer merged 6 commits into
masterfrom
feature/issue-312-rpc-session-listing-delete
Jul 6, 2026
Merged

Expose cross-project session listing and session deletion over RPC#315
m-aebrer merged 6 commits into
masterfrom
feature/issue-312-rpc-session-listing-delete

Conversation

@m-aebrer

@m-aebrer m-aebrer commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Closes #312

Adds list_all_sessions and delete_session RPC commands so external frontends (the planned web dashboard) can list sessions across all projects and delete sessions without importing SessionManager directly. Moves the trash-first deletion logic from the TUI session selector into core.

Implementation plan posted as a comment below.

@m-aebrer

m-aebrer commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Implementation Plan

Problem analysis

The fleet-centric dashboard (implementation issue 307, design issue 311) needs to list sessions across all projects and delete sessions via RPC. Today:

  • The RPC list_sessions handler calls SessionManager.list(cwd, sessionDir) — current project only. SessionManager.listAll() exists in core but has no RPC command.
  • Session deletion exists only as a private deleteSessionFile() free function inside the TUI session selector (modes/interactive/components/session-selector.ts, ~lines 615-653): trash CLI first (with -- guard for --prefixed paths, and "file gone afterwards" treated as success), unlink fallback, combined error hints. The active-session guard ("Cannot delete the currently active session") lives at the TUI call site via string comparison with sessionManager.getSessionFile().

SessionManager already has the static-method convention for path-based operations that need no live instance (open, list, listAll, forkFrom) — deletion fits this group exactly.

Deliverables

  1. Core: SessionManager.deleteSession(sessionPath) static method (core/session-manager.ts)

    • Move the trash-then-unlink logic from the TUI component verbatim (behavior-preserving: -- arg guard, exit-code-0-or-file-gone success check, error hint composition).
    • Returns the existing shape { ok, method: "trash" | "unlink", error? }.
    • Add a guard: refuse paths that do not end in .jsonl or do not exist, with descriptive errors (fail loudly, no silent no-op).
    • SessionManager is already exported from the package barrel — no new exports needed.
  2. TUI: session selector delegates to core (modes/interactive/components/session-selector.ts)

    • Replace the local deleteSessionFile() with SessionManager.deleteSession(); delete the local function.
    • No behavior change: confirmation flow, active-session guard, optimistic list filtering, trash-vs-deleted messaging all stay as-is.
  3. RPC: two new commands (modes/rpc/rpc-types.ts, modes/rpc/rpc-mode.ts)

    • list_all_sessions: calls SessionManager.listAll(), maps to the existing RpcSessionInfo DTO (path, id, cwd, name, created/modified ISO strings, messageCount, firstMessage). Response data: { sessions: RpcSessionInfo[] }, mirroring list_sessions.
    • delete_session { sessionPath }: handler checks sessionPath === session.sessionManager.getSessionFile() and returns an explicit error ("Cannot delete the currently active session") before delegating to SessionManager.deleteSession(). On ok: false, returns the composed error via the standard error response. Success response data: { method: "trash" | "unlink" }.
    • Both added to the RpcCommand and RpcResponse unions; error cases use the shared success: false shape (no new plumbing).
  4. RPC client: typed methods (modes/rpc/rpc-client.ts)

    • listAllSessions(): Promise<RpcSessionInfo[]> — unwraps .sessions, mirroring listSessions().
    • deleteSession(sessionPath): Promise<{ method: "trash" | "unlink" }>.
    • JSDoc on both, following existing method style.
  5. Docs (docs/rpc.md, packages/coding-agent/CHANGELOG.md)

    • #### list_all_sessions block under ### Session Listing (note: scans every project directory; may be slow with many sessions).
    • #### delete_session block under ### Session with request/response JSON and the three error cases (active session, nonexistent path, deletion failure).
    • CHANGELOG entries under Unreleased → Added.

Files to create or modify

File Change
packages/coding-agent/src/core/session-manager.ts Add static deleteSession() (logic moved from TUI)
packages/coding-agent/src/modes/interactive/components/session-selector.ts Delete local deleteSessionFile(), call core
packages/coding-agent/src/modes/rpc/rpc-types.ts Two command + two response union members
packages/coding-agent/src/modes/rpc/rpc-mode.ts Two case handlers
packages/coding-agent/src/modes/rpc/rpc-client.ts Two client methods
packages/coding-agent/test/session-manager-delete.test.ts New: core deletion tests
packages/coding-agent/test/rpc-session-commands.test.ts New: RPC handler + client wiring tests
packages/coding-agent/docs/rpc.md Two command doc blocks
packages/coding-agent/CHANGELOG.md Added entries

Testing approach

  • Core deletion (session-manager-delete.test.ts): temp-dir fixtures with real session JSONL files (pattern from session-info-modified-timestamp.test.ts). Cover: successful deletion (file gone; method reported), nonexistent path error, non-.jsonl path error. Trash-vs-unlink branching is environment-dependent — assert on the result shape and file absence, not on which method ran.
  • RPC wiring (rpc-session-commands.test.ts): follow the rpc-performance.test.ts pattern — no subprocess, no API keys. For handlers: extract testable data-mapping if warranted, or test via createTestSession() + direct handler invocation. For client: mock client.send with vi.fn(), assert command shape and data unwrapping for both new methods, including error propagation (send resolving success: false → method throws).
  • TUI regression: existing session-selector-path-delete.test.ts (confirmation-flow wiring) must keep passing unchanged.
  • Note: test/rpc.test.ts e2e suite is API-key-gated; not extended here (unit coverage suffices for path-based commands; switch_session precedent has no e2e test either).

Risks and open questions

  • Path safety: delete_session accepts an arbitrary path. Restricting to getSessionsDir() containment would break legitimately custom --session-dir setups, so the plan keeps parity with switch_session (also path-based, unrestricted) plus the .jsonl/existence guards. The dashboard server adds its own authz layer (per issue 307's design). Flagging for review.
  • listAll() swallows errors (returns [] on failure by design, matching TUI usage). The RPC handler will surface what core gives it; changing core error semantics is out of scope here.
  • The branch includes one pre-existing-failure fix commit (Copilot-retired model IDs in packages/ai tests, probe-verified replacements) per the repo's no-ignoring-failures rule — kept as a separate commit for reviewability.

Plan created by mach6

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Vitest coverage

Metric Covered Total Coverage
Statements 18258 34453 52.99%
Branches 9907 21346 46.41%
Functions 2878 5412 53.17%
Lines 16130 30646 52.63%

View full coverage run

@m-aebrer

m-aebrer commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Progress Update

Implemented the full plan:

  • Core: Added SessionManager.deleteSession() static — trash-first deletion with unlink fallback, moved verbatim from the TUI session selector, plus new upfront guards (non-.jsonl path, nonexistent file) that fail loudly with descriptive errors.
  • TUI: Session selector now delegates to the core method; local deleteSessionFile() and its imports removed. No behavior change to confirmation flow, active-session guard, or messaging.
  • RPC: New list_all_sessions (maps SessionManager.listAll() to the existing RpcSessionInfo DTO) and delete_session (active-session guard in handler, returns { method: "trash" | "unlink" }) commands with typed request/response union members.
  • Client: RpcClient.listAllSessions() and RpcClient.deleteSession() with JSDoc.
  • Tests: session-manager-delete.test.ts (delete success, missing file, non-session-file guard with file-survival assertion) and rpc-session-commands.test.ts (client command shapes, data unwrapping, error rejection for both commands). Existing selector delete-flow tests pass unchanged.
  • Docs: docs/rpc.md blocks for both commands (including error examples), CHANGELOG entries under Unreleased.

Validation:

  • npm run build clean
  • Full npm test green (all workspaces)
  • npx biome check clean, workspace links verified
  • Live RPC smoke test against compiled dist/cli.js: list_all_sessions returned 631 sessions; delete_session deleted a real file (method: unlink), refused a .txt path and a missing path with the expected error messages, and left the non-session file untouched.

The branch also carries one prior commit fixing pre-existing test failures (Copilot-retired model IDs, probe-verified replacements) — kept separate for reviewability.

Commit: dffee87


Progress tracked by mach6

@m-aebrer m-aebrer marked this pull request as ready for review July 6, 2026 15:01
@m-aebrer

m-aebrer commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Code Review

Critical

None.

Important

Finding 1 — Active-session guard is a raw lexical string compare (bypassable) · rpc-mode.ts ~574-578 · error-auditor, conf 87

The delete_session handler guards the live session with command.sessionPath === activePath, where activePath = getSessionFile(). Neither operand is canonicalized. getSessionFile() returns a resolve()d path in some open paths and a raw join()d path in others, while command.sessionPath arrives verbatim from an untrusted, cross-project RPC client. A differently-spelled path to the active session (relative, .. segments, doubled slashes, symlinked component, un-resolve()d form) slips past the equality check and gets trashed/unlinked. The in-memory SessionManager then keeps appendFileSync-ing to the deleted path, silently recreating a headerless file and corrupting continuity — the headline "active-session protection" fails open. Fix: resolve() (ideally realpathSync.native when present) both operands before comparing, and repeat the check inside SessionManager.deleteSession as defense-in-depth.

Note: code-reviewer traced the intended dashboard workflow (paths sourced from list_all_sessions, built via the same join(getSessionsDir(), ...)) and found the guard holds for that path — so this is a robustness/untrusted-input concern, not a break in the happy path.

Finding 2 — delete_session active-session guard has zero test coverage · rpc-mode.ts ~573-583 · test-reviewer, conf 90

The safety guard is completely untested. rpc-session-commands.test.ts only exercises the client wrapper with a mocked send; the "Cannot delete the currently active session" string there is a hand-written literal, not the handler's real output. A regression in the guard (wrong comparison, dropped guard, changed message) is caught nowhere. The repo already has the fix pattern: extract the handler body into an exported helper (like getPerformanceStatsData in rpc-performance.test.ts) and test with a mocked session.sessionManager.getSessionFile. Should ship with this PR.

Suggestions

Finding 3 — list_all_sessions swallows systemic failures, returns [] as apparent success · session-manager.ts ~1447-1496 → rpc-mode.ts ~643 · error-auditor, conf 82

The outer catch { return []; } in listAll() converts a whole-operation failure (unreadable sessions dir, EMFILE under load, I/O error) into an empty-but-successful RPC response. The frontend cannot distinguish "you have zero sessions" from "listing failed." The per-file and per-dir skips are legitimate degradation; the outer catch is the graceful-fallback-masking-failure pattern the project forbids. Consider letting the outer failure propagate so the RPC layer can respond success: false. (Note: the plan flagged listAll() error semantics as pre-existing / out of scope — included here for the assessor to weigh.)

Finding 4 — Path outside sessions directory is not rejected · session-manager.ts ~1391-1398 · completeness-checker, conf 100

Issue #312 acceptance criteria: "delete_session errors clearly when targeting the active session, a nonexistent path, or a path outside the sessions directory." The guard only checks .jsonl suffix + existence — no getSessionsDir() containment check. /tmp/evil.jsonl passes and is deletable via RPC. The plan explicitly and deliberately omitted the containment check (parity with switch_session, avoid breaking custom --session-dir, dashboard adds its own authz). The deviation is documented and reasoned, but acceptance criterion (c) is literally unmet — a product/reviewer call.

Finding 5 — deleteSession never asserts method; trash branch + error-hint composition untested · session-manager.ts ~1384-1428 · test-reviewer, conf 88

The success test asserts only ok === true and file-gone, never result.method. Since trash isn't installed in CI, that test silently exercises only the unlink-fallback branch; on a dev machine with trash installed it exercises the trash branch instead — same test, different path, asserting neither. The trash-success branch and the double-failure error-hint composition (${unlinkError} (${trashErrorHint}), ·-join, 200-char slice) are untested. Both are deterministically mockable via node:child_process. At minimum assert method and add a mocked-spawnSync trash-branch case.

Finding 6 — list_all_sessions DTO mapping (Date → ISO string) untested · rpc-mode.ts ~642-655 · test-reviewer, conf 85

The handler maps SessionInfo → RpcSessionInfo, converting created/modified via .toISOString() — the exact serialization contract clients depend on. The client test hardcodes ISO strings, so it can't catch a dropped .toISOString() or a renamed field. Extract and test the mapping (dovetails with Finding 7).

Finding 7 — Duplicated SessionInfo → RpcSessionInfo mapping · rpc-mode.ts ~628-654 · simplifier, conf 88

The new list_all_sessions mapping block is byte-for-byte identical to the pre-existing list_sessions mapping. Extract a module-level toRpcSessionInfo(s: SessionInfo): RpcSessionInfo helper and call sessions.map(toRpcSessionInfo) from both handlers. Behavior-preserving, and makes the RPC shape a single source of truth so the two handlers can't drift. Pairs naturally with Finding 6 (the extracted helper is the unit to test).

Finding 8 — Unreachable ?? stderr fallback in trash error hint · session-manager.ts ~1407 · simplifier, conf 82

parts.push(stderr.split("\n")[0] ?? stderr)String.prototype.split always returns a non-empty array, and the tsconfig does not enable noUncheckedIndexedAccess, so [0] is typed string and the ?? stderr branch is unreachable at both runtime and type level. Behavior-preserving to drop it. Minor — and it was moved verbatim from the TUI original, so pre-existing dead code, not introduced here.

Strengths

  • Moving deletion to core added .jsonl + existence guards the TUI original lacked — a genuine hardening, verified behavior-preserving byte-for-byte otherwise.
  • list_all_sessions mapping reuses the exact shape of the sibling list_sessions handler — consistent, low-surprise (see Finding 7 to dedupe).
  • trash-not-installed case (status === null) correctly falls through to unlink; unlink failure is caught and surfaced loudly as success: false.
  • Docs (rpc.md) and CHANGELOG accurately describe both commands including the active-session error.
  • code-reviewer resolved every swapped model ID against the built registry (claude-opus-4.5, claude-sonnet-5, claude-opus-4-1 all resolve; supportsXhigh assertion still valid); tsc --noEmit clean; new tests 7/7 green.

Agents run: code-reviewer, error-auditor, test-reviewer, completeness-checker, simplifier


Reviewed by mach6

@m-aebrer

m-aebrer commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Review Assessment

Independent verification of the review above (#315 (comment)), each finding checked against source.

Classifications

Finding Classification Reasoning
1 — No path canonicalization on active-session guard genuine (low–med, not high) Confirmed: getSessionFile() returns a resolve()d/absolute path (session-manager.ts:697,747), but the guard command.sessionPath === activePath (rpc-mode.ts:574-576) and deleteSession() use the raw client path — no resolve(). A non-canonical path (./../symlink/relative) to the active session passes === and gets deleted; after unlink, _persist (:843) appendFileSyncs to the deleted path, recreating a headerless file. Requires a misbehaving client on the trusted local RPC channel — a well-behaved dashboard passes back the exact s.path and matches. Fix is a one-liner, worth doing.
2 — Active-session guard has zero test coverage genuine Confirmed: no test invokes the handler. rpc-session-commands.test.ts:45-57 tests the client rejecting on a hand-written success:false literal via mocked send — never exercises the guard. Extract-and-test precedent is real (getPerformanceStatsData). Security-relevant new code, untested.
3 — listAll() outer catch { return [] } swallows failures deferred (pre-existing) Confirmed the swallow exists (:1493-1495), but git show master confirms it pre-dates this PR and is already consumed by interactive-mode.ts / main.ts. This PR only exposes existing behavior over RPC. Out of scope — but given the "fail loudly" preference, a tracking issue is warranted.
4 — No containment check (path outside sessions dir) genuine (acceptance-criterion gap) Confirmed: issue #312 explicitly lists "or a path outside the sessions directory." Guard checks only .jsonl + existsSync (:1385-1391) — no getSessionsDir() check, making delete_session an open .jsonl-deletion primitive. Design tension is real (custom --session-dir, switch_session parity), but the criterion is unmet — needs implementation or an explicit recorded waiver.
5 — Success test never asserts method; trash + hint branches untested genuine (partial) Confirmed: session-manager-delete.test.ts:27-29 asserts only ok + file-gone. With trash absent in CI only the unlink fallback runs; trash-success branch and getTrashErrorHint() (:1400-1412) are new, uncovered. Testable via spawnSync mock.
6 — list_all_sessions Date→ISO mapping untested genuine Confirmed: no test invokes the handler; the client test hardcodes ISO strings, so a dropped .toISOString() (:648-649) isn't caught.
7 — Duplicated SessionInfo → RpcSessionInfo mapping nitpick Confirmed byte-for-byte identical (:628-637 vs :643-652). DRY, not correctness — but extracting toRpcSessionInfo() yields the single testable unit that resolves Finding 6, so do it together.
8 — Unreachable ?? stderr fallback nitpick Confirmed: noUncheckedIndexedAccess absent from tsconfig.base.json, split always returns non-empty → [0] is string, ?? branch is dead. Harmless, stylistic. Moved verbatim from TUI (pre-existing).

Tally: 5 genuine · 2 nitpick · 0 false positive · 1 deferred

Action Plan

Fix before merge, priority order:

  1. Finding 4 — Enforce sessions-directory containment, or record an explicit waiver. Unmet acceptance criterion; combined with Finding 1 makes delete_session a broad file-deletion primitive. Either add a getSessionsDir() containment check in deleteSession(), or strike the criterion on issue Expose cross-project session listing and session deletion over RPC #312 with the --session-dir/parity rationale documented.
  2. Finding 1 — Canonicalize before compare + delete. resolve() command.sessionPath once; compare the resolved value against activePath and pass the resolved value into deleteSession(). Closes the guard-bypass → headerless-corruption path.
  3. Findings 2, 5, 6 — Add tests for the new paths (repo "new testable code ships with tests" rule): active-session guard (extract a helper à la getPerformanceStatsData, or drive the handler with a session stub); assert result.method + add trash-success and double-failure cases via spawnSync mock; cover the Date→ISO mapping.
  4. Finding 7 — Extract toRpcSessionInfo() shared by both handlers, as part of step 3 (its unit test satisfies Finding 6).

Follow-up (not blocking):

  • Finding 3 — open a separate issue for listAll()'s silent catch { return [] } (conflicts with "fail loudly"; pre-existing, shared with TUI/main consumers).

Skip: Finding 8 (harmless dead-code nitpick).


Assessment by mach6

- Enforce sessions-directory containment in SessionManager.deleteSession;
  custom --session-dir setups pass their dir via a new allowedDirs option
- Canonicalize the path before every guard (suffix, active-session,
  containment, existence) and before deletion, so a non-canonical spelling
  cannot bypass protection; add defense-in-depth activeSessionPath check
- Canonicalize both operands of the RPC active-session compare
- Extract shared toRpcSessionInfo() helper for list_sessions/list_all_sessions
- Add tests: canonicalized active-session guard, containment refusal,
  method/trash branches + error-hint composition, Date->ISO DTO mapping
- Document containment/canonicalization in rpc.md and CHANGELOG
@m-aebrer

m-aebrer commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Progress Update

Addressed review findings 1, 2, 4, 5, 6, 7 from the assessment.

Genuine issues fixed:

  • Finding 4 (containment)SessionManager.deleteSession now refuses any path outside the global sessions directory. Custom --session-dir setups still work via a new allowedDirs option (the RPC handler passes the active session dir; the TUI passes the active session file's dirname). Satisfies the issue's "path outside the sessions directory" acceptance criterion.
  • Finding 1 (canonicalization) — the path is resolve()d once and every guard (suffix, active-session, containment, existence) plus the deletion itself use the canonical form. The RPC active-session compare is now resolve(a) === resolve(b), and deleteSession gained a defense-in-depth activeSessionPath check so the guard can't be bypassed by a non-canonical spelling.
  • Finding 7 (dedupe) — extracted an exported toRpcSessionInfo() helper; both list_sessions and list_all_sessions call it (single source of truth for the DTO shape).

Tests added:

  • Finding 2 — proves a non-canonical path to the active session is still refused.
  • Finding 5spawnSync mocked for deterministic branch selection: asserts result.method, covers the trash-success branch, the "file gone after nonzero exit" heuristic, and the double-failure error-hint composition (first-stderr-line-only).
  • Finding 6 — verifies the Date -> ISO string conversion in toRpcSessionInfo.

Docs: rpc.md and CHANGELOG updated to describe the containment/canonicalization behavior.

Not in this batch:

  • Finding 3 (listAll() silent catch { return [] }) — deferred. Pre-existing behavior shared with the TUI/main consumers; needs a separate tracking issue per the assessment.
  • Finding 8 (unreachable ?? stderr fallback) — skipped as a nitpick, per the finding selection.

Validation: npm run build clean · full suite green (3968 passed, 0 failed) · biome clean · existing selector delete-flow tests pass unchanged.

Commit: 9908b6f


Progress tracked by mach6

@m-aebrer

m-aebrer commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Code Review (second pass — post-fix)

Re-review of the current state after commit 9908b6f addressed the first-cycle findings. The containment guard, canonicalization, active-session defense-in-depth, the toRpcSessionInfo dedupe, and the added tests are all present and correct for the lexical/happy paths. Four new findings, led by a symlink-canonicalization gap that two independent agents (code-reviewer and error-auditor) surfaced separately.

Critical

None.

Important

Finding 1 — resolve() is lexical: symlinked path components bypass the containment and active-session guards, and the docstring/CHANGELOG claim otherwise · session-manager.ts ~1385-1418, rpc-mode.ts ~594 · code-reviewer conf 90 + error-auditor conf 90

The fix canonicalizes with resolve(sessionPath), and the JSDoc + CHANGELOG explicitly claim this defeats "symlinked components." That claim is false: path.resolve() is purely lexical — it collapses ./.. and absolutizes, but does no filesystem access and does not dereference symlinks. Only fs.realpathSync does. Concrete consequences:

  • Containment escape: if <sessionsDir>/link is a symlink to /etc, then sessionPath = ".../sessions/link/x.jsonl" resolves lexically under the sessions root → isContained is trueunlink/trash follows the symlink and deletes /etc/x.jsonl. The .jsonl suffix + containment guard are the only protections and both are lexical.
  • Active-session bypass: resolvedPath === resolve(activeSessionPath) compares lexical strings, so a symlinked-directory spelling of the live session file slips past and the active session file is deleted out from under the running session — after which _persist keeps appendFileSync-ing to the deleted path.

This codebase already knows the right pattern — realpathSync is used for exactly this symlink-escape defense in core/tools/tmp-read.ts, core/nested-context.ts, core/resource-loader.ts, core/skills.ts, and core/tools/file-mutation-queue.ts. deleteSession is the lone exception. Exploit requires a pre-planted symlink inside a write-accessible dir, so practical severity is bounded — but a security guard whose docs overstate its protection is exactly the false-safety claim that misleads maintainers. Fix: canonicalize with realpathSync (realpath the containing dir, re-join the basename; fall back to lexical resolve on ENOENT as the sibling call sites do) before the containment and active-session comparisons — or, if symlink hardening is out of scope, correct the docstring and CHANGELOG to drop the "symlinked components" claim.

Finding 2 — One session file with a missing/invalid header timestamp throws and blanks the entire list_all_sessions response · session-manager.ts ~606, rpc-mode.ts toRpcSessionInfo ~52 · error-auditor conf 85

buildSessionInfo guards modified (falls back to stats.mtime) but leaves created: new Date(header.timestamp) unguarded — the only header validation is type === "session". A header with a missing/malformed timestamp yields an Invalid Date, and the record is not filtered out. Later toRpcSessionInfo calls s.created.toISOString(), and Invalid Date.toISOString() throws RangeError: Invalid time value. Because that runs inside sessions.map(toRpcSessionInfo), the throw propagates and the whole command fails. list_sessions shares the code, but this PR amplifies exposure massively: listAll aggregates files from every project, so the odds of hitting one legacy/partial/hand-crafted header are far higher, and one bad file now blanks the entire multi-project listing rather than being dropped. Fix: guard created the same way modified is (fall back to stats.mtime/epoch when NaN), so a bad entry degrades locally instead of poisoning the map; optionally make toRpcSessionInfo defensive against Invalid Date.

Finding 3 — RPC server-side handlers (delete_session / list_all_sessions) are never exercised — only the client wrapper with a mocked send · rpc-mode.ts ~592, ~654 · test-reviewer conf 95

Answering the first review's open question directly: no test exercises the RPC handler's active-session guard. The three rpc-session-commands.test.ts cases overwrite client.send with a vi.fn(), so they validate only the client wrapper (request shape + unwrap + reject-on-success:false). The handler's own untested wiring: its duplicate pre-check active-session guard; passing allowedDirs: [getSessionDir()] and activeSessionPath: activePath into core (a regression here would silently break containment/active-session protection at the RPC boundary); the result.ok===false → error / result.method → success mapping; and list_all_sessions mapping through toRpcSessionInfo. This is the boundary exposed to external frontends, so the guard wiring is security-relevant. Fix: drive handleCommand with a stubbed session whose getSessionFile() returns a known active path, asserting the active-session error is returned without touching the filesystem, a valid path returns { method }, and list_all_sessions returns ISO-mapped DTOs — or extract the two case bodies into small exported functions and test those (the getPerformanceStatsData precedent).

Suggestions

Finding 4 — The trashResult.error.message branch of the error-hint composer is never covered · session-manager.ts getTrashErrorHint ~1433-1441 · test-reviewer conf 88

The double-failure test mocks spawnSync to return status:1 with stderr but no error, so only the stderr branch runs. The if (trashResult.error) parts.push(trashResult.error.message) line — and the ·-join of both an error.message and a stderr line plus the .slice(0, 200) truncation — is never exercised. Fix: point sessionPath at a directory (so unlink throws) and mock spawnSync to return both error and stderr; assert the composed message contains both, the · separator, and honors the 200-char cap.

Strengths

  • The containment check resolvedPath.startsWith(root + sep) is correct — the + sep properly blocks the prefix-sibling bypass (/x/sessions-evil vs root /x/sessions), and each root is resolve()-normalized.
  • allowedDirs wiring is correct: RPC passes getSessionDir(), TUI passes the active file's dirname; both cover default-layout and custom --session-dir sessions.
  • TUI delegation is byte-for-byte behavior-preserving vs the old deleteSessionFile; the new containment guard is a strict addition that regresses no legitimate deletion path.
  • toRpcSessionInfo dedupe cleanly makes the DTO shape a single source of truth; Date→ISO mapping is directly unit-tested.
  • The trash-success heuristic (status===0 || !existsSync) traces cleanly through all three branches (not-installed / non-zero-with-file / unlink-fallback); the unlink catch composes both errors and returns ok:false with no silent swallow.
  • Completeness: all six issue Expose cross-project session listing and session deletion over RPC #312 acceptance criteria are met, including the previously-missing "path outside the sessions directory" error.

Also noted (below threshold): spawnSync("trash", …) has no timeout — a hung trash (stuck FUSE/network location) would freeze the synchronous RPC server; cheap insurance to add one. And resolvedPath.startsWith("-") in trashArgs is now dead code (a resolve()d path is always absolute), so the ["--", resolvedPath] branch is unreachable. Finding 3 from the first cycle (listAll's outer catch { return [] }) remains deferred; the PR also adds a new inner per-directory catch { dirFiles.push([]) } that silently drops unreadable project dirs — same "fail loudly" tension, worth a diagnostic.

Agents run: code-reviewer, error-auditor, test-reviewer, completeness-checker


Reviewed by mach6

@m-aebrer

m-aebrer commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Review Assessment (second pass)

Independent verification of the review above (#315 (comment)), each finding checked against source at HEAD 9908b6f.

Classifications

Finding Classification Reasoning
1 — resolve() doesn't defeat symlinked components; false doc/CHANGELOG claim genuine (blocks merge) resolve is imported from "path" (session-manager.ts:18) — purely lexical, does not dereference symlinks. The codebase uses realpathSync for exactly this symlink-escape defense everywhere else (tmp-read.ts even comments "realpathSync dereferences symlinks so /tmp/evil -> /etc/passwd is caught"; also nested-context, resource-loader, skills, file-mutation-queue). The JSDoc (:1384) explicitly claims canonicalization defeats "symlinked components" — false. Exploit is real (<sessionsDir>/link -> /etc, then .../link/x.jsonl resolves under the root lexically but unlink follows the link) but requires a pre-planted symlink in a user-owned dir, so it's defense-in-depth, not remote.
2 — Unguarded created crashes entire list_all_sessions genuine (blocks merge) created: new Date(header.timestamp) (:606) is unguarded, while modified uses getSessionModifiedDate with a stats.mtime fallback (:543-544). new Date(bad) yields Invalid Date without throwing, so buildSessionInfo's try/catch does not filter it — the record survives. Then toRpcSessionInfo calls s.created.toISOString() (rpc-mode.ts:57), which throws RangeError inside sessions.map(...), failing the whole command. listAll aggregating across all projects genuinely widens exposure vs per-project list_sessions.
3 — RPC server-side handlers untested genuine rpc-session-commands.test.ts only tests the pure toRpcSessionInfo and RpcClient methods with a mocked send. No test invokes handleCommand/the handler branches — the handler's own active-session pre-check (rpc-mode.ts:593) and the allowedDirs/activeSessionPath wiring are unexercised. Lower severity because core deleteSession is well-covered; only the thin handler wiring is untested. New testable code should ship with tests.
4 — trashResult.error hint branch uncovered nitpick Accurate: the double-failure test overrides the mock with stderr but no error field, so if (trashResult.error) parts.push(...) (~:1435) is never taken. Minor defensive branch, not a correctness gap.
Note (i) — spawnSync("trash") has no timeout genuine (minor) spawnSync blocks the event loop; a hung trash CLI would freeze all RPC command processing. No timeout option passed. Real robustness edge, low likelihood.
Note (ii) — resolvedPath.startsWith("-") dead code genuine (minor) resolve() always returns an absolute path, so it can never start with -; the ["--", resolvedPath] branch (:1424) is unreachable. Per project convention dead code is a liability.

Tally: 3 genuine (blocking) · 1 nitpick · 0 false positive · 0 deferred · 2 genuine-minor

Action Plan

Fix before merge, priority order (security/integrity → correctness → tests → minor):

  1. Finding 1 — canonicalize with realpathSync before the containment and active-session guards (fall back to lexical resolve only on ENOENT, matching tmp-read.ts/file-mutation-queue.ts). At minimum the JSDoc + CHANGELOG "symlinked components" claim must be corrected — it is currently false, violating the "docs must not overstate" and "security trumps all" rules. Add a symlinked-parent test (the existing bypass test only covers ., not symlinks).
  2. Finding 2 — guard created against Invalid Date the same way modified is guarded (fall back to stats.mtime, or filter the record when the header timestamp is invalid). Add a malformed-timestamp test asserting the list still returns.
  3. Finding 3 — add server-side handler tests exercising delete_session and list_all_sessions directly (active-session pre-check, allowedDirs/activeSessionPath wiring, DTO mapping).
  4. Note (i) — add a timeout to spawnSync("trash", …). Cheap hardening against event-loop freeze.
  5. Note (ii) — remove the dead resolvedPath.startsWith("-") branch (:1424).

Skip: Finding 4 (nitpick — trivial coverage gap on a defensive hint branch; optionally fold a trashResult.error-present assertion into the new tests, but non-blocking).

No deferred items.


Assessment by mach6

…er test gaps

Addresses the second-pass review findings on the RPC session commands:

- Symlink canonicalization (finding 1): deleteSession now canonicalizes
  with realpathSync (dereferencing symlinked parent components), not just
  lexical resolve(), so a symlinked path cannot bypass the containment or
  active-session guards. Allowed roots are canonicalized the same way.
  Corrected the JSDoc, CHANGELOG, and docs/rpc.md which overstated the
  protection lexical resolve() provided.
- created-timestamp guard (finding 2): buildSessionInfo now falls back to
  file mtime when the header timestamp is missing/malformed, so one bad
  file no longer throws RangeError through toISOString() and blanks the
  entire list_all_sessions response.
- Testable RPC handlers (finding 3): extracted deleteSessionForRpc and
  listAllSessionsForRpc helpers (getPerformanceStatsData precedent) and
  removed the redundant lexical active-session pre-check; core is
  authoritative.
- spawnSync trash call gains a timeout; removed the unreachable
  startsWith('-') branch (canonicalized paths are always absolute).

Adds tests for symlinked-parent bypass refusal, active-session-via-symlink
refusal, trash+stderr hint composition, missing/malformed timestamp
fallback, and full server-side handler wiring.
@m-aebrer

m-aebrer commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Progress Update

Addressed all five action-plan items from the second-pass assessment.

Genuine issues fixed:

  • Finding 1 (symlink bypass + false docs)deleteSession now canonicalizes with realpathSync (dereferencing symlinked parent components), not just lexical resolve(), so a symlinked path can no longer bypass the containment or active-session guards. Allowed roots are canonicalized the same way (also fixes symlinked roots like macOS /tmp). The JSDoc, CHANGELOG, and docs/rpc.md were corrected — they had overstated the protection lexical resolve() provided. New helpers canonicalizeForDeletion() / canonicalizeDir() follow the established tmp-read.ts / file-mutation-queue.ts pattern.
  • Finding 2 (created crashes list_all_sessions) — added getSessionCreatedDate() mirroring the existing modified guard: falls back to file mtime when the header timestamp is missing or malformed, so one bad file can no longer throw RangeError through .toISOString() and blank the entire cross-project listing.
  • Finding 3 (untested RPC handlers) — extracted deleteSessionForRpc() and listAllSessionsForRpc() exported helpers (the getPerformanceStatsData precedent) so the server-side wiring is unit-testable; removed the now-redundant lexical active-session pre-check in the handler since core is authoritative, and dropped the unused resolve import.

Minor items fixed:

  • Note (i) — added a timeout to the spawnSync("trash", …) call so a hung trash can't freeze the synchronous caller (falls through to unlink).
  • Note (ii) — removed the unreachable startsWith("-") -- branch; a canonicalized path is always absolute.

Tests added (13):

  • Symlinked-parent-directory containment bypass is refused, and the active session addressed through a symlinked parent is refused (proves finding 1).
  • trash spawn-error + stderr hint composition (folds in the finding 4 nitpick coverage).
  • Missing and malformed header timestamps fall back to a valid Date, and a bad-timestamp session serializes through toRpcSessionInfo without throwing (finding 2).
  • Full deleteSessionForRpc wiring: active-session refusal without touching the filesystem, valid deletion reporting method, nonexistent-path error, containment error; plus listAllSessionsForRpc Date→ISO mapping (finding 3).

Not changed:

  • Finding 3 from the first cycle (listAll() silent catch { return [] }) remains deferred — pre-existing, shared with the TUI/main consumers.

Validation: tsc clean · biome clean · npm run build clean · pre-commit hook full suite green (3998 passed, 0 failed) · TUI selector delete-flow regression tests pass unchanged.

Commit: 44bce33


Progress tracked by mach6

@m-aebrer

m-aebrer commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Code Review (third pass — post-fix, code + errors aspects)

Re-review of the current state after commit 44bce33 addressed the second-cycle findings. The symlink canonicalization (realpathSync), containment guard, created-timestamp guard, and DTO dedupe are all present and correct for the lexical/happy paths and the covered symlink tests. Two new genuine findings emerged, both on the containment guard's edges.

Agents run: code-reviewer, error-auditor

Critical

None.

Important

Finding 1 — --no-session RPC widens the deletion containment guard to the entire cwd subtree · rpc-mode.ts ~84-86 → session-manager.ts canonicalizeDir ~564-568, containment ~1455-1456 · code-reviewer conf 85

deleteSessionForRpc unconditionally forwards the active session dir as an allowed root:

const result = await SessionManager.deleteSession(sessionPath, {
    allowedDirs: [sessionManager.getSessionDir()],
    activeSessionPath: activePath,
});

When the RPC process is started with an in-memory session (dreb --rpc --no-sessionSessionManager.inMemory(), main.ts:446), getSessionDir() returns the empty string (inMemory constructs with sessionDir = ""). That "" flows into the containment guard, where every allowed root is passed through canonicalizeDir:

const allowedRoots = [getSessionsDir(), ...(opts.allowedDirs ?? [])].map((dir) => canonicalizeDir(dir));

canonicalizeDir("") calls resolve("")process.cwd(), silently adding the working directory as an allowed deletion root. The guard resolvedPath.startsWith(root + sep) then accepts any *.jsonl path anywhere under the project working tree. In memory mode there is also no active-session guard to help (no active file), so .jsonl is the only remaining filter. This is a real widening of the exact security boundary the containment guard exists to enforce, on the untrusted external-frontend RPC surface this feature targets. The TUI caller is already safe — it filters: currentSessionFilePath ? [dirname(...)] : [] (session-selector.ts:768). The RPC caller regressed relative to that pattern. Fix: .filter(Boolean) the allowed roots and/or have deleteSessionForRpc pass [] when getSessionDir() is empty.

Finding 2 — canonicalizeDir swallows ALL realpathSync errors, not just ENOENT — degrading the symlink-containment guard · session-manager.ts ~564-568 · error-auditor conf 90

function canonicalizeDir(dir: string): string {
    const lexical = resolve(dir);
    try {
        return realpathSync(lexical);
    } catch {          // catches EVERYTHING, not just ENOENT
        return lexical;
    }
}

The doc comment claims the fallback fires only "when the directory does not exist yet (realpath throws ENOENT)." But the bare catch also fires on EACCES (untraversable component of a custom --session-dir), ELOOP (symlink loop), and ENOTDIR — silently reverting to the purely-lexical resolve(), which does not dereference symlinks. That defeats the exact protection realpath was added to provide: a session subdir <sessionsDir>/proj/sub that is a symlink pointing outside the tree, if realpathSync throws ELOOP/EACCES rather than resolving, falls back to the lexical path, startsWith(root + sep) passes, and unlink follows the directory symlink and deletes a file outside the sessions tree. The same lexical-vs-realpath divergence can also make the active-session guard mismatch (target canonicalized one way, active path the other → strings differ → active session becomes deletable). canonicalizeForDeletion inherits this via canonicalizeDir(dirname(...)). Fix: only fall back on err.code === "ENOENT"; rethrow (or refuse deletion) on any other error — matching the intent documented in tmp-read.ts.

Suggestions

Finding 3 — list_all_sessions silently drops entire projects (or the whole listing) on directory read failure, now over an external API · session-manager.ts listAll inner catch { dirFiles.push([]) } ~1410-1418 and outer catch { return [] } · error-auditor conf 85

Verified pre-existing and unchanged by this PR (dates to at least 0ae3f77), so not a regression — but this PR newly promotes it from a TUI-only path to an externally-consumed RPC surface (list_all_sessions). A per-project readdir failure (EACCES, transient I/O, dir deleted mid-scan) silently omits that project's sessions; any other failure collapses the whole listing to []. The external frontend receives success: true with a silently-incomplete or silently-empty list and no signal. Given the project's stated aversion to silent fallbacks, exposing this swallow over a new external API warrants a conscious decision (partial-failure indicator, or surface an error) rather than inheriting it by default. Carried forward from the first cycle's deferred Finding 3 — the RPC exposure is the new wrinkle.

Finding 4 — TOCTOU between canonicalization/containment check and unlink · session-manager.ts deleteSession canonicalize ~1442 vs spawnSync/unlink ~1476-1490 · error-auditor conf 80

The path is canonicalized and containment-checked once, then trash/unlink re-resolve the path string later. If a directory component is swapped for a symlink between check and deletion, the deletion follows the current symlink outside the validated tree and still reports ok: true. For a single-user local session store this is largely theoretical (requires a concurrent local writer racing inside the sessions tree) — noted for completeness. A robust fix (open parent dir, unlink by relative name at fd) is likely overkill for this threat model.

Strengths

  • canonicalizeForDeletion correctly dereferences symlinked parent components via realpathSync while preserving the basename, so a symlinked session file is unlinked directly rather than followed; the symlink-parent and ./.. bypass tests confirm this.
  • Containment startsWith(root + sep) correctly avoids the sibling-prefix collision (sessions/ vs sessions-evil/).
  • getSessionCreatedDate mirrors the existing getSessionModifiedDate NaN guard and correctly prevents the Invalid DatetoISOString() RangeError from taking down the whole list_all_sessions map.
  • The trash→unlink flow (trash error, non-zero status, and timeout status-null cases all fall through to unlink; genuine unlink failure propagated as structured ok: false with the trash hint) is loud and correct end-to-end; getData in rpc-client rejects on success: false.
  • The absolute-path / no----guard reasoning for trash is valid since resolvedPath is always absolute post-canonicalization.

Reviewed by mach6

@m-aebrer

m-aebrer commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Review Assessment (third pass)

Independent verification of the review above (#315 (comment)), each finding checked against source at HEAD 44bce33.

Verification notes

  • resolve("")process.cwd() — confirmed empirically.
  • inMemory sets sessionDir = ""session-manager.ts:1365-1366 (new SessionManager(cwd, "", undefined, false)).
  • --no-session + --rpc coexist — createSessionManager (main.ts:445-446) returns inMemory() whenever parsed.noSession, independent of mode; rpc mode then runs that session. No guard forbids the combination.
  • RPC delete wiring — deleteSessionForRpc (rpc-mode.ts:83-89) passes allowedDirs: [sessionManager.getSessionDir()], called live by the handler.
  • canonicalizeDir bare catch — session-manager.ts:564-570; doc says ENOENT-only, catch {} swallows all. New in this PR.
  • listAll catches — inner catch { dirFiles.push([]) } + outer catch { return [] } confirmed pre-existing on master (flatten commit 2d17287).
  • TUI asymmetry — session-selector.ts:768 filters empty (currentSessionFilePath ? [dirname(...)] : []); RPC does not.
  • Test gap — rpc-session-commands.test.ts:176 tests "outside" with a real allowedDir, never the empty-string widening; no test exercises non-ENOENT realpath failure.

Classifications

Finding Classification Reasoning
1 — --no-session RPC widens containment to cwd genuine (blocks merge) Verified end-to-end: getSessionDir() returns "" for in-memory sessions, --rpc --no-session is reachable, canonicalizeDir("") = realpathSync(cwd) adds cwd to allowedRoots, so startsWith(cwd + sep) accepts any .jsonl under the working tree. TUI filters empty; RPC (rpc-mode.ts:85) does not. Real guard-widening on the untrusted RPC surface.
2 — bare catch in canonicalizeDir defeats symlink guard genuine (blocks merge) The catch swallows EACCES/ELOOP/ENOTDIR and silently reverts to non-dereferencing lexical resolve(), contradicting its own doc ("ENOENT") and silently degrading a security guard — exactly the graceful-fallback pattern the project rejects. Full remote exploit is borderline (existsSync catches some cases), but defense-in-depth + fail-loud policy make the fix warranted.
3 — listAll silently drops projects/listing, now over RPC genuine (low priority) The bare catches are genuinely pre-existing (confirmed on master), so not a regression — but the new list_all_sessions RPC surface makes the silent [] reachable programmatically, where a client can't distinguish "no sessions" from "listing crashed." Given the fail-loud stance, worth surfacing rather than deferring indefinitely.
4 — TOCTOU between check and unlink/trash nitpick Real in theory — the path string is re-resolved by the OS at spawnSync("trash", …)/unlink after the containment check — but negligible for a single-user local session store with no adversarial concurrent writer.

Tally: 3 genuine (2 blocking + 1 low-priority) · 1 nitpick · 0 false positive · 0 deferred

Action Plan

Fix before merge, priority order:

  1. Finding 1 — stop the cwd widening. In deleteSessionForRpc (rpc-mode.ts:83-89), filter the empty session dir, mirroring the TUI: const dir = sessionManager.getSessionDir(); allowedDirs: dir ? [dir] : []. Also harden core deleteSession to drop empty-string allowedDirs entries so no future caller can re-introduce this. Needs a test: RPC delete against an in-memory / empty-sessionDir manager must refuse a .jsonl sitting in cwd (the existing rpc-session-commands.test.ts:176 case uses a real dir and misses this).
  2. Finding 2 — fail closed on non-ENOENT realpath errors. Narrow the catch in canonicalizeDir (session-manager.ts:566-568) to err.code === "ENOENT"; on any other errno, propagate (or make deleteSession refuse the deletion) rather than silently lexical-resolving. Fix the doc comment to match. Needs a test: force a non-ENOENT realpathSync error and assert deletion is refused, not silently lexically resolved.
  3. Finding 3 — surface listAll failures (low priority; fast-follow acceptable). At minimum log/emit a warning when a project dir is skipped or the top-level listing throws, so RPC clients aren't handed a silently-truncated result. Pre-existing, so a small follow-up is acceptable if fixing inline risks scope-creep — but it shouldn't ship indefinitely given the fail-loud policy.

Skip: Finding 4 (TOCTOU) — no action for the single-user local store; note as a known theoretical limitation.

Test flags: Findings 1 and 2 both introduce testable behavior and, per the repo's "new testable code ships with tests" rule, must not merge without the tests above.


Assessment by mach6

@m-aebrer

m-aebrer commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Review Assessment — Addendum (supersedes the prior action plan)

Maintainer direction after the third-pass review: the whole containment guard is the wrong tool for the threat model and should be removed, not further hardened. This addendum revises the action plan accordingly. The mechanical findings above are still individually correct — but most of them are edge cases inside a guard that shouldn't exist in this form, so fixing them one by one has been chasing a moving target across three cycles.

Why containment is the wrong frame here

  • The spec contradicts itself. Acceptance criterion (c) says delete_session must "error clearly when targeting … a path outside the sessions directory." But the same issue's Technical Notes say: "switch_session already takes a session path cross-project; delete_session should follow the same path-based addressing." switch_session has no containment guard — it will load any path handed to it. Criterion (c) and the "mirror switch_session" guidance are in direct tension.
  • It defends a non-threat. The RPC channel is a trusted, local, single-user surface. switch_session (which loads arbitrary session files) ships guard-free; adding a bespoke deletion-only containment boundary defends a boundary the sibling command doesn't, at the cost of a containment guard implemented via lexical path-prefix matching — a class of bug (symlink deref, empty-string root → cwd, TOCTOU) that does not converge in a single fix. Three review cycles are the evidence.
  • The dashboard (Build first-class dreb web dashboard #307) already owns authz. Per issue Build first-class dreb web dashboard #307's design, the frontend adds its own authorization layer. Duplicating a weaker version of that in core deletion buys nothing.

Decision (maintainer): take Option 1 — drop the containment guard, match switch_session's unrestricted path-based addressing, and strike acceptance criterion (c) on #312 with this rationale recorded. This collapses the entire finding class rather than patching its edges.

Revised classification under Option 1

Finding Prior class Revised disposition
1 — --no-session RPC widens containment to cwd genuine (blocking) Dissolved by removal. No allowedDirs plumbing → no empty-string → cwd widening. Nothing to fix; the code path goes away.
2 — canonicalizeDir bare catch swallows non-ENOENT errors genuine (blocking) Severity collapses. canonicalizeDir/containment canonicalization exists to serve the containment tree; with containment gone it's no longer a security guard. Any residual canonicalization needed for the active-session compare (criterion a) is a single-path resolve(), not a symlink-escape defense.
3 — listAll silently drops projects/listing over RPC genuine (low priority) Unchanged — stands on its own. Independent of containment; still a fail-loud concern now that list_all_sessions exposes the silent [] programmatically. Fast-follow acceptable.
4 — TOCTOU between check and unlink/trash nitpick Dissolved by removal. It was a containment-check-vs-unlink race; no containment check, no race to note.

Revised action plan

  1. Remove the containment guard (allowedDirs, canonicalizeDir, the startsWith(root + sep) check, and the RPC/TUI allowedDirs wiring). delete_session accepts a session path with the same addressing as switch_session.
  2. Keep the two acceptance criteria that are real and cheap: refuse the active session (criterion a — string-compare the resolved active path) and refuse a nonexistent / non-.jsonl path (criterion b). Both fail loudly. No containment tree required.
  3. Strike acceptance criterion (c) on issue Expose cross-project session listing and session deletion over RPC #312 with the rationale above (trusted local channel, switch_session parity, dashboard owns authz). Record the waiver so this doesn't get re-flagged in a future cycle.
  4. Finding 3 (fast-follow): let listAll failures surface instead of a silent [], so RPC clients can tell "empty" from "crashed." Pre-existing; can be a small follow-up.
  5. Tests: update/remove the containment tests; keep active-session and nonexistent-path tests. The empty-string and non-ENOENT-realpath tests requested in the prior plan are moot once the guard is gone.

Net: removing ~a guard's worth of code retires Findings 1, 2, and 4 outright and aligns delete_session with its sibling command. Finding 3 is the only genuine item that survives, and it's low-priority fail-loud cleanup.

This addendum reflects a maintainer scope decision; it does not itself change code.


Assessment by mach6

delete_session now uses the same unrestricted, cross-project path addressing
as switch_session: resolve() for the active-session compare and deletion, no
sessions-directory containment guard. Removes the realpathSync canonicalization
helpers, the allowedDirs option, and its RPC/TUI wiring. This is a trusted local
channel and the dashboard (#307) owns its own authz; the containment guard
defended a non-threat and its lexical path-matching generated a recurring class
of bypass findings (symlink deref, empty-allowedDirs -> cwd widening, TOCTOU)
across three review cycles. Acceptance criterion (c) on #312 struck with rationale.

listAll() no longer swallows failures: removed the outer catch{return []} and the
inner per-directory catch{push([])}. A missing sessions dir is still a legitimate
empty state; any real I/O failure now propagates so list_all_sessions responds
success:false and the TUI selector surfaces the error instead of a silent empty list.

Kept guards: active session, non-.jsonl, nonexistent file. Rewrote the delete and
RPC-handler tests for the unrestricted model, added a listAll fail-loud test.
@m-aebrer

m-aebrer commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Progress Update

Acted on the third-pass assessment's maintainer decision (Option 1): removed the sessions-directory containment guard rather than hardening it further, and made cross-project listing fail loud.

Containment removed (dissolves the third-pass findings 1, 2, and 4):

  • SessionManager.deleteSession now uses the same unrestricted, cross-project path addressing as switch_sessionresolve() for the active-session compare and deletion, no containment guard. Deleted the realpathSync canonicalization helpers (canonicalizeDir/canonicalizeForDeletion), the allowedDirs option, and its RPC + TUI wiring (removed the now-unused realpathSync/basename/dirname/sep imports). This is the exact deleteSessionForRpc path that widened to the cwd subtree under --no-session (empty allowedDirsresolve("")process.cwd()) — that code is gone, along with the lexical/symlink and TOCTOU edges the guard kept generating.
  • Kept guards (criteria a & b): refuses the active session, non-.jsonl paths, and nonexistent files — all loud, structured errors.

listAll fails loud (finding 3):

  • Removed the outer catch { return [] } and the inner per-directory catch { dirFiles.push([]) }. A missing sessions directory is still a legitimate empty state; any real I/O failure now propagates, so list_all_sessions responds success: false and the TUI selector surfaces Failed to load sessions instead of masking a failure as "no sessions."

Tests:

  • Rewrote session-manager-delete.test.ts for the unrestricted model (dropped containment/symlink cases; kept trash/unlink branches, error-hint composition, active-session refusal via a non-canonical ./.. spelling).
  • Updated rpc-session-commands.test.ts (dropped getSessionDir from the handler stub and the containment test; added a non-.jsonl refusal case).
  • New session-manager-listall.test.ts — regression guard proving a listing I/O failure rejects instead of returning [].

Docs / issue:

  • docs/rpc.md and CHANGELOG updated to describe the unrestricted addressing and the fail-loud listing.
  • Acceptance criterion (c) struck on the linked issue with the rationale recorded (the criterion contradicted this issue's own "mirror switch_session" note, defended a non-threat on a trusted local channel, and the dashboard owns its own authz).

Validation: pre-commit hook full suite green (4008 passed, 0 failed) · biome clean · npm run build clean · typecheck clean. CI on the prior commit was green.

Commit: 9992413


Progress tracked by mach6

@m-aebrer

m-aebrer commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Code Review (fourth pass — post guard-removal)

Re-review at HEAD 9992413 after the maintainer decision to remove the containment guard and make listAll fail loud. The settled decisions (no containment, lexical resolve()-only addressing, struck criterion) were treated as fixed constraints and not re-litigated. This round is markedly cleaner: no critical or high-severity findings. The code-reviewer found nothing ≥ 80 confidence; the completeness-checker confirmed every effective acceptance criterion is met at HEAD.

Critical

None.

Important

None.

Suggestions

Finding 1 — CLI cross-project session resolution turns a listAll I/O failure into a raw unhandled rejection · main.ts ~361 (resolveSessionPath) · error-auditor, conf 85, severity low

The listAll() docstring added in this PR enumerates the consumers it hardened ("RPC responds success:false; the TUI selector surfaces 'Failed to load sessions'") — but the third consumer, resolveSessionPath (bare session-ID resolution for --session/--fork), has no try/catch anywhere up its chain (resolveSessionPathcreateSessionManagermain()cli.ts with no .catch()). Under Node 22's default unhandled-rejection behavior, a real I/O error in the sessions tree (e.g. an EACCES project subdir) crashes with a raw stack dump instead of a clean message. It IS loud and non-zero-exit, so no policy violation — but it's strictly uglier than the two sibling consumers this same PR deliberately updated. Fix: catch around the listAll() call and emit a clean Failed to list sessions while resolving '<arg>': <msg> + exit 1.

Finding 2 — TUI delete wiring (the component's real onDeleteSession) is untested · session-selector.ts ~762-815 · test-reviewer, conf 90, severity medium

The PR's actual TUI-side behavioral change — onDeleteSession calling SessionManager.deleteSession(sessionPath, { activeSessionPath: currentSessionFilePath }), then the ok-branch (list filtering, trash-vs-deleted status, refresh) and error-branch ("Failed to delete: …") — is never exercised. The existing session-selector-path-delete.test.ts overwrites list.onDeleteSession with its own stub after construction, so the real wiring never runs. In particular there is no test that deleting the active session through the TUI is refused (the whole reason currentSessionFilePath is threaded into the constructor). Suggested: drive the confirm-delete keypress on the active session and assert the file survives + the failure status shows; plus a non-active delete asserting list filtering and the info status.

Finding 3 — TUI "Failed to load sessions" path (fail-loud listAll consumer) untested · session-selector.ts ~900-920 (loadScope catch) · test-reviewer, conf 88, severity medium

The stated user-visible value of the fail-loud change is that the selector shows an error instead of a silently-empty list — but no test passes a rejecting loader to SessionSelectorComponent. The catch block is real logic (loading-flag reset, scope/seq guards, 4s error status, initial-load list clearing). Suggested: construct the selector with an allSessionsLoader that throws, toggle to "all" scope, assert Failed to load sessions: boom renders.

Finding 4 — list_all_sessions RPC rejection → success:false covered only transitively · rpc-mode.ts (listAllSessionsForRpc) · test-reviewer, conf 82, severity low

No test proves a listAll rejection becomes a success:false RPC response. The conversion happens in the generic pre-existing handleInputLine try/catch, and handleCommand is a non-exported closure — so this is thin wiring over guaranteed JS propagation; borderline over-testing. At most a one-liner: mock SessionManager.listAll to reject and assert listAllSessionsForRpc() rejects.

Finding 5 — getSessionCreatedDate duplicates the fallback tail of getSessionModifiedDate · session-manager.ts ~537-557 · simplifier, conf 88, severity low

The last two lines of getSessionModifiedDate are byte-for-byte the body of the new getSessionCreatedDate. Having getSessionModifiedDate delegate (return getSessionCreatedDate(header, statsMtime)) removes the duplicated header-parse/NaN-guard, makes the semantic relationship explicit, and means the Invalid-Date guard protects both fields so they can't drift. Behavior-identical.

Finding 6 — resolveForDeletion is a no-op alias for resolve() · session-manager.ts ~559-566 · simplifier, conf 80, severity low

The wrapper's only content is return resolve(filePath); its doc-anchor rationale is already stated verbatim in deleteSession's own JSDoc. Inlining resolve() at the call sites removes one indirection layer. The simplifier itself flags this as genuinely borderline (the shared name signals intent at 3 call sites) — a judgment call, defensible either way.

Strengths

  • Guard removal left zero artifacts: no leftover allowedDirs/canonicalize*/realpathSync references anywhere in src; imports clean; JSDoc, docs/rpc.md, and CHANGELOG all accurately describe the current resolve()-only, no-containment behavior with no stale overstated claims. tsc --noEmit clean, all 24 new tests pass.
  • Fail-loud refactor is precise: missing sessions dir still returns [] (legit empty state); real I/O errors propagate; RPC dispatch (handleInputLine try/catch) and TUI selector (loadScope catch, covering both scopes, initial + lazy loads, progress callbacks inside the try, stale-seq guards) both verified to convert the rejection into loud, contextual errors.
  • deleteSession error paths all verified sound: trash timeout (status: null → fall through to unlink, no hang), trash-absent ENOENT fall-through, structured composed errors on double failure, in-memory sessions (getSessionFile()undefined) correctly skipping the active-session guard via the truthiness check.
  • Selector delete flow: optimistic list filtering only on ok: true; failures surface as "Failed to delete: …". No silent optimistic removal.
  • Completeness confirmed: every effective acceptance criterion of issue 312 (post-waiver) verified met with evidence — commands + types + handlers, core deletion with TUI delegation, active/nonexistent/non-jsonl errors, client methods, tests, docs (including the may-be-slow caveat), CHANGELOG, and the criterion-(c) waiver properly recorded on the issue.
  • Test quality: deterministic spawnSync mocking, both error-hint composition branches covered, temp dirs/env/mocks cleaned up, ENV_AGENT_DIR isolation race-safe under vitest file isolation.

Agents run: code-reviewer, error-auditor, test-reviewer, completeness-checker, simplifier


Reviewed by mach6

@m-aebrer

m-aebrer commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Review Assessment (fourth pass)

Independent verification of the review above (#315 (comment)), each finding checked against source at HEAD 9992413.

Classifications

Finding Classification Reasoning
1 — CLI resolveSessionPath turns a listAll I/O failure into a raw unhandled rejection genuine (low) Confirmed: main.ts:361 awaits SessionManager.listAll() with no try/catch anywhere up the chain (createSessionManagermain()cli.ts, no .catch()). Before this PR listAll had catch { return [] }, so this crash path is newly created by this PR's fail-loud change — and the sibling SessionManager.list two lines above still swallows internally, so the exposure is unique to listAll. The PR deliberately hardened the two other consumers (RPC → success:false, TUI → "Failed to load sessions") and the docstring enumerates exactly those two; leaving the CLI consumer to die with a raw stack dump is a self-inflicted inconsistency. Still loud + non-zero exit, so cosmetic/consistency — a 3-line fix.
2 — TUI delete wiring untested nitpick Confirmed the existing test overwrites list.onDeleteSession with a stub, so the real handler never runs. But the only NEW code in that handler is the one-line activeSessionPath: currentSessionFilePath pass-through — the substantive guard logic is in core SessionManager.deleteSession, which is thoroughly tested (including active-session refusal via non-canonical paths). The ok/error branch bodies are pre-existing and unchanged. Low incremental value.
3 — TUI "Failed to load sessions" path untested deferred (no tracking issue needed) The loadScope catch block is entirely pre-existing — not in this PR's diff. The PR only makes it newly reachable. Per the repo rule, pre-existing untouched code is deferrable. The catch is generic and works for any rejecting loader.
4 — RPC rejection → success:false covered only transitively nitpick Confirmed the conversion happens in the generic pre-existing handleInputLine try/catch. listAllSessionsForRpc is await + map; testing that a rejection propagates through await is near-zero value. The reviewer itself flagged it as borderline over-testing. The success:false client-side shape is already tested.
5 — getSessionCreatedDate duplicates the fallback tail of getSessionModifiedDate nitpick Confirmed byte-for-byte duplication. A one-line delegation would DRY it and is behavior-identical — but it's correctness-neutral style churn in a fourth review round.
6 — resolveForDeletion no-op alias for resolve() nitpick (lean keep) Confirmed it's return resolve(filePath). But it's a deliberate self-documenting wrapper carrying the "trusted local channel, no containment" rationale at both call sites. Inlining loses the intent-naming. The simplifier itself called it defensible either way. Leave it.

Tally: 1 genuine (low) · 4 nitpick · 0 false positive · 1 deferred

Completeness: the completeness-checker independently verified every effective acceptance criterion of issue 312 (post-waiver) is met at HEAD, the criterion-(c) waiver is properly recorded on the issue, and docs/CHANGELOG accurately describe the shipped behavior with no stale claims from intermediate commits.

Action Plan

  1. Finding 1 (optional but recommended) — wrap the listAll() call in resolveSessionPath (main.ts:361) in a try/catch that prints Failed to list sessions while resolving '<arg>': <msg> and exits 1, matching the two consumers this PR already hardened. ~3 lines. This is the only real defect found this round; it is also honestly skippable (current behavior is already loud and non-zero-exit — the harm is a raw stack trace instead of a clean message).

Skip: Findings 2, 4, 5, 6 — thin-wiring test gaps over already-tested core logic, trivial await-propagation, or DRY/style churn not worth a fifth round.

Defer (no tracking issue): Finding 3 — pre-existing generic error path the PR merely makes reachable.

Bottom line: No finding blocks merge. Fix finding 1 if you want the consistency polish; otherwise this PR is ready for /skill:mach6-publish.


Assessment by mach6

@m-aebrer

m-aebrer commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Progress Update

Fixed finding 1 from the fourth-pass assessment (the only genuine issue).

Fix: resolveSessionPath in main.ts now wraps its SessionManager.listAll() call in a try/catch. A real I/O failure during bare-ID --session/--fork cross-project resolution now prints Failed to list sessions while resolving '<arg>': <message> and exits 1 — matching the file's established log.error + process.exit(1) pattern and the two listAll consumers this PR already hardened (RPC → success: false, TUI selector → "Failed to load sessions"). Previously the rejection escaped to the top level as a raw unhandled-rejection stack dump.

Findings 2, 4, 5, 6 skipped and finding 3 deferred per the assessment (nitpicks / pre-existing path — none blocking).

Validation: typecheck clean · biome clean · npm run build clean · full workspace suite green (pre-commit hook: all packages passed, 0 failed).

Commit: b76677e


Progress tracked by mach6

@m-aebrer m-aebrer merged commit 3b3c13e into master Jul 6, 2026
3 checks passed
@m-aebrer m-aebrer deleted the feature/issue-312-rpc-session-listing-delete branch July 6, 2026 17:05
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.

Expose cross-project session listing and session deletion over RPC

1 participant