Flagged by the automated push security review on v2.7.1. - GET /checkpoints?chat_id= : the chat_id branch filtered by chat_id alone (any session's chat_id read its checkpoints). Now joins chats and gates on chats.session_id. - restoreCheckpoint scope guard was fail-open: `cp.session_id && cp.session_id !== sessionId` fell through on a null denormalized session_id, allowing a cross-session restore (worktree reset + transcript trim). Now resolves the owning session via the checkpoint's chat and denies on missing/mismatch. - Adds a DB-integration regression for the null-session_id cross-session case. Both scope authoritatively through chats.session_id (checkpoints.session_id is a nullable hint). Coder suite 234 passing; 7/7 checkpoint tests (incl. the regression) against live postgres+git; typecheck clean. Hotfix on v2.7.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
74 lines
3.0 KiB
TypeScript
74 lines
3.0 KiB
TypeScript
/**
|
|
* write-edit-robustness #4 — checkpoint restore + list routes (coder side).
|
|
*
|
|
* Proxied through the apps/server `/api/coder/*` blanket forwarder (no server-side
|
|
* change needed for new routes). Restore rewinds the session worktree to the
|
|
* checkpoint's shadow commit, trims the transcript from the anchor message forward,
|
|
* and resets the agent backend — see services/checkpoints.ts.
|
|
*/
|
|
import type { FastifyInstance } from 'fastify';
|
|
import type { Sql } from '../db.js';
|
|
import { restoreCheckpoint, CheckpointNotFoundError } from '../services/checkpoints.js';
|
|
|
|
export function registerCheckpointRoutes(app: FastifyInstance, sql: Sql): void {
|
|
// GET /api/sessions/:sessionId/checkpoints?chat_id= — list a chat's checkpoints
|
|
// so the frontend can mark which messages have a restore point. When chat_id is
|
|
// omitted, returns every checkpoint for the session's chats.
|
|
app.get<{ Params: { sessionId: string }; Querystring: { chat_id?: string } }>(
|
|
'/api/sessions/:sessionId/checkpoints',
|
|
async (req, reply) => {
|
|
const sessionId = req.params.sessionId;
|
|
const chatId = req.query.chat_id;
|
|
|
|
const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`;
|
|
if (session.length === 0) {
|
|
reply.code(404);
|
|
return { error: 'session not found' };
|
|
}
|
|
|
|
// Scope authoritatively through chats.session_id (always set) — NOT the
|
|
// denormalized checkpoints.session_id (nullable). The chat_id branch must
|
|
// still be session-gated or it's an IDOR (any session's chat_id reads its
|
|
// checkpoints).
|
|
const rows = chatId
|
|
? await sql<{ id: string; chat_id: string; message_id: string | null; label: string | null; created_at: Date }[]>`
|
|
SELECT cp.id, cp.chat_id, cp.message_id, cp.label, cp.created_at
|
|
FROM checkpoints cp
|
|
JOIN chats c ON c.id = cp.chat_id
|
|
WHERE cp.chat_id = ${chatId} AND c.session_id = ${sessionId}
|
|
ORDER BY cp.created_at
|
|
`
|
|
: await sql<{ id: string; chat_id: string; message_id: string | null; label: string | null; created_at: Date }[]>`
|
|
SELECT cp.id, cp.chat_id, cp.message_id, cp.label, cp.created_at
|
|
FROM checkpoints cp
|
|
JOIN chats c ON c.id = cp.chat_id
|
|
WHERE c.session_id = ${sessionId}
|
|
ORDER BY cp.created_at
|
|
`;
|
|
return rows;
|
|
},
|
|
);
|
|
|
|
// POST /api/sessions/:sessionId/checkpoints/:checkpointId/restore — restore.
|
|
app.post<{ Params: { sessionId: string; checkpointId: string } }>(
|
|
'/api/sessions/:sessionId/checkpoints/:checkpointId/restore',
|
|
async (req, reply) => {
|
|
const { sessionId, checkpointId } = req.params;
|
|
|
|
try {
|
|
const result = await restoreCheckpoint(sql, checkpointId, {
|
|
sessionId,
|
|
log: app.log,
|
|
});
|
|
return result;
|
|
} catch (err) {
|
|
if (err instanceof CheckpointNotFoundError) {
|
|
reply.code(404);
|
|
return { error: err.message };
|
|
}
|
|
throw err;
|
|
}
|
|
},
|
|
);
|
|
}
|