Phase 2 of v2.0. BooCoder is now a functional write-capable chatbot.
Write-path guard: resolveWritePath() uses resolve() (no realpath — files may
not exist for creates) + prefix-check + secret-file deny list (.env, *.pem,
id_rsa*, etc.). 23 unit tests cover traversal attacks.
Pending-changes service: queueEdit/Create/Delete → applyOne/All →
rejectOne/All → rewindOne. Edit diffs stored as JSON {old, new}. All writes
queue before touching disk; apply re-validates the path guard.
5 write tools: edit_file, create_file, delete_file, apply_pending, rewind.
Registered alongside 25 read-only tools from BooChat (30 total, alpha-sorted).
Write tools use a module-level inference context for sql+sessionId injection.
Inference loop via workspace dependency: apps/coder imports
createInferenceRunner, createBroker, ALL_TOOLS from @boocode/server (dist/).
apps/server gains declaration: true + exports map with typed subpath entries.
No code duplication — one inference engine shared by both apps.
API routes: POST /api/sessions/:id/messages (user msg → inference), POST stop,
GET/POST pending-changes CRUD (5 endpoints), WebSocket session streaming.
Dockerfile updated to build apps/server first (coder depends on its .d.ts).
Health endpoint reports tool count: {"ok":true,"db":true,"tools":30}.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
122 lines
3.5 KiB
TypeScript
122 lines
3.5 KiB
TypeScript
import type { FastifyInstance } from 'fastify';
|
|
import type { Sql } from '../db.js';
|
|
import {
|
|
listPending,
|
|
applyOne,
|
|
applyAll,
|
|
rejectOne,
|
|
rewindOne,
|
|
} from '../services/pending_changes.js';
|
|
|
|
/**
|
|
* Resolve project root from a session's project path.
|
|
*/
|
|
async function resolveProjectRoot(sql: Sql, sessionId: string): Promise<string | null> {
|
|
const rows = await sql<{ path: string }[]>`
|
|
SELECT p.path FROM sessions s
|
|
JOIN projects p ON s.project_id = p.id
|
|
WHERE s.id = ${sessionId}
|
|
`;
|
|
return rows.length > 0 ? rows[0]!.path : null;
|
|
}
|
|
|
|
/**
|
|
* Resolve project root from a pending change's session.
|
|
*/
|
|
async function resolveProjectRootForChange(sql: Sql, changeId: string): Promise<string | null> {
|
|
const rows = await sql<{ path: string }[]>`
|
|
SELECT p.path FROM pending_changes pc
|
|
JOIN sessions s ON pc.session_id = s.id
|
|
JOIN projects p ON s.project_id = p.id
|
|
WHERE pc.id = ${changeId}
|
|
`;
|
|
return rows.length > 0 ? rows[0]!.path : null;
|
|
}
|
|
|
|
export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
|
|
// GET /api/sessions/:sessionId/pending — list pending changes for a session
|
|
app.get<{ Params: { sessionId: string } }>(
|
|
'/api/sessions/:sessionId/pending',
|
|
async (req, reply) => {
|
|
const sessionId = req.params.sessionId;
|
|
|
|
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' };
|
|
}
|
|
|
|
const pending = await listPending(sql, sessionId);
|
|
return pending;
|
|
},
|
|
);
|
|
|
|
// POST /api/sessions/:sessionId/pending/apply — apply all pending changes
|
|
app.post<{ Params: { sessionId: string } }>(
|
|
'/api/sessions/:sessionId/pending/apply',
|
|
async (req, reply) => {
|
|
const sessionId = req.params.sessionId;
|
|
|
|
const projectRoot = await resolveProjectRoot(sql, sessionId);
|
|
if (!projectRoot) {
|
|
reply.code(404);
|
|
return { error: 'session or project not found' };
|
|
}
|
|
|
|
const results = await applyAll(sql, sessionId, projectRoot);
|
|
return { results };
|
|
},
|
|
);
|
|
|
|
// POST /api/pending/:id/apply — apply a single pending change
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/pending/:id/apply',
|
|
async (req, reply) => {
|
|
const changeId = req.params.id;
|
|
|
|
const projectRoot = await resolveProjectRootForChange(sql, changeId);
|
|
if (!projectRoot) {
|
|
reply.code(404);
|
|
return { error: 'pending change or project not found' };
|
|
}
|
|
|
|
const result = await applyOne(sql, changeId, projectRoot);
|
|
if (!result.success) {
|
|
reply.code(422);
|
|
}
|
|
return result;
|
|
},
|
|
);
|
|
|
|
// POST /api/pending/:id/reject — reject a single pending change
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/pending/:id/reject',
|
|
async (req, reply) => {
|
|
const changeId = req.params.id;
|
|
|
|
await rejectOne(sql, changeId);
|
|
return { ok: true };
|
|
},
|
|
);
|
|
|
|
// POST /api/pending/:id/rewind — rewind (undo) an applied change
|
|
app.post<{ Params: { id: string } }>(
|
|
'/api/pending/:id/rewind',
|
|
async (req, reply) => {
|
|
const changeId = req.params.id;
|
|
|
|
const projectRoot = await resolveProjectRootForChange(sql, changeId);
|
|
if (!projectRoot) {
|
|
reply.code(404);
|
|
return { error: 'pending change or project not found' };
|
|
}
|
|
|
|
const result = await rewindOne(sql, changeId, projectRoot);
|
|
if (!result.success) {
|
|
reply.code(422);
|
|
}
|
|
return result;
|
|
},
|
|
);
|
|
}
|