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>
225 lines
7.4 KiB
TypeScript
225 lines
7.4 KiB
TypeScript
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
|
|
import { dirname } from 'node:path';
|
|
import type { Sql } from '../db.js';
|
|
import { resolveWritePath } from './write_guard.js';
|
|
|
|
// --- Types -------------------------------------------------------------------
|
|
|
|
export interface PendingChange {
|
|
id: string;
|
|
session_id: string;
|
|
task_id: string | null;
|
|
file_path: string;
|
|
operation: 'create' | 'edit' | 'delete';
|
|
diff: string;
|
|
status: 'pending' | 'applied' | 'rejected' | 'reverted';
|
|
created_at: string;
|
|
}
|
|
|
|
export interface ApplyResult {
|
|
id: string;
|
|
file_path: string;
|
|
operation: string;
|
|
success: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
// --- Queue functions ---------------------------------------------------------
|
|
|
|
export async function queueEdit(
|
|
sql: Sql,
|
|
sessionId: string,
|
|
taskId: string | null,
|
|
filePath: string,
|
|
oldString: string,
|
|
newString: string,
|
|
projectRoot: string,
|
|
): Promise<PendingChange> {
|
|
const resolved = resolveWritePath(projectRoot, filePath);
|
|
const diff = JSON.stringify({ old: oldString, new: newString });
|
|
|
|
const [row] = await sql<PendingChange[]>`
|
|
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
|
|
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff})
|
|
RETURNING *
|
|
`;
|
|
return row!;
|
|
}
|
|
|
|
export async function queueCreate(
|
|
sql: Sql,
|
|
sessionId: string,
|
|
taskId: string | null,
|
|
filePath: string,
|
|
content: string,
|
|
projectRoot: string,
|
|
): Promise<PendingChange> {
|
|
const resolved = resolveWritePath(projectRoot, filePath);
|
|
|
|
const [row] = await sql<PendingChange[]>`
|
|
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
|
|
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content})
|
|
RETURNING *
|
|
`;
|
|
return row!;
|
|
}
|
|
|
|
export async function queueDelete(
|
|
sql: Sql,
|
|
sessionId: string,
|
|
taskId: string | null,
|
|
filePath: string,
|
|
projectRoot: string,
|
|
): Promise<PendingChange> {
|
|
const resolved = resolveWritePath(projectRoot, filePath);
|
|
|
|
const [row] = await sql<PendingChange[]>`
|
|
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
|
|
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '')
|
|
RETURNING *
|
|
`;
|
|
return row!;
|
|
}
|
|
|
|
// --- Apply functions ---------------------------------------------------------
|
|
|
|
export async function applyOne(
|
|
sql: Sql,
|
|
changeId: string,
|
|
projectRoot: string,
|
|
): Promise<ApplyResult> {
|
|
const [change] = await sql<PendingChange[]>`
|
|
SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'pending'
|
|
`;
|
|
if (!change) {
|
|
return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not pending' };
|
|
}
|
|
|
|
try {
|
|
// Re-validate path in case projectRoot has shifted
|
|
resolveWritePath(projectRoot, change.file_path);
|
|
|
|
switch (change.operation) {
|
|
case 'create': {
|
|
await mkdir(dirname(change.file_path), { recursive: true });
|
|
await writeFile(change.file_path, change.diff, 'utf8');
|
|
break;
|
|
}
|
|
case 'edit': {
|
|
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
|
const content = await readFile(change.file_path, 'utf8');
|
|
if (!content.includes(oldStr)) {
|
|
throw new Error('old_string not found in file — file may have changed since the edit was queued');
|
|
}
|
|
const updated = content.replace(oldStr, newStr);
|
|
await writeFile(change.file_path, updated, 'utf8');
|
|
break;
|
|
}
|
|
case 'delete': {
|
|
// Stash current content in diff for potential rewind
|
|
try {
|
|
const existing = await readFile(change.file_path, 'utf8');
|
|
await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`;
|
|
} catch {
|
|
// File may already be gone — proceed with status update
|
|
}
|
|
await unlink(change.file_path);
|
|
break;
|
|
}
|
|
}
|
|
|
|
await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`;
|
|
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
|
|
}
|
|
}
|
|
|
|
export async function applyAll(
|
|
sql: Sql,
|
|
sessionId: string,
|
|
projectRoot: string,
|
|
): Promise<ApplyResult[]> {
|
|
const pending = await sql<PendingChange[]>`
|
|
SELECT * FROM pending_changes
|
|
WHERE session_id = ${sessionId} AND status = 'pending'
|
|
ORDER BY created_at ASC
|
|
`;
|
|
const results: ApplyResult[] = [];
|
|
for (const change of pending) {
|
|
results.push(await applyOne(sql, change.id, projectRoot));
|
|
}
|
|
return results;
|
|
}
|
|
|
|
// --- Reject functions --------------------------------------------------------
|
|
|
|
export async function rejectOne(sql: Sql, changeId: string): Promise<void> {
|
|
await sql`UPDATE pending_changes SET status = 'rejected' WHERE id = ${changeId} AND status = 'pending'`;
|
|
}
|
|
|
|
export async function rejectAll(sql: Sql, sessionId: string): Promise<void> {
|
|
await sql`UPDATE pending_changes SET status = 'rejected' WHERE session_id = ${sessionId} AND status = 'pending'`;
|
|
}
|
|
|
|
// --- Rewind functions --------------------------------------------------------
|
|
|
|
export async function rewindOne(
|
|
sql: Sql,
|
|
changeId: string,
|
|
projectRoot: string,
|
|
): Promise<ApplyResult> {
|
|
const [change] = await sql<PendingChange[]>`
|
|
SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'applied'
|
|
`;
|
|
if (!change) {
|
|
return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not applied' };
|
|
}
|
|
|
|
try {
|
|
resolveWritePath(projectRoot, change.file_path);
|
|
|
|
switch (change.operation) {
|
|
case 'create': {
|
|
// Reverse a create: delete the file
|
|
await unlink(change.file_path);
|
|
break;
|
|
}
|
|
case 'edit': {
|
|
// Reverse an edit: swap old and new
|
|
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
|
const content = await readFile(change.file_path, 'utf8');
|
|
if (!content.includes(newStr)) {
|
|
throw new Error('new_string not found in file — cannot rewind; file may have been modified since apply');
|
|
}
|
|
const reverted = content.replace(newStr, oldStr);
|
|
await writeFile(change.file_path, reverted, 'utf8');
|
|
break;
|
|
}
|
|
case 'delete': {
|
|
// Reverse a delete: recreate the file (diff holds the original content stashed at apply time)
|
|
await mkdir(dirname(change.file_path), { recursive: true });
|
|
await writeFile(change.file_path, change.diff, 'utf8');
|
|
break;
|
|
}
|
|
}
|
|
|
|
await sql`UPDATE pending_changes SET status = 'reverted' WHERE id = ${changeId}`;
|
|
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
|
|
}
|
|
}
|
|
|
|
// --- Query functions ---------------------------------------------------------
|
|
|
|
export async function listPending(sql: Sql, sessionId: string): Promise<PendingChange[]> {
|
|
return sql<PendingChange[]>`
|
|
SELECT * FROM pending_changes
|
|
WHERE session_id = ${sessionId} AND status = 'pending'
|
|
ORDER BY created_at ASC
|
|
`;
|
|
}
|