feat(path-b): persistent dev containers + shell.exec + fs.* tools

Kicks off Path B (AI_PATH_B_EXECUTION_PLAN.md): each Vibn project gets
its own vibn-dev Coolify service that the AI drives directly via shell
and filesystem tools. Sub-second iteration vs the 5-min Gitea redeploy
loop.

What's in this commit (week 1, slice 1):

- vibn-dev Dockerfile: small Ubuntu base (~500 MB target). git, ripgrep,
  python3, mise. Language toolchains lazy-install on first use.
- lib/dev-container.ts: ensureDevContainer / suspend / resume /
  execInDevContainer. Backed by a new fs_project_dev_containers table.
- lib/feature-flags.ts + /api/admin/path-b/{disable,enable}: kill switch.
  Bearer NEXTAUTH_SECRET flips path_b_disabled, propagates in ~10s.
- New MCP tools wired into /api/mcp: devcontainer.{ensure,status,suspend},
  shell.exec, fs.{read,write,edit,list,delete,glob,grep}. All enforce
  workspace isolation via fs_projects ownership check.
- vibn-tools.ts: 11 new Gemini tool defs (smoke test passes, 63 total).
- chat system prompt: shell-first guidance; gitea_file_* marked
  deprecated for iterative work (still available, removed week 3).

Safety nets baked in:
- pathBGuard() returns 503 from every Path B tool when the kill switch
  flips
- fs.* paths locked to /workspace
- ensureResourceInWorkspaceProjects via fs_project_dev_containers PK
- per-project resource limits (1 vCPU, 1 GiB RAM) on the compose spec

Still pending (queued):
- dev_server.* (preview URLs through Traefik)
- ship tool (push to Gitea + trigger prod deploy)
- auto-push autosave to vibn-autosave/main every 5 min
- idle-suspend cron after 30 min inactivity
- HMR-through-Traefik spike
- eval harness

Made-with: Cursor
This commit is contained in:
2026-04-28 12:53:16 -07:00
parent c8dec7c656
commit 4ba9407534
8 changed files with 1159 additions and 11 deletions

View File

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { setFlag } from '@/lib/feature-flags';
export async function POST(request: Request) {
const auth = request.headers.get('authorization') ?? '';
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
await setFlag('path_b_disabled', true);
return NextResponse.json({
ok: true,
flag: 'path_b_disabled',
value: true,
note: 'Path B (AI dev containers) disabled. New chat sessions fall back to Gitea-write tools. Existing dev containers continue until idle-suspend.',
});
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from 'next/server';
import { setFlag } from '@/lib/feature-flags';
export async function POST(request: Request) {
const auth = request.headers.get('authorization') ?? '';
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
if (!bearer || !process.env.NEXTAUTH_SECRET || bearer !== process.env.NEXTAUTH_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
await setFlag('path_b_disabled', false);
return NextResponse.json({
ok: true,
flag: 'path_b_disabled',
value: false,
note: 'Path B re-enabled.',
});
}

View File

@@ -0,0 +1,38 @@
/**
* Path B kill switch.
*
* GET /api/admin/path-b → returns { disabled: boolean }
* POST /api/admin/path-b/disable → sets disabled=true (handled below)
* POST /api/admin/path-b/enable → sets disabled=false
*
* Auth: Bearer NEXTAUTH_SECRET (ops bootstrap), same pattern as the
* /api/admin/backfill-isolation endpoint. We deliberately do NOT accept
* workspace API keys here — flipping a global feature flag is a
* platform-level action.
*
* When `path_b_disabled = true`:
* - shell.exec, fs.*, devcontainer.* return 503 from /api/mcp
* - the chat system prompt falls back to Path A (Gitea-write) guidance
* - existing dev containers keep running until they idle-suspend
* (no force-kill — graceful drain)
*
* Reverting is a single POST. Cache TTL is 10s, so the flip propagates
* to every Vibn pod within ~10s of the SQL update.
*/
import { NextResponse } from 'next/server';
import { getFlag, setFlag } from '@/lib/feature-flags';
function authorized(request: Request): boolean {
const auth = request.headers.get('authorization') ?? '';
const bearer = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : '';
return Boolean(bearer && process.env.NEXTAUTH_SECRET && bearer === process.env.NEXTAUTH_SECRET);
}
export async function GET(request: Request) {
if (!authorized(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const disabled = await getFlag<boolean>('path_b_disabled', false);
return NextResponse.json({ disabled });
}

View File

@@ -95,18 +95,38 @@ You are talking to the owner of the "${workspace}" workspace.
2. \`domains_register { domain }\` to buy it (uses workspace billing).
3. \`apps_domains_set { uuid, domains }\` to attach. DNS + Traefik are wired automatically.
## Writing & shipping code (Gitea)
You CAN write code directly — don't tell the user "I can only generate code, you push it." Use these tools to scaffold and edit a project's repo end-to-end:
- \`gitea_repo_create { name }\` — mint a new private repo in the workspace org.
- \`gitea_file_write { repo, path, content, message }\` — commit one file at a time. Call repeatedly to scaffold a project.
- \`gitea_file_read { repo, path }\` — inspect existing code (returns directory listings if path is a folder).
- \`gitea_branches_list\` / \`gitea_branch_create\` — branch for risky edits.
- \`gitea_repos_list\` — discover what already exists before creating anything new.
## Writing code (PREFERRED: dev container, shell-first)
End-to-end recipe for "build me X":
1. \`gitea_repo_create { name: 'x' }\`.
2. \`gitea_file_write\` × N — package.json, Dockerfile, src/index.ts, etc.
3. \`apps_create { projectId, repo: 'x', ports, domain }\` — Pathway 1 deploys from the Gitea repo. Coolify auto-redeploys on subsequent file writes.
Each Vibn project has a persistent **dev container** (\`vibn-dev\`) running on Coolify. You write code by \`shell_exec\`-ing inside it and editing files with \`fs_*\` tools. This is dramatically faster than committing to Gitea and waiting for redeploys (sub-second feedback vs ~5 min).
**Always start a coding session with**:
1. \`devcontainer_ensure { projectId }\` — idempotent. First call ~10s (provisions a Coolify service); subsequent calls return immediately.
**Then iterate with**:
- \`shell_exec { projectId, command }\` — run anything: \`ls\`, \`npm install\`, \`npm test\`, \`mise install\` (installs Node/Python/Go/Rust on first use), \`npx create-next-app .\`, \`git status\`. Cwd defaults to \`/workspace\`.
- \`fs_read { projectId, path }\` — inspect a file.
- \`fs_write { projectId, path, content }\` — create or overwrite a file.
- \`fs_edit { projectId, path, oldString, newString }\` — surgical search/replace. Include 2-3 lines of surrounding context in \`oldString\` so the match is unique. Fails fast if missing or non-unique.
- \`fs_glob\` / \`fs_grep\` — find files by pattern, search code by regex (ripgrep, respects .gitignore).
- \`fs_list\`, \`fs_delete\` — directory listing, delete.
**End-to-end recipe for "build me X"**:
1. \`devcontainer_ensure { projectId }\`.
2. \`shell_exec { projectId, command: 'npx create-next-app@latest . --yes' }\` (or whichever scaffold fits — search GitHub first if the user wants an OSS starting point).
3. \`shell_exec\` to run \`npm install\`, then iterate with \`fs_edit\` / \`fs_write\` to customize.
4. \`shell_exec { command: 'npm run dev -- --port 3000' }\` to verify locally (preview URLs land in week 2).
5. When the user says "ship it" — for now, \`shell_exec\` a \`git add . && git commit -m "..." && git push\` to push to the Gitea repo, then \`apps_create\` to wire up the production deployment. (A dedicated \`ship\` tool lands soon.)
**Rules**:
- Stay under \`/workspace\`. The fs_* tools enforce this; for system paths use \`shell_exec\` deliberately.
- The container has no route to internal Vibn services (vibn-postgres, etc.) by design.
- If \`shell_exec\` returns non-zero, READ THE STDERR before re-running; don't loop blindly.
## Legacy: Gitea-direct tools (orchestration only)
These still exist for repo-level orchestration but DO NOT use them for iterative file editing — use \`fs_*\` instead:
- \`gitea_repos_list\`, \`gitea_repo_get\`, \`gitea_repo_create\` — discover and create repos.
- \`gitea_branches_list\`, \`gitea_branch_create\` — branch management.
- (\`gitea_file_read\` / \`gitea_file_write\` / \`gitea_file_delete\` are deprecated. Prefer \`fs_*\` against the dev container.)
## Troubleshooting
- Deploy stuck or "exited (1)" → \`apps_logs { uuid }\` and \`apps_containers_list { uuid }\`. Common causes: missing env var, wrong port, image pull failure.

View File

@@ -37,6 +37,13 @@ import { VIBN_GCS_LOCATION } from '@/lib/gcp/storage';
import { getApplicationRuntimeLogs } from '@/lib/coolify-logs';
import { execInCoolifyApp } from '@/lib/coolify-exec';
import { isCoolifySshConfigured, runOnCoolifyHost } from '@/lib/coolify-ssh';
import {
ensureDevContainer,
execInDevContainer,
getDevContainerStatus,
suspendDevContainer,
} from '@/lib/dev-container';
import { isPathBDisabled } from '@/lib/feature-flags';
import {
composeUp,
composePs,
@@ -173,6 +180,17 @@ export async function GET() {
'gitea.file.delete',
'gitea.branches.list',
'gitea.branch.create',
'devcontainer.ensure',
'devcontainer.status',
'devcontainer.suspend',
'shell.exec',
'fs.read',
'fs.write',
'fs.edit',
'fs.list',
'fs.delete',
'fs.glob',
'fs.grep',
],
},
},
@@ -318,6 +336,29 @@ export async function POST(request: Request) {
case 'gitea.branch.create':
return await toolGiteaBranchCreate(principal, params);
case 'devcontainer.ensure':
return await toolDevContainerEnsure(principal, params);
case 'devcontainer.status':
return await toolDevContainerStatus(principal, params);
case 'devcontainer.suspend':
return await toolDevContainerSuspend(principal, params);
case 'shell.exec':
return await toolShellExec(principal, params);
case 'fs.read':
return await toolFsRead(principal, params);
case 'fs.write':
return await toolFsWrite(principal, params);
case 'fs.edit':
return await toolFsEdit(principal, params);
case 'fs.list':
return await toolFsList(principal, params);
case 'fs.delete':
return await toolFsDelete(principal, params);
case 'fs.glob':
return await toolFsGlob(principal, params);
case 'fs.grep':
return await toolFsGrep(principal, params);
default:
return NextResponse.json(
{ error: `Unknown tool "${action}"` },
@@ -2767,3 +2808,450 @@ async function toolGiteaBranchCreate(principal: Principal, params: Record<string
const b = await giteaCreateBranch(owner, repo, name, fromBranch);
return NextResponse.json({ result: { name: b.name, sha: b.commit?.id } });
}
// ── Path B: dev container + shell + filesystem tools ─────────────────
//
// These tools live "inside" the per-project vibn-dev container. The
// AI uses them to author code with sub-second feedback instead of the
// ~5 min Gitea-commit → Coolify-redeploy loop.
//
// Tenant safety strategy:
// 1. loadProjectForPrincipal() verifies the projectId is in the
// caller's workspace (same SELECT pattern as toolProjectsGet).
// 2. ensureDevContainer() / execInDevContainer() take projectId, NOT
// a raw container UUID. The container UUID is fetched from
// fs_project_dev_containers, which is keyed by projectId. A user
// can never address a foreign container directly.
// 3. The vibn-dev image runs as uid 1000 (`vibn`) by default. Coolify
// network policy isolates dev containers from internal Vibn
// services (vibn-postgres, vibn-frontend) — see /vibn-dev/README.
interface ProjectForPath {
id: string;
data: any;
slug: string;
name: string;
}
async function loadProjectForPrincipal(
principal: Principal,
projectId: string,
): Promise<ProjectForPath | null> {
const rows = await query<{ id: string; data: any; slug: string }>(
`SELECT id, data, slug
FROM fs_projects
WHERE id = $1
AND (vibn_workspace_id = $2 OR workspace = $3)
LIMIT 1`,
[projectId, principal.workspace.id, principal.workspace.slug],
);
if (rows.length === 0) return null;
const r = rows[0];
const d = r.data || {};
return {
id: r.id,
data: d,
slug: r.slug,
name: d.productName || d.name || d.title || r.slug,
};
}
async function pathBGuard(): Promise<NextResponse | null> {
if (await isPathBDisabled()) {
return NextResponse.json(
{
error:
'Path B (AI dev containers) is currently disabled by an admin. Use the Gitea-based tools instead, or contact support.',
},
{ status: 503 },
);
}
return null;
}
function requireProjectId(params: Record<string, any>): string | NextResponse {
const id = String(params.projectId ?? params.project_id ?? '').trim();
if (!id) {
return NextResponse.json({ error: 'Param "projectId" is required' }, { status: 400 });
}
return id;
}
async function resolveProjectOr404(
principal: Principal,
params: Record<string, any>,
): Promise<ProjectForPath | NextResponse> {
const idOrErr = requireProjectId(params);
if (idOrErr instanceof NextResponse) return idOrErr;
const project = await loadProjectForPrincipal(principal, idOrErr);
if (!project) {
return NextResponse.json(
{ error: `Project ${idOrErr} not found in this workspace` },
{ status: 404 },
);
}
return project;
}
// ── devcontainer.* ───────────────────────────────────────────────────
async function toolDevContainerEnsure(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
try {
const r = await ensureDevContainer({
projectId: project.id,
projectSlug: project.slug,
projectName: project.name,
workspace: principal.workspace,
noStart: Boolean(params.noStart),
});
return NextResponse.json({ result: r });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 },
);
}
}
async function toolDevContainerStatus(principal: Principal, params: Record<string, any>) {
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const status = await getDevContainerStatus(project.id);
return NextResponse.json({ result: status });
}
async function toolDevContainerSuspend(principal: Principal, params: Record<string, any>) {
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
await suspendDevContainer(project.id);
return NextResponse.json({ result: { ok: true, projectId: project.id, state: 'suspended' } });
}
// ── shell.exec ───────────────────────────────────────────────────────
//
// Universal escape hatch. Runs an arbitrary shell command inside
// /workspace as the `vibn` user (uid 1000). Output is capped at 1 MB
// and the call times out at 60s by default (max 10 min).
async function toolShellExec(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const command = typeof params.command === 'string' ? params.command : '';
if (!command.trim()) {
return NextResponse.json({ error: 'Param "command" is required' }, { status: 400 });
}
// Lazy-provision: if there's no dev container yet, create one before
// running the command. The first call is ~10-15s; subsequent calls
// skip this branch entirely.
await ensureDevContainer({
projectId: project.id,
projectSlug: project.slug,
projectName: project.name,
workspace: principal.workspace,
});
try {
const result = await execInDevContainer({
projectId: project.id,
command,
cwd: typeof params.cwd === 'string' ? params.cwd : undefined,
timeoutMs: Number.isFinite(Number(params.timeoutMs))
? Number(params.timeoutMs)
: Number.isFinite(Number(params.timeout_ms))
? Number(params.timeout_ms)
: undefined,
maxBytes: Number.isFinite(Number(params.maxBytes)) ? Number(params.maxBytes) : undefined,
env: params.env && typeof params.env === 'object' ? params.env : undefined,
user: typeof params.user === 'string' ? params.user : undefined,
});
return NextResponse.json({
result: {
code: result.code,
stdout: result.stdout,
stderr: result.stderr,
truncated: result.truncated,
durationMs: result.durationMs,
},
});
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 400 },
);
}
}
// ── fs.* ─────────────────────────────────────────────────────────────
//
// Implemented on top of shell.exec for now. Each fs.* call shells out
// to a coreutil (`cat`, `tee`, `rm`, etc) inside the dev container.
// This keeps the surface area tiny and ensures the AI's view of the
// filesystem matches what its `shell.exec` calls see.
//
// Path validation: we lock fs.* to /workspace by default. Absolute
// paths outside /workspace are rejected (prevents the AI from
// stomping on /etc, /home/vibn/.bashrc, etc by accident — though the
// `vibn` user has sudo, so a determined `shell.exec` can still go
// anywhere; fs.* just removes the obvious footguns).
const FS_ROOT = '/workspace';
function shq(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
function normalizeFsPath(p: string): string | NextResponse {
if (!p || typeof p !== 'string') {
return NextResponse.json({ error: 'Param "path" is required' }, { status: 400 });
}
let abs: string;
if (p.startsWith('/')) {
abs = p;
} else {
abs = `${FS_ROOT}/${p}`.replace(/\/+/g, '/');
}
// Disallow .. traversal that escapes /workspace.
const norm = abs.replace(/\/[^/]+\/\.\.(?=\/|$)/g, '').replace(/\/+/g, '/');
if (!norm.startsWith(FS_ROOT) && norm !== FS_ROOT) {
return NextResponse.json(
{ error: `Path "${p}" is outside ${FS_ROOT}; use shell.exec for system paths.` },
{ status: 400 },
);
}
return norm;
}
async function runFsCmd(
principal: Principal,
project: ProjectForPath,
command: string,
timeoutMs?: number,
): Promise<{ code: number | null; stdout: string; stderr: string; truncated: boolean }> {
await ensureDevContainer({
projectId: project.id,
projectSlug: project.slug,
projectName: project.name,
workspace: principal.workspace,
});
const r = await execInDevContainer({
projectId: project.id,
command,
timeoutMs,
});
return { code: r.code, stdout: r.stdout, stderr: r.stderr, truncated: r.truncated };
}
async function toolFsRead(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const path = normalizeFsPath(String(params.path ?? ''));
if (path instanceof NextResponse) return path;
const offset = Number.isFinite(Number(params.offset)) ? Math.max(0, Number(params.offset)) : 0;
const limit = Number.isFinite(Number(params.limit)) ? Math.max(1, Number(params.limit)) : 0;
// `test -f`, then read with optional sed window.
let cmd: string;
if (limit > 0) {
const start = offset + 1;
const end = offset + limit;
cmd = `test -f ${shq(path)} && sed -n ${shq(`${start},${end}p`)} ${shq(path)}`;
} else {
cmd = `test -f ${shq(path)} && cat ${shq(path)}`;
}
const r = await runFsCmd(principal, project, cmd);
if (r.code !== 0) {
return NextResponse.json(
{ error: `fs.read failed for ${path}: ${r.stderr.trim() || 'not a file or missing'}` },
{ status: 404 },
);
}
return NextResponse.json({
result: { path, content: r.stdout, truncated: r.truncated, offset, limit: limit || null },
});
}
async function toolFsWrite(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const path = normalizeFsPath(String(params.path ?? ''));
if (path instanceof NextResponse) return path;
const content = typeof params.content === 'string' ? params.content : '';
// Stream content via base64 to avoid shell-quoting headaches with
// arbitrary binary / multibyte input.
const b64 = Buffer.from(content, 'utf8').toString('base64');
const cmd =
`mkdir -p ${shq(path.replace(/\/[^/]+$/, '') || FS_ROOT)} && ` +
`printf %s ${shq(b64)} | base64 -d > ${shq(path)}`;
const r = await runFsCmd(principal, project, cmd);
if (r.code !== 0) {
return NextResponse.json(
{ error: `fs.write failed: ${r.stderr.trim() || 'unknown error'}` },
{ status: 500 },
);
}
return NextResponse.json({
result: { path, bytesWritten: Buffer.byteLength(content, 'utf8') },
});
}
async function toolFsEdit(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const path = normalizeFsPath(String(params.path ?? ''));
if (path instanceof NextResponse) return path;
const oldString = typeof params.oldString === 'string' ? params.oldString : '';
const newString = typeof params.newString === 'string' ? params.newString : '';
const replaceAll = Boolean(params.replaceAll);
if (!oldString) {
return NextResponse.json({ error: 'Param "oldString" is required' }, { status: 400 });
}
// Read → in-memory replace → write back. Done in one shell pipeline
// via a small embedded Python (always present on the base image)
// because doing this with sed is a quoting nightmare. The script
// bails non-zero if oldString is missing or non-unique (Aider-style).
const payload = {
path,
oldString,
newString,
replaceAll,
};
const py = `import json,sys
spec=json.loads(sys.stdin.read())
with open(spec['path'],'r',encoding='utf-8') as f: src=f.read()
old=spec['oldString']; new=spec['newString']; ra=spec['replaceAll']
n=src.count(old)
if n==0:
sys.stderr.write('oldString not found'); sys.exit(2)
if n>1 and not ra:
sys.stderr.write(f'oldString found {n}x; pass replaceAll=true or include more context'); sys.exit(3)
out=src.replace(old,new) if ra else src.replace(old,new,1)
with open(spec['path'],'w',encoding='utf-8') as f: f.write(out)
print(n)`;
const b64 = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64');
const pyB64 = Buffer.from(py, 'utf8').toString('base64');
const cmd =
`python3 -c "$(printf %s ${shq(pyB64)} | base64 -d)" <<< "$(printf %s ${shq(b64)} | base64 -d)"`;
const r = await runFsCmd(principal, project, cmd);
if (r.code !== 0) {
const status = r.code === 2 ? 404 : r.code === 3 ? 409 : 500;
return NextResponse.json(
{
error: `fs.edit failed: ${r.stderr.trim() || 'unknown error'}`,
code: r.code,
},
{ status },
);
}
return NextResponse.json({
result: { path, replacements: parseInt(r.stdout.trim() || '0', 10) },
});
}
async function toolFsList(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const path = normalizeFsPath(String(params.path ?? '/workspace'));
if (path instanceof NextResponse) return path;
const cmd = `cd ${shq(path)} && ls -lA --time-style=long-iso 2>&1 | head -200`;
const r = await runFsCmd(principal, project, cmd);
return NextResponse.json({ result: { path, listing: r.stdout, code: r.code } });
}
async function toolFsDelete(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const path = normalizeFsPath(String(params.path ?? ''));
if (path instanceof NextResponse) return path;
const recursive = Boolean(params.recursive);
// Belt-and-suspenders: never let `rm -rf /workspace` itself slip through.
if (path === FS_ROOT) {
return NextResponse.json(
{ error: 'Refusing to delete /workspace itself.' },
{ status: 400 },
);
}
const cmd = `rm ${recursive ? '-rf' : '-f'} ${shq(path)}`;
const r = await runFsCmd(principal, project, cmd);
if (r.code !== 0) {
return NextResponse.json(
{ error: `fs.delete failed: ${r.stderr.trim()}` },
{ status: 500 },
);
}
return NextResponse.json({ result: { ok: true, path } });
}
async function toolFsGlob(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const pattern = String(params.pattern ?? '').trim();
if (!pattern) {
return NextResponse.json({ error: 'Param "pattern" is required' }, { status: 400 });
}
const cwd = normalizeFsPath(String(params.cwd ?? '/workspace'));
if (cwd instanceof NextResponse) return cwd;
// ripgrep --files --glob is faster + smarter than `find` and respects .gitignore.
const cmd = `cd ${shq(cwd)} && rg --files --glob ${shq(pattern)} | head -500`;
const r = await runFsCmd(principal, project, cmd);
const files = r.stdout.split('\n').map(s => s.trim()).filter(Boolean);
return NextResponse.json({ result: { pattern, cwd, files, truncated: files.length === 500 } });
}
async function toolFsGrep(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const pattern = String(params.pattern ?? '');
if (!pattern) {
return NextResponse.json({ error: 'Param "pattern" is required' }, { status: 400 });
}
const cwd = normalizeFsPath(String(params.cwd ?? '/workspace'));
if (cwd instanceof NextResponse) return cwd;
const glob = typeof params.glob === 'string' && params.glob.trim() ? params.glob.trim() : null;
const ctx = Number.isFinite(Number(params.contextLines))
? Math.min(10, Math.max(0, Number(params.contextLines)))
: 0;
const flags = [
'--no-heading',
'--line-number',
'--max-count', '50',
'--max-columns', '300',
ctx ? `--context ${ctx}` : '',
glob ? `--glob ${shq(glob)}` : '',
]
.filter(Boolean)
.join(' ');
const cmd = `cd ${shq(cwd)} && rg ${flags} ${shq(pattern)} | head -500`;
const r = await runFsCmd(principal, project, cmd);
return NextResponse.json({
result: { pattern, cwd, glob, matches: r.stdout, truncated: r.truncated },
});
}