fix: don't discard codex/gemini/antigravity conversations on Resume; fix DELETE ownership dup + missing broadcast

resumeHistorySession() creates the resumed row in its own mode via a
modeConfigKey map (opencode/pi/grok/omp -> continueSession, deepseek ->
resumeSession) and retires the old row afterward. codex, gemini and
antigravity were missing from that map, so resuming one of their rows
started a brand-new session with NO continuation while still deleting
the row it came from -- silent data loss dressed as the duplicate-row
fix. Gate row retirement on continuesSomething (true only for modes that
actually got a continuation config) instead of wiring an unverified
sessionId->native-conversation-id assumption for the three affected CLIs.

DELETE /api/sessions/:id reimplemented the ownership 404 check inline in
two places instead of going through findSessionOrFail, and its
persisted-only-session branch never broadcast session:deleted, so other
open tabs kept the retired row until their next unrelated fetch. Extract
the shared 404 into sessionNotFoundError(), add findPersistedSessionOrFail()
alongside findSessionOrFail() in route-helpers.ts (same ownership
contract, returns a SessionState instead of a live Session), and use both
from the route instead of inline checks. Add the missing broadcast.
This commit is contained in:
timkjr
2026-08-28 14:03:07 -05:00
parent 2ee2eacb4b
commit f18dccace1
5 changed files with 205 additions and 23 deletions
+35 -7
View File
@@ -12,7 +12,7 @@ import { homedir } from 'node:os';
import type { z } from 'zod';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { Session } from '../session.js';
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../types.js';
import { ApiErrorCode, createErrorResponse, type AuthUser, type SessionState } from '../types.js';
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js';
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
import { SseEvent } from './sse-events.js';
@@ -264,6 +264,18 @@ export function revokeUserSessions(
return removed;
}
/**
* The 404 both session-lookup helpers below throw. A missing session and one
* the caller isn't allowed to see get the IDENTICAL error (never 403), so
* existence of another user's session is never leaked.
*/
function sessionNotFoundError(sessionId: string): Error & { statusCode: number; body: unknown } {
return Object.assign(new Error(`Session ${sessionId} not found`), {
statusCode: 404,
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
});
}
/**
* Look up a session by ID or throw a structured error.
* Replaces the pattern: `const session = sessions.get(id); if (!session) return createErrorResponse(...)`.
@@ -274,15 +286,31 @@ export function revokeUserSessions(
*/
export function findSessionOrFail(ctx: SessionPort, sessionId: string, req?: FastifyRequest): Session {
const session = ctx.sessions.get(sessionId);
if (!session || (req && !canAccessOwned(getAuthUser(req), session.owner))) {
throw Object.assign(new Error(`Session ${sessionId} not found`), {
statusCode: 404,
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
});
}
if (!session) throw sessionNotFoundError(sessionId);
if (req && !canAccessOwned(getAuthUser(req), session.owner)) throw sessionNotFoundError(sessionId);
return session;
}
/**
* Like {@link findSessionOrFail}, for a session that exists ONLY in persisted
* state — a resumed-but-never-reattached row (e.g. a non-claude "Resume" that
* relaunched into a new session and wants to retire the row it can no longer
* reattach to) has no live `Session` instance for `findSessionOrFail` to
* return, so this returns the persisted record instead. Same ownership
* enforcement, same 404-not-403 leak protection — this is that function's
* missing other half, not a separate check reimplemented inline.
*/
export function findPersistedSessionOrFail(
store: { getSession(id: string): SessionState | null },
sessionId: string,
req?: FastifyRequest
): SessionState {
const persisted = store.getSession(sessionId);
if (!persisted) throw sessionNotFoundError(sessionId);
if (req && !canAccessOwned(getAuthUser(req), persisted.owner)) throw sessionNotFoundError(sessionId);
return persisted;
}
/** Shortest prefix accepted for a parent session id (see resolveParentSessionId). */
const PARENT_SESSION_ID_MIN_PREFIX = 8;