v2.0.0-beta: write tools, pending-changes queue, inference loop, API routes
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>
This commit is contained in:
@@ -5,12 +5,16 @@ RUN corepack enable
|
||||
WORKDIR /build
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
|
||||
COPY apps/server/package.json ./apps/server/
|
||||
COPY apps/coder/package.json ./apps/coder/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY apps/coder ./apps/coder
|
||||
# Build server first (coder depends on it via workspace dep for types + inference)
|
||||
COPY apps/server ./apps/server
|
||||
RUN pnpm -C apps/server build
|
||||
|
||||
COPY apps/coder ./apps/coder
|
||||
RUN pnpm -C apps/coder build
|
||||
|
||||
RUN pnpm deploy --filter=@boocode/coder --prod --legacy /out/coder
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boocode/server": "workspace:*",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@fastify/websocket": "^10.0.1",
|
||||
"fastify": "^4.28.1",
|
||||
@@ -20,6 +22,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.10",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.5.0"
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// BooCoder's config is a superset of the server's Config type so it can be
|
||||
// passed directly into the inference runner's InferenceContext. Fields the
|
||||
// inference loop reads: LLAMA_SWAP_URL, PROJECT_ROOT_WHITELIST. The rest
|
||||
// default to values that satisfy the server's Zod schema without BooCoder
|
||||
// needing to supply them in its environment.
|
||||
const ConfigSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
PORT: z.coerce.number().int().positive().default(3000),
|
||||
@@ -7,8 +12,17 @@ const ConfigSchema = z.object({
|
||||
DATABASE_URL: z.string().url(),
|
||||
LLAMA_SWAP_URL: z.string().url(),
|
||||
PROJECT_ROOT_WHITELIST: z.string().default('/opt'),
|
||||
BOOTSTRAP_ROOT: z.string().default('/opt/projects'),
|
||||
DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'),
|
||||
LOG_LEVEL: z.string().default('info'),
|
||||
CONTAINER_GUIDANCE_FILE: z.string().optional(),
|
||||
// Fields needed to satisfy the server's Config type but unused by BooCoder:
|
||||
SEARXNG_URL: z.string().url().default('http://100.114.205.53:8888'),
|
||||
GITEA_BASE_URL: z.string().url().default('https://git.indifferentketchup.com'),
|
||||
GITEA_USER: z.string().default('indifferentketchup'),
|
||||
GITEA_TOKEN: z.string().optional(),
|
||||
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
|
||||
MCP_CONFIG_PATH: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import Fastify from 'fastify';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import { loadConfig } from './config.js';
|
||||
import { getSql, applySchema, pingDb, closeDb } from './db.js';
|
||||
// v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the
|
||||
// inference loop, broker, and tool registry without duplication.
|
||||
import { createInferenceRunner } from '@boocode/server/inference';
|
||||
import { createBroker } from '@boocode/server/broker';
|
||||
import { appendMcpTools, ALL_TOOLS } from '@boocode/server/tools';
|
||||
import type { Config as ServerConfig } from '@boocode/server/config';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
// v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility.
|
||||
import { WRITE_TOOLS } from './services/tools/index.js';
|
||||
import { adaptWriteTool } from './services/tools/adapter.js';
|
||||
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js';
|
||||
// Routes
|
||||
import { registerMessageRoutes } from './routes/messages.js';
|
||||
import { registerPendingRoutes } from './routes/pending.js';
|
||||
import { registerWebSocket } from './routes/ws.js';
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
@@ -28,12 +44,72 @@ async function main() {
|
||||
await applySchema(sql);
|
||||
app.log.info('database schema applied');
|
||||
|
||||
// Broker: in-memory pub/sub for session + user channel streaming.
|
||||
const broker = createBroker(app.log);
|
||||
|
||||
// --- Tool registry extension ---
|
||||
// Append BooCoder write tools (adapted to BooChat's ToolDef interface) to
|
||||
// the shared ALL_TOOLS registry. appendMcpTools re-sorts and rebuilds
|
||||
// TOOLS_BY_NAME so tool-phase.ts dispatch sees the full set.
|
||||
const adaptedWriteTools = WRITE_TOOLS.map((t) => adaptWriteTool(t));
|
||||
appendMcpTools(adaptedWriteTools);
|
||||
app.log.info(`tool registry: ${ALL_TOOLS.length} tools loaded (${WRITE_TOOLS.length} write tools)`);
|
||||
|
||||
// Inference runner: same engine as BooChat, uses ALL_TOOLS (which includes
|
||||
// the appended write tools) for tool dispatch.
|
||||
const inference = createInferenceRunner(
|
||||
{
|
||||
sql,
|
||||
config: config as unknown as ServerConfig,
|
||||
log: app.log,
|
||||
publish: (sessionId, frame) => {
|
||||
broker.publishFrame(sessionId, frame as unknown as WsFrame);
|
||||
},
|
||||
broker,
|
||||
},
|
||||
(user, frame) => {
|
||||
broker.publishUserFrame(user, frame as unknown as WsFrame);
|
||||
}
|
||||
);
|
||||
|
||||
// Wrap the inference runner to set/clear the write-tool context around each run.
|
||||
// The inference runner calls enqueue() which fires asynchronously — we hook
|
||||
// into the enqueue to set context before the run starts.
|
||||
const inferenceApi = {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => {
|
||||
// Set the inference context so write tools can access sql + sessionId.
|
||||
// The context persists for the duration of the inference run. Since
|
||||
// BooCoder is single-user and runs one inference at a time per session,
|
||||
// this module-level state is safe.
|
||||
setInferenceContext({ sql, sessionId, taskId: null });
|
||||
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||
},
|
||||
cancel: async (sessionId: string, chatId: string) => {
|
||||
const result = await inference.cancel(sessionId, chatId);
|
||||
clearInferenceContext();
|
||||
return result;
|
||||
},
|
||||
hasActive: (chatId: string) => inference.hasActive(chatId),
|
||||
};
|
||||
|
||||
// Register WebSocket support
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
// Health endpoint
|
||||
app.get('/api/health', async (_req, reply) => {
|
||||
const dbOk = await pingDb(sql);
|
||||
const status = dbOk ? 200 : 503;
|
||||
return reply.status(status).send({ ok: dbOk, db: dbOk });
|
||||
return reply.status(status).send({
|
||||
ok: dbOk,
|
||||
db: dbOk,
|
||||
tools: ALL_TOOLS.length,
|
||||
});
|
||||
});
|
||||
|
||||
// Register routes
|
||||
registerMessageRoutes(app, sql, broker, inferenceApi);
|
||||
registerPendingRoutes(app, sql);
|
||||
registerWebSocket(app, sql, broker);
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async () => {
|
||||
|
||||
126
apps/coder/src/routes/messages.ts
Normal file
126
apps/coder/src/routes/messages.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
|
||||
const SendBody = z.object({
|
||||
content: z.string().min(1).max(64_000),
|
||||
chat_id: z.string().uuid(),
|
||||
});
|
||||
|
||||
interface InferenceApi {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||
hasActive: (chatId: string) => boolean;
|
||||
}
|
||||
|
||||
export function registerMessageRoutes(
|
||||
app: FastifyInstance,
|
||||
sql: Sql,
|
||||
broker: Broker,
|
||||
inference: InferenceApi,
|
||||
): void {
|
||||
// POST /api/sessions/:sessionId/messages — send a user message + kick off inference
|
||||
app.post<{ Params: { sessionId: string } }>(
|
||||
'/api/sessions/:sessionId/messages',
|
||||
async (req, reply) => {
|
||||
const parsed = SendBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const sessionId = req.params.sessionId;
|
||||
const { content, chat_id: chatId } = parsed.data;
|
||||
|
||||
// Validate session exists
|
||||
const sessionRows = await sql<{ id: string }[]>`
|
||||
SELECT id FROM sessions WHERE id = ${sessionId}
|
||||
`;
|
||||
if (sessionRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'session not found' };
|
||||
}
|
||||
|
||||
// Validate chat belongs to session and is open
|
||||
const chatRows = await sql<{ id: string; session_id: string }[]>`
|
||||
SELECT id, session_id FROM chats WHERE id = ${chatId} AND session_id = ${sessionId} AND status = 'open'
|
||||
`;
|
||||
if (chatRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'chat not found or not open in this session' };
|
||||
}
|
||||
|
||||
// Reject if inference is already running on this chat
|
||||
if (inference.hasActive(chatId)) {
|
||||
reply.code(409);
|
||||
return { error: 'inference already running on this chat' };
|
||||
}
|
||||
|
||||
// Create user message + streaming assistant row in a transaction
|
||||
const result = await sql.begin(async (tx) => {
|
||||
const [userMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
|
||||
});
|
||||
|
||||
// Publish user message frames so WS subscribers see it immediately
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_started',
|
||||
message_id: result.user_message_id,
|
||||
chat_id: chatId,
|
||||
role: 'user',
|
||||
} as unknown as WsFrame);
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'delta',
|
||||
message_id: result.user_message_id,
|
||||
chat_id: chatId,
|
||||
content,
|
||||
} as unknown as WsFrame);
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: result.user_message_id,
|
||||
chat_id: chatId,
|
||||
} as unknown as WsFrame);
|
||||
|
||||
// Enqueue inference — the runner will stream assistant deltas via broker
|
||||
inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default');
|
||||
|
||||
reply.code(202);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/sessions/:sessionId/stop — cancel active inference
|
||||
app.post<{ Params: { sessionId: string } }>(
|
||||
'/api/sessions/:sessionId/stop',
|
||||
async (req, reply) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
|
||||
// Find active chats in this session
|
||||
const chats = await sql<{ id: string }[]>`
|
||||
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open'
|
||||
`;
|
||||
let cancelled = false;
|
||||
for (const chat of chats) {
|
||||
if (inference.hasActive(chat.id)) {
|
||||
cancelled = await inference.cancel(sessionId, chat.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { cancelled };
|
||||
},
|
||||
);
|
||||
}
|
||||
121
apps/coder/src/routes/pending.ts
Normal file
121
apps/coder/src/routes/pending.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
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;
|
||||
},
|
||||
);
|
||||
}
|
||||
51
apps/coder/src/routes/ws.ts
Normal file
51
apps/coder/src/routes/ws.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
|
||||
export function registerWebSocket(
|
||||
app: FastifyInstance,
|
||||
sql: Sql,
|
||||
broker: Broker,
|
||||
): void {
|
||||
// Per-session streaming WebSocket. Clients connect here to receive live
|
||||
// inference frames (deltas, tool_calls, tool_results, message_complete).
|
||||
app.get<{ Params: { sessionId: string } }>(
|
||||
'/api/ws/sessions/:sessionId',
|
||||
{ websocket: true },
|
||||
async (socket, req) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
|
||||
// Validate session exists
|
||||
const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`;
|
||||
if (session.length === 0) {
|
||||
socket.send(JSON.stringify({ type: 'error', error: 'session not found' }));
|
||||
socket.close(1008, 'session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send snapshot of existing messages so client can hydrate
|
||||
const messages = await sql<Record<string, unknown>[]>`
|
||||
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
||||
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
||||
summary, tail_start_id, compacted_at
|
||||
FROM messages_with_parts
|
||||
WHERE session_id = ${sessionId}
|
||||
ORDER BY created_at ASC, id ASC
|
||||
`;
|
||||
socket.send(JSON.stringify({ type: 'snapshot', messages }));
|
||||
|
||||
// Subscribe to broker for live frames
|
||||
const unsubscribe = broker.subscribe(sessionId, (frame) => {
|
||||
if (socket.readyState !== socket.OPEN) return;
|
||||
try {
|
||||
socket.send(JSON.stringify(frame));
|
||||
} catch (err) {
|
||||
app.log.warn({ err, sessionId }, 'ws send failed');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => unsubscribe());
|
||||
socket.on('error', () => unsubscribe());
|
||||
},
|
||||
);
|
||||
}
|
||||
115
apps/coder/src/services/__tests__/write_guard.test.ts
Normal file
115
apps/coder/src/services/__tests__/write_guard.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveWritePath, isSecretPath, WriteGuardError } from '../write_guard.js';
|
||||
|
||||
const PROJECT_ROOT = '/opt/projects/my-app';
|
||||
|
||||
describe('resolveWritePath', () => {
|
||||
it('resolves a relative path correctly', () => {
|
||||
const result = resolveWritePath(PROJECT_ROOT, 'src/index.ts');
|
||||
expect(result).toBe('/opt/projects/my-app/src/index.ts');
|
||||
});
|
||||
|
||||
it('resolves nested relative path', () => {
|
||||
const result = resolveWritePath(PROJECT_ROOT, 'src/lib/utils.ts');
|
||||
expect(result).toBe('/opt/projects/my-app/src/lib/utils.ts');
|
||||
});
|
||||
|
||||
it('throws on ../ escape', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '../../../etc/passwd')).toThrow(WriteGuardError);
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '../../../etc/passwd')).toThrow('path escapes project root');
|
||||
});
|
||||
|
||||
it('throws on absolute path outside project root', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '/etc/shadow')).toThrow(WriteGuardError);
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '/tmp/exploit')).toThrow('path escapes project root');
|
||||
});
|
||||
|
||||
it('allows absolute path inside project root', () => {
|
||||
const result = resolveWritePath(PROJECT_ROOT, '/opt/projects/my-app/src/new.ts');
|
||||
expect(result).toBe('/opt/projects/my-app/src/new.ts');
|
||||
});
|
||||
|
||||
it('denies .env files', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '.env')).toThrow(WriteGuardError);
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '.env')).toThrow('cannot write to secret file');
|
||||
});
|
||||
|
||||
it('denies .env.local', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '.env.local')).toThrow(WriteGuardError);
|
||||
});
|
||||
|
||||
it('denies .env.production', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '.env.production')).toThrow(WriteGuardError);
|
||||
});
|
||||
|
||||
it('denies *.pem files', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, 'certs/server.pem')).toThrow(WriteGuardError);
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, 'certs/server.pem')).toThrow('cannot write to secret file');
|
||||
});
|
||||
|
||||
it('denies *.key files', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, 'ssl/private.key')).toThrow(WriteGuardError);
|
||||
});
|
||||
|
||||
it('denies id_rsa', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '.ssh/id_rsa')).toThrow(WriteGuardError);
|
||||
});
|
||||
|
||||
it('denies id_ed25519', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '.ssh/id_ed25519')).toThrow(WriteGuardError);
|
||||
});
|
||||
|
||||
it('denies credentials.json', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, 'credentials.json')).toThrow(WriteGuardError);
|
||||
});
|
||||
|
||||
it('passes a normal file inside project', () => {
|
||||
const result = resolveWritePath(PROJECT_ROOT, 'src/components/Button.tsx');
|
||||
expect(result).toBe('/opt/projects/my-app/src/components/Button.tsx');
|
||||
});
|
||||
|
||||
it('passes a non-existent nested file (no realpath)', () => {
|
||||
// This is the key difference from BooChat's pathGuard: no realpath means
|
||||
// files that don't exist yet still pass validation
|
||||
const result = resolveWritePath(PROJECT_ROOT, 'src/new-dir/new-file.ts');
|
||||
expect(result).toBe('/opt/projects/my-app/src/new-dir/new-file.ts');
|
||||
});
|
||||
|
||||
it('throws on null/empty path', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '')).toThrow(WriteGuardError);
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '')).toThrow('file path is required');
|
||||
});
|
||||
|
||||
it('normalizes ../ within project root and still allows', () => {
|
||||
const result = resolveWritePath(PROJECT_ROOT, 'src/../lib/utils.ts');
|
||||
expect(result).toBe('/opt/projects/my-app/lib/utils.ts');
|
||||
});
|
||||
|
||||
it('rejects path that looks inside root but normalizes outside', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, 'src/../../other-project/hack.ts')).toThrow(WriteGuardError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSecretPath', () => {
|
||||
it('detects .env', () => {
|
||||
expect(isSecretPath('.env')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects nested .env', () => {
|
||||
expect(isSecretPath('config/.env')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects *.pfx', () => {
|
||||
expect(isSecretPath('certs/client.pfx')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag normal source files', () => {
|
||||
expect(isSecretPath('src/index.ts')).toBe(false);
|
||||
expect(isSecretPath('README.md')).toBe(false);
|
||||
expect(isSecretPath('package.json')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty string', () => {
|
||||
expect(isSecretPath('')).toBe(false);
|
||||
});
|
||||
});
|
||||
224
apps/coder/src/services/pending_changes.ts
Normal file
224
apps/coder/src/services/pending_changes.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
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
|
||||
`;
|
||||
}
|
||||
30
apps/coder/src/services/tools/adapter.ts
Normal file
30
apps/coder/src/services/tools/adapter.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Adapts BooCoder write tools (which take ToolContext) into BooChat's ToolDef
|
||||
* interface (which takes `projectRoot, extraRoots?`).
|
||||
*
|
||||
* The adapter reads the module-level inference context at execute time, so the
|
||||
* wrapping happens at boot (static) — no per-inference re-wrap needed.
|
||||
*/
|
||||
|
||||
import type { ToolDef as ServerToolDef } from '@boocode/server/tools';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { getInferenceContext } from './inference_context.js';
|
||||
|
||||
/**
|
||||
* Wrap a BooCoder write tool (execute takes ToolContext) into a BooChat
|
||||
* ToolDef (execute takes projectRoot + optional extraRoots). The adapter
|
||||
* builds the ToolContext from the module-level inference context at call time.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function adaptWriteTool(tool: ToolDef<any>): ServerToolDef<any> {
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
jsonSchema: tool.jsonSchema,
|
||||
async execute(input: unknown, projectRoot: string, _extraRoots?: readonly string[]): Promise<unknown> {
|
||||
const ctx: ToolContext = getInferenceContext();
|
||||
return tool.execute(input, projectRoot, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
44
apps/coder/src/services/tools/apply_pending.ts
Normal file
44
apps/coder/src/services/tools/apply_pending.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { applyAll } from '../pending_changes.js';
|
||||
|
||||
const ApplyPendingInput = z.object({});
|
||||
type ApplyPendingInputT = z.infer<typeof ApplyPendingInput>;
|
||||
|
||||
export const applyPendingTool: ToolDef<ApplyPendingInputT> = {
|
||||
name: 'apply_pending',
|
||||
description:
|
||||
'Apply all pending changes for the current session to disk. ' +
|
||||
'Each queued create/edit/delete is executed in order.',
|
||||
inputSchema: ApplyPendingInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'apply_pending',
|
||||
description:
|
||||
'Apply all pending changes for the current session to disk. ' +
|
||||
'Each queued create/edit/delete is executed in order.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(_input: ApplyPendingInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
const results = await applyAll(context.sql, context.sessionId, projectRoot);
|
||||
const succeeded = results.filter((r) => r.success).length;
|
||||
const failed = results.filter((r) => !r.success).length;
|
||||
|
||||
return {
|
||||
total: results.length,
|
||||
succeeded,
|
||||
failed,
|
||||
results,
|
||||
message:
|
||||
results.length === 0
|
||||
? 'No pending changes to apply.'
|
||||
: `Applied ${succeeded}/${results.length} changes.${failed > 0 ? ` ${failed} failed.` : ''}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
51
apps/coder/src/services/tools/create_file.ts
Normal file
51
apps/coder/src/services/tools/create_file.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { queueCreate } from '../pending_changes.js';
|
||||
|
||||
const CreateFileInput = z.object({
|
||||
file_path: z.string().min(1),
|
||||
content: z.string(),
|
||||
});
|
||||
type CreateFileInputT = z.infer<typeof CreateFileInput>;
|
||||
|
||||
export const createFileTool: ToolDef<CreateFileInputT> = {
|
||||
name: 'create_file',
|
||||
description:
|
||||
'Queue creation of a new file with the given content. ' +
|
||||
'The change is staged in pending_changes and must be applied explicitly.',
|
||||
inputSchema: CreateFileInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_file',
|
||||
description:
|
||||
'Queue creation of a new file with the given content. ' +
|
||||
'The change is staged in pending_changes and must be applied explicitly.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string', description: 'Path for the new file (relative to project root or absolute)' },
|
||||
content: { type: 'string', description: 'Full content of the file to create' },
|
||||
},
|
||||
required: ['file_path', 'content'],
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input: CreateFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
const change = await queueCreate(
|
||||
context.sql,
|
||||
context.sessionId,
|
||||
context.taskId,
|
||||
input.file_path,
|
||||
input.content,
|
||||
projectRoot,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: 'create',
|
||||
message: `File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
};
|
||||
},
|
||||
};
|
||||
48
apps/coder/src/services/tools/delete_file.ts
Normal file
48
apps/coder/src/services/tools/delete_file.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { queueDelete } from '../pending_changes.js';
|
||||
|
||||
const DeleteFileInput = z.object({
|
||||
file_path: z.string().min(1),
|
||||
});
|
||||
type DeleteFileInputT = z.infer<typeof DeleteFileInput>;
|
||||
|
||||
export const deleteFileTool: ToolDef<DeleteFileInputT> = {
|
||||
name: 'delete_file',
|
||||
description:
|
||||
'Queue deletion of a file. ' +
|
||||
'The change is staged in pending_changes and must be applied explicitly.',
|
||||
inputSchema: DeleteFileInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'delete_file',
|
||||
description:
|
||||
'Queue deletion of a file. ' +
|
||||
'The change is staged in pending_changes and must be applied explicitly.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string', description: 'Path to the file to delete (relative to project root or absolute)' },
|
||||
},
|
||||
required: ['file_path'],
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input: DeleteFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
const change = await queueDelete(
|
||||
context.sql,
|
||||
context.sessionId,
|
||||
context.taskId,
|
||||
input.file_path,
|
||||
projectRoot,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: 'delete',
|
||||
message: `File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
};
|
||||
},
|
||||
};
|
||||
54
apps/coder/src/services/tools/edit_file.ts
Normal file
54
apps/coder/src/services/tools/edit_file.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { queueEdit } from '../pending_changes.js';
|
||||
|
||||
const EditFileInput = z.object({
|
||||
file_path: z.string().min(1),
|
||||
old_string: z.string().min(1),
|
||||
new_string: z.string(),
|
||||
});
|
||||
type EditFileInputT = z.infer<typeof EditFileInput>;
|
||||
|
||||
export const editFileTool: ToolDef<EditFileInputT> = {
|
||||
name: 'edit_file',
|
||||
description:
|
||||
'Queue an edit to a file. The edit replaces old_string with new_string. ' +
|
||||
'The change is staged in pending_changes and must be applied explicitly.',
|
||||
inputSchema: EditFileInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'edit_file',
|
||||
description:
|
||||
'Queue an edit to a file. The edit replaces old_string with new_string. ' +
|
||||
'The change is staged in pending_changes and must be applied explicitly.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string', description: 'Path to the file to edit (relative to project root or absolute)' },
|
||||
old_string: { type: 'string', description: 'The exact string to find and replace (must appear in the file)' },
|
||||
new_string: { type: 'string', description: 'The replacement string' },
|
||||
},
|
||||
required: ['file_path', 'old_string', 'new_string'],
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input: EditFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
const change = await queueEdit(
|
||||
context.sql,
|
||||
context.sessionId,
|
||||
context.taskId,
|
||||
input.file_path,
|
||||
input.old_string,
|
||||
input.new_string,
|
||||
projectRoot,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: 'edit',
|
||||
message: `Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
};
|
||||
},
|
||||
};
|
||||
26
apps/coder/src/services/tools/index.ts
Normal file
26
apps/coder/src/services/tools/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ToolDef } from './types.js';
|
||||
import { editFileTool } from './edit_file.js';
|
||||
import { createFileTool } from './create_file.js';
|
||||
import { deleteFileTool } from './delete_file.js';
|
||||
import { applyPendingTool } from './apply_pending.js';
|
||||
import { rewindTool } from './rewind.js';
|
||||
|
||||
export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
|
||||
|
||||
// All BooCoder write tools. The inference loop (Phase 2B) will combine these
|
||||
// with BooChat's read-only tools to form the full tool set available to agents.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const WRITE_TOOLS: readonly ToolDef<any>[] = [
|
||||
applyPendingTool,
|
||||
createFileTool,
|
||||
deleteFileTool,
|
||||
editFileTool,
|
||||
rewindTool,
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const WRITE_TOOLS_BY_NAME: ReadonlyMap<string, ToolDef<any>> = new Map(
|
||||
WRITE_TOOLS.map((t) => [t.name, t]),
|
||||
);
|
||||
|
||||
export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool };
|
||||
36
apps/coder/src/services/tools/inference_context.ts
Normal file
36
apps/coder/src/services/tools/inference_context.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Sql } from '../../db.js';
|
||||
|
||||
/**
|
||||
* Module-level inference context for write tools.
|
||||
*
|
||||
* Set via `setInferenceContext()` before each inference run starts.
|
||||
* Write tools read it via `getInferenceContext()` during execute.
|
||||
* Same pattern as BooChat's `loadConfig()` singleton — tools need
|
||||
* ambient state that can't be threaded through the tool-phase execute
|
||||
* signature (which is `execute(input, projectRoot, extraRoots?)`).
|
||||
*/
|
||||
|
||||
export interface InferenceContext {
|
||||
sql: Sql;
|
||||
sessionId: string;
|
||||
taskId: string | null;
|
||||
}
|
||||
|
||||
let current: InferenceContext | null = null;
|
||||
|
||||
export function setInferenceContext(ctx: InferenceContext): void {
|
||||
current = ctx;
|
||||
}
|
||||
|
||||
export function clearInferenceContext(): void {
|
||||
current = null;
|
||||
}
|
||||
|
||||
export function getInferenceContext(): InferenceContext {
|
||||
if (!current) {
|
||||
throw new Error(
|
||||
'Write tool called outside inference context — setInferenceContext() was not called before this run',
|
||||
);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
71
apps/coder/src/services/tools/rewind.ts
Normal file
71
apps/coder/src/services/tools/rewind.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { rewindOne } from '../pending_changes.js';
|
||||
|
||||
const RewindInput = z.object({
|
||||
change_id: z.string().uuid().optional(),
|
||||
all: z.boolean().optional(),
|
||||
});
|
||||
type RewindInputT = z.infer<typeof RewindInput>;
|
||||
|
||||
export const rewindTool: ToolDef<RewindInputT> = {
|
||||
name: 'rewind',
|
||||
description:
|
||||
'Revert applied changes. Provide change_id to revert a specific change, ' +
|
||||
'or set all=true to revert all applied changes for the session (in reverse order).',
|
||||
inputSchema: RewindInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'rewind',
|
||||
description:
|
||||
'Revert applied changes. Provide change_id to revert a specific change, ' +
|
||||
'or set all=true to revert all applied changes for the session (in reverse order).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
change_id: { type: 'string', format: 'uuid', description: 'ID of a specific change to revert' },
|
||||
all: { type: 'boolean', description: 'If true, revert all applied changes for this session' },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input: RewindInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
if (input.change_id) {
|
||||
const result = await rewindOne(context.sql, input.change_id, projectRoot);
|
||||
return {
|
||||
results: [result],
|
||||
message: result.success
|
||||
? `Reverted change ${input.change_id} (${result.operation} on ${result.file_path}).`
|
||||
: `Failed to revert: ${result.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.all) {
|
||||
// Rewind all applied changes for this session in reverse order
|
||||
const applied = await context.sql<{ id: string }[]>`
|
||||
SELECT id FROM pending_changes
|
||||
WHERE session_id = ${context.sessionId} AND status = 'applied'
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
const results = [];
|
||||
for (const row of applied) {
|
||||
results.push(await rewindOne(context.sql, row.id, projectRoot));
|
||||
}
|
||||
const succeeded = results.filter((r) => r.success).length;
|
||||
return {
|
||||
total: results.length,
|
||||
succeeded,
|
||||
failed: results.length - succeeded,
|
||||
results,
|
||||
message:
|
||||
results.length === 0
|
||||
? 'No applied changes to revert.'
|
||||
: `Reverted ${succeeded}/${results.length} changes.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { error: 'Provide either change_id or all=true.' };
|
||||
},
|
||||
};
|
||||
32
apps/coder/src/services/tools/types.ts
Normal file
32
apps/coder/src/services/tools/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { z } from 'zod';
|
||||
import type { Sql } from '../../db.js';
|
||||
|
||||
export interface ToolJsonSchema {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to BooCoder tool execute functions.
|
||||
*
|
||||
* Unlike BooChat's tools (which only need projectRoot), BooCoder's write tools
|
||||
* interact with the database (pending_changes table) and need session/task
|
||||
* context for proper attribution.
|
||||
*/
|
||||
export interface ToolContext {
|
||||
sql: Sql;
|
||||
sessionId: string;
|
||||
taskId: string | null;
|
||||
}
|
||||
|
||||
export interface ToolDef<TInput> {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: z.ZodType<TInput>;
|
||||
jsonSchema: ToolJsonSchema;
|
||||
execute(input: TInput, projectRoot: string, context: ToolContext): Promise<unknown>;
|
||||
}
|
||||
73
apps/coder/src/services/write_guard.ts
Normal file
73
apps/coder/src/services/write_guard.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { resolve, sep } from 'node:path';
|
||||
|
||||
export class WriteGuardError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'WriteGuardError';
|
||||
}
|
||||
}
|
||||
|
||||
// Deny list: files that should never be written regardless of path-guard.
|
||||
// Subset of BooChat's secret_guard.ts — covers the most dangerous patterns.
|
||||
// Full parity with BooChat's deny list is not needed for write-guard because
|
||||
// the write tools are intentional (model chose to create/edit); we block only
|
||||
// files that are unambiguously secrets.
|
||||
const SECRET_PATTERNS: readonly string[] = [
|
||||
'.env',
|
||||
'.env.local',
|
||||
'.env.production',
|
||||
'.env.development',
|
||||
'.env.staging',
|
||||
'id_rsa',
|
||||
'id_dsa',
|
||||
'id_ecdsa',
|
||||
'id_ed25519',
|
||||
'*.pem',
|
||||
'*.key',
|
||||
'*.p12',
|
||||
'*.pfx',
|
||||
'*.crt',
|
||||
'credentials.json',
|
||||
'*.kdbx',
|
||||
'.netrc',
|
||||
];
|
||||
|
||||
export function isSecretPath(filePath: string): boolean {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
const segments = normalized.split('/').filter((s) => s.length > 0);
|
||||
if (segments.length === 0) return false;
|
||||
const basename = segments[segments.length - 1]!;
|
||||
|
||||
return SECRET_PATTERNS.some((pattern) => {
|
||||
if (pattern.startsWith('*')) {
|
||||
return basename.endsWith(pattern.slice(1));
|
||||
}
|
||||
return basename === pattern;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and validate a write target path.
|
||||
*
|
||||
* Key difference from BooChat's pathGuard: no realpath() — the file may not
|
||||
* exist yet (creates). Uses resolve() to normalize ../ segments and then
|
||||
* checks the result stays within projectRoot.
|
||||
*/
|
||||
export function resolveWritePath(projectRoot: string, filePath: string): string {
|
||||
if (!filePath || filePath.length === 0) {
|
||||
throw new WriteGuardError('file path is required');
|
||||
}
|
||||
|
||||
const candidate = filePath.startsWith('/') ? filePath : resolve(projectRoot, filePath);
|
||||
const normalized = resolve(candidate); // normalizes ../ segments
|
||||
|
||||
if (!normalized.startsWith(projectRoot + sep) && normalized !== projectRoot) {
|
||||
throw new WriteGuardError(`path escapes project root: ${filePath}`);
|
||||
}
|
||||
|
||||
if (isSecretPath(normalized)) {
|
||||
throw new WriteGuardError(`cannot write to secret file: ${filePath}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
9
apps/coder/vitest.config.ts
Normal file
9
apps/coder/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: false,
|
||||
include: ['src/**/__tests__/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
@@ -4,6 +4,23 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"exports": {
|
||||
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||
"./inference": { "types": "./dist/services/inference/index.d.ts", "default": "./dist/services/inference/index.js" },
|
||||
"./tools": { "types": "./dist/services/tools.d.ts", "default": "./dist/services/tools.js" },
|
||||
"./broker": { "types": "./dist/services/broker.d.ts", "default": "./dist/services/broker.js" },
|
||||
"./compaction": { "types": "./dist/services/compaction.d.ts", "default": "./dist/services/compaction.js" },
|
||||
"./model-context": { "types": "./dist/services/model-context.d.ts", "default": "./dist/services/model-context.js" },
|
||||
"./system-prompt": { "types": "./dist/services/system-prompt.d.ts", "default": "./dist/services/system-prompt.js" },
|
||||
"./agents": { "types": "./dist/services/agents.d.ts", "default": "./dist/services/agents.js" },
|
||||
"./truncate": { "types": "./dist/services/truncate.d.ts", "default": "./dist/services/truncate.js" },
|
||||
"./path-guard": { "types": "./dist/services/path_guard.d.ts", "default": "./dist/services/path_guard.js" },
|
||||
"./file-ops": { "types": "./dist/services/file_ops.d.ts", "default": "./dist/services/file_ops.js" },
|
||||
"./types": { "types": "./dist/types/api.d.ts", "default": "./dist/types/api.js" },
|
||||
"./ws-frames": { "types": "./dist/types/ws-frames.d.ts", "default": "./dist/types/ws-frames.js" },
|
||||
"./db": { "types": "./dist/db.d.ts", "default": "./dist/db.js" },
|
||||
"./config": { "types": "./dist/config.d.ts", "default": "./dist/config.js" }
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"declaration": false,
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -48,6 +48,9 @@ importers:
|
||||
|
||||
apps/coder:
|
||||
dependencies:
|
||||
'@boocode/server':
|
||||
specifier: workspace:*
|
||||
version: link:../server
|
||||
'@fastify/static':
|
||||
specifier: ^7.0.4
|
||||
version: 7.0.4
|
||||
@@ -73,6 +76,9 @@ importers:
|
||||
typescript:
|
||||
specifier: ^5.5.0
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.0.0
|
||||
version: 3.2.4(@types/debug@4.1.13)(@types/node@20.19.41)(lightningcss@1.32.0)(msw@2.14.6(@types/node@20.19.41)(typescript@5.9.3))
|
||||
|
||||
apps/server:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user