feat(mcp): apps.exec — run one-shot commands in app containers
Companion to apps.logs. SSH to the Coolify host as vibn-logs, resolve the target container by app uuid + service, and run the caller's command through `docker exec ... sh -lc`. No TTY, no stdin — this is the write-path sibling of apps.logs, purpose-built for migrations, seeds, CLI invocations, and ad-hoc debugging. - lib/coolify-containers.ts extracts container enumeration + service resolution into a shared helper used by both logs and exec. - lib/coolify-exec.ts wraps docker exec with timeout (60s default, 10-min cap), output byte cap (1 MB default, 5 MB cap), optional --user / --workdir, and structured audit logging of the command + target (never the output). - app/api/mcp/route.ts wires `apps.exec` into the dispatcher and advertises it in the capabilities manifest. - app/api/workspaces/[slug]/apps/[uuid]/exec/route.ts exposes the same tool over REST for session-cookie callers. Tenant safety: every entrypoint runs getApplicationInProject before touching SSH, so an agent can only exec in apps belonging to their workspace. Made-with: Cursor
This commit is contained in:
85
app/api/workspaces/[slug]/apps/[uuid]/exec/route.ts
Normal file
85
app/api/workspaces/[slug]/apps/[uuid]/exec/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* POST /api/workspaces/[slug]/apps/[uuid]/exec
|
||||
*
|
||||
* Run a one-shot command inside an app container via `docker exec`
|
||||
* on the Coolify host (over SSH). The companion of `/logs` for the
|
||||
* write path.
|
||||
*
|
||||
* Body (JSON):
|
||||
* command string required. Passed through `sh -lc`.
|
||||
* service string? compose service to target (required when
|
||||
* the app has >1 container).
|
||||
* user string? docker exec --user
|
||||
* workdir string? docker exec --workdir
|
||||
* timeout_ms number? default 60_000, max 600_000
|
||||
* max_bytes number? default 1_000_000, max 5_000_000
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
|
||||
import { getApplicationInProject, TenantError } from '@/lib/coolify';
|
||||
import { execInCoolifyApp } from '@/lib/coolify-exec';
|
||||
import { isCoolifySshConfigured } from '@/lib/coolify-ssh';
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ slug: string; uuid: string }> }
|
||||
) {
|
||||
const { slug, uuid } = await params;
|
||||
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
|
||||
if (principal instanceof NextResponse) return principal;
|
||||
|
||||
const ws = principal.workspace;
|
||||
if (!ws.coolify_project_uuid) {
|
||||
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
|
||||
}
|
||||
if (!isCoolifySshConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'apps.exec requires SSH to the Coolify host, not configured on this deployment.' },
|
||||
{ status: 501 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Body must be JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const command = typeof body.command === 'string' ? body.command : '';
|
||||
if (!command) {
|
||||
return NextResponse.json({ error: 'command is required' }, { status: 400 });
|
||||
}
|
||||
const service = typeof body.service === 'string' && body.service.trim()
|
||||
? body.service.trim()
|
||||
: undefined;
|
||||
const user = typeof body.user === 'string' && body.user.trim() ? body.user.trim() : undefined;
|
||||
const workdir = typeof body.workdir === 'string' && body.workdir.trim()
|
||||
? body.workdir.trim()
|
||||
: undefined;
|
||||
const timeoutMs = Number.isFinite(Number(body.timeout_ms)) ? Number(body.timeout_ms) : undefined;
|
||||
const maxBytes = Number.isFinite(Number(body.max_bytes)) ? Number(body.max_bytes) : undefined;
|
||||
|
||||
try {
|
||||
await getApplicationInProject(uuid, ws.coolify_project_uuid);
|
||||
const result = await execInCoolifyApp({
|
||||
appUuid: uuid,
|
||||
command,
|
||||
service,
|
||||
user,
|
||||
workdir,
|
||||
timeoutMs,
|
||||
maxBytes,
|
||||
});
|
||||
return NextResponse.json(result);
|
||||
} catch (err) {
|
||||
if (err instanceof TenantError) {
|
||||
return NextResponse.json({ error: err.message }, { status: 403 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to exec in container', details: err instanceof Error ? err.message : String(err) },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user