import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { readFileSync } from 'node:fs'; import { rm, mkdir } from 'node:fs/promises'; import { resolve } from 'node:path'; import postgres from 'postgres'; import { buildShadowCommitCommand, createCheckpoint, restoreCheckpoint, CheckpointNotFoundError, } from '../checkpoints.js'; import { ensureSessionWorktree } from '../worktrees.js'; import { hostExec } from '../host-exec.js'; /** * write-edit-robustness #4 — worktree checkpoint tests. * * Pure-helper coverage (no DB / no host) for the shadow-commit command builder, * plus a DB+git integration block (DB-opt-in via DATABASE_URL, skips cleanly * otherwise; mirrors reconnect_integration.test.ts) that exercises the real * create → restore round trip against a worktree on the host fs. */ describe('buildShadowCommitCommand (pure)', () => { it('parks the commit under refs/boocode/checkpoints/ and prints only the SHA', () => { const cmd = buildShadowCommitCommand('/tmp/booworktrees/sess-abc', 'cp-id-123'); // Uses a temp index so the real working tree/index is untouched. expect(cmd).toContain('TMP=$(mktemp)'); expect(cmd).toContain('GIT_INDEX_FILE="$TMP" git read-tree HEAD'); expect(cmd).toContain('GIT_INDEX_FILE="$TMP" git add -A'); expect(cmd).toContain('git write-tree'); expect(cmd).toContain("git commit-tree \"$TREE\" -p HEAD -m \"boocode checkpoint\""); // Ref name matches the row id, and stdout is ONLY the SHA (printf, no newline). expect(cmd).toContain("update-ref 'refs/boocode/checkpoints/cp-id-123'"); expect(cmd).toContain("printf '%s' \"$SHA\""); expect(cmd).not.toContain('echo "$SHA"'); }); it('shell-escapes the worktree path and the id', () => { const cmd = buildShadowCommitCommand("/tmp/it's a path", "id'; rm -rf /"); // Single quotes inside the path/id are escaped via the '\'' wrapping idiom — no // bare interpolation that could break out of the quoting. expect(cmd).toContain("cd '/tmp/it'\\''s a path'"); expect(cmd).toContain("refs/boocode/checkpoints/id'\\''; rm -rf /"); }); }); describe.runIf(!!process.env.DATABASE_URL)('checkpoint create + restore (DB + git)', () => { let sql: ReturnType; const stamp = Date.now(); const projectDir = `/tmp/boocode-checkpoint-proj-${stamp}`; let projectId: string; let sessionId: string; let chatId: string; let worktreePath: string; beforeAll(async () => { sql = postgres(process.env.DATABASE_URL!, { max: 3 }); // Server schema first (FK targets), then coder schema (worktrees + checkpoints). const serverSchema = resolve(__dirname, '../../../../server/src/schema.sql'); const coderSchema = resolve(__dirname, '../../schema.sql'); await sql.unsafe(readFileSync(serverSchema, 'utf8')); await sql.unsafe(readFileSync(coderSchema, 'utf8')); await mkdir(projectDir, { recursive: true }); await hostExec( `cd ${projectDir} && git init -q && git config user.email t@t && git config user.name t ` + `&& echo hello > README.md && git add -A && git commit -qm init`, { timeoutMs: 20_000 }, ); const [project] = await sql<{ id: string }[]>` INSERT INTO projects (name, path, status) VALUES ('checkpoint-test', ${projectDir}, 'open') RETURNING id `; projectId = project!.id; const [session] = await sql<{ id: string }[]>` INSERT INTO sessions (project_id, name, model, status) VALUES (${projectId}, 'cp', 'm', 'open') RETURNING id `; sessionId = session!.id; const [chat] = await sql<{ id: string }[]>` INSERT INTO chats (session_id, name, status) VALUES (${sessionId}, 'tab', 'open') RETURNING id `; chatId = chat!.id; const wt = await ensureSessionWorktree(sql, projectDir, sessionId); worktreePath = wt.worktreePath; }); afterAll(async () => { if (sql) { const rows = await sql<{ path: string }[]>`SELECT path FROM worktrees WHERE session_id = ${sessionId}`.catch(() => []); for (const r of rows) { await hostExec(`git -C ${projectDir} worktree remove ${r.path} --force`, { timeoutMs: 10_000 }).catch(() => {}); } await sql`DELETE FROM checkpoints WHERE chat_id = ${chatId}`.catch(() => {}); await sql`DELETE FROM agent_sessions WHERE chat_id = ${chatId}`.catch(() => {}); await sql`DELETE FROM worktrees WHERE session_id = ${sessionId}`.catch(() => {}); await sql`DELETE FROM chats WHERE id = ${chatId}`.catch(() => {}); await sql`DELETE FROM sessions WHERE id = ${sessionId}`.catch(() => {}); await sql`DELETE FROM projects WHERE id = ${projectId}`.catch(() => {}); await sql.end({ timeout: 5 }); } await rm(projectDir, { recursive: true, force: true }); }); it('createCheckpoint inserts a row + a private ref capturing tracked + untracked', async () => { const [wt] = await sql<{ id: string }[]>`SELECT id FROM worktrees WHERE session_id = ${sessionId} AND status = 'active'`; const worktreeId = wt!.id; // Pre-turn untracked + tracked-edit state the agent will start from. await hostExec(`cd ${worktreePath} && echo edited >> README.md && echo new > extra.txt`, { timeoutMs: 10_000 }); const [assistantMsg] = await sql<{ id: string }[]>` INSERT INTO messages (session_id, chat_id, role, content, status) VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming') RETURNING id `; const messageId = assistantMsg!.id; const cp = await createCheckpoint(sql, { chatId, sessionId, worktreeId, worktreePath, messageId, }); expect(cp).not.toBeNull(); expect(cp!.commit_sha).toMatch(/^[0-9a-f]{40}$/); const [row] = await sql<{ commit_sha: string; worktree_id: string; message_id: string }[]>` SELECT commit_sha, worktree_id, message_id FROM checkpoints WHERE id = ${cp!.id} `; expect(row!.commit_sha).toBe(cp!.commit_sha); expect(row!.worktree_id).toBe(worktreeId); expect(row!.message_id).toBe(messageId); // The ref exists and the captured tree carries the untracked file (proves the // temp-index `git add -A` snapshotted untracked content). const refLs = await hostExec( `git -C ${worktreePath} ls-tree -r --name-only ${cp!.commit_sha}`, { timeoutMs: 10_000 }, ); expect(refLs.exitCode).toBe(0); expect(refLs.stdout).toContain('extra.txt'); // The shadow commit did NOT disturb the real working tree: extra.txt is still // present + still untracked (status shows it). const status = await hostExec(`git -C ${worktreePath} status --porcelain`, { timeoutMs: 10_000 }); expect(status.stdout).toContain('extra.txt'); }); it('restoreCheckpoint resets the worktree, trims the transcript, and drops later checkpoints', async () => { // Clean slate for this test: reset the worktree to HEAD, clear prior rows. await hostExec(`git -C ${worktreePath} reset --hard HEAD && git -C ${worktreePath} clean -fd`, { timeoutMs: 10_000 }); await sql`DELETE FROM checkpoints WHERE chat_id = ${chatId}`; await sql`DELETE FROM messages WHERE chat_id = ${chatId}`; const [wt] = await sql<{ id: string }[]>`SELECT id FROM worktrees WHERE session_id = ${sessionId} AND status = 'active'`; const worktreeId = wt!.id; // Turn 1: a user msg, then the assistant turn the checkpoint anchors. The // worktree is pristine (matches HEAD) when this checkpoint is captured. await sql`INSERT INTO messages (session_id, chat_id, role, content, status) VALUES (${sessionId}, ${chatId}, 'user', 'do it', 'complete')`; const [a1] = await sql<{ id: string }[]>` INSERT INTO messages (session_id, chat_id, role, content, status) VALUES (${sessionId}, ${chatId}, 'assistant', 'turn 1', 'complete') RETURNING id `; const cp1 = await createCheckpoint(sql, { chatId, sessionId, worktreeId, worktreePath, messageId: a1!.id }); expect(cp1).not.toBeNull(); // The agent (turn 1) writes a file into the worktree. await hostExec(`cd ${worktreePath} && echo agent-wrote > agent.txt`, { timeoutMs: 10_000 }); // Turn 2: another user msg + assistant turn, AND a second (later) checkpoint. await sql`INSERT INTO messages (session_id, chat_id, role, content, status) VALUES (${sessionId}, ${chatId}, 'user', 'more', 'complete')`; const [a2] = await sql<{ id: string }[]>` INSERT INTO messages (session_id, chat_id, role, content, status) VALUES (${sessionId}, ${chatId}, 'assistant', 'turn 2', 'complete') RETURNING id `; const cp2 = await createCheckpoint(sql, { chatId, sessionId, worktreeId, worktreePath, messageId: a2!.id }); expect(cp2).not.toBeNull(); // An agent_sessions row that restore should mark 'crashed'. await sql` INSERT INTO agent_sessions (chat_id, session_id, worktree_id, agent, backend, agent_session_id, status, last_active_at) VALUES (${chatId}, ${sessionId}, ${worktreeId}, 'goose', 'acp_warm', 'sess-1', 'active', clock_timestamp()) ON CONFLICT (chat_id, agent) DO UPDATE SET status = 'active' `; const before = await sql<{ id: string }[]>`SELECT id FROM messages WHERE chat_id = ${chatId} ORDER BY created_at`; expect(before.length).toBe(4); // user, a1, user, a2 // Restore to cp1 (before turn 1's assistant message). const result = await restoreCheckpoint(sql, cp1!.id, { sessionId }); expect(result.checkpoint_id).toBe(cp1!.id); expect(result.worktree_reset).toBe(true); expect(result.backend_reset).toBe(true); // a1, user(turn2), a2 deleted (created_at >= a1) → 3 trimmed. expect(result.messages_deleted).toBe(3); // Transcript trimmed to just the first user message. const after = await sql<{ role: string; content: string }[]>`SELECT role, content FROM messages WHERE chat_id = ${chatId} ORDER BY created_at`; expect(after.length).toBe(1); expect(after[0]!.role).toBe('user'); // Worktree reset: the agent's file is gone (it was written after cp1). const ls = await hostExec(`ls ${worktreePath}/agent.txt`, { timeoutMs: 10_000 }); expect(ls.exitCode).not.toBe(0); // The agent_sessions row was reset to 'crashed'. const [as] = await sql<{ status: string }[]>`SELECT status FROM agent_sessions WHERE chat_id = ${chatId} AND agent = 'goose'`; expect(as!.status).toBe('crashed'); // cp1 survives (re-restorable); cp2 (later) was dropped. const cps = await sql<{ id: string }[]>`SELECT id FROM checkpoints WHERE chat_id = ${chatId}`; expect(cps.map((c) => c.id)).toEqual([cp1!.id]); }); it('restoreCheckpoint throws CheckpointNotFoundError for an unknown id', async () => { await expect( restoreCheckpoint(sql, '00000000-0000-0000-0000-000000000000', { sessionId }), ).rejects.toBeInstanceOf(CheckpointNotFoundError); }); it('restoreCheckpoint throws when the checkpoint is not in the requested session', async () => { // A checkpoint whose session_id differs from the route's sessionId. const [wt] = await sql<{ id: string }[]>`SELECT id FROM worktrees WHERE session_id = ${sessionId} AND status = 'active'`; const cp = await createCheckpoint(sql, { chatId, sessionId, worktreeId: wt!.id, worktreePath, messageId: null }); expect(cp).not.toBeNull(); await expect( restoreCheckpoint(sql, cp!.id, { sessionId: '11111111-1111-1111-1111-111111111111' }), ).rejects.toBeInstanceOf(CheckpointNotFoundError); await sql`DELETE FROM checkpoints WHERE id = ${cp!.id}`; }); it('restoreCheckpoint denies a NULL-session_id checkpoint from another session (no fail-open IDOR)', async () => { // Regression for the fail-open authorization bug: a checkpoint row whose // denormalized session_id is NULL must STILL be scoped via its chat's owning // session (chats.session_id), not skipped. The old guard `cp.session_id && // cp.session_id !== sessionId` fell through on NULL → cross-session restore. const [row] = await sql<{ id: string }[]>` INSERT INTO checkpoints (chat_id, session_id, message_id, commit_sha) VALUES (${chatId}, NULL, NULL, 'deadbeef') RETURNING id `; await expect( restoreCheckpoint(sql, row!.id, { sessionId: '22222222-2222-2222-2222-222222222222' }), ).rejects.toBeInstanceOf(CheckpointNotFoundError); await sql`DELETE FROM checkpoints WHERE id = ${row!.id}`; }); });