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:
2026-04-23 14:18:49 -07:00
parent e766315ecd
commit 8c83f8c490
5 changed files with 410 additions and 40 deletions

View File

@@ -27,6 +27,8 @@ import {
} from '@/lib/workspace-gcs';
import { VIBN_GCS_LOCATION } from '@/lib/gcp/storage';
import { getApplicationRuntimeLogs } from '@/lib/coolify-logs';
import { execInCoolifyApp } from '@/lib/coolify-exec';
import { isCoolifySshConfigured } from '@/lib/coolify-ssh';
import {
deployApplication,
getApplicationInProject,
@@ -99,6 +101,7 @@ export async function GET() {
'apps.domains.list',
'apps.domains.set',
'apps.logs',
'apps.exec',
'apps.envs.list',
'apps.envs.upsert',
'apps.envs.delete',
@@ -193,6 +196,8 @@ export async function POST(request: Request) {
return await toolAppsDomainsSet(principal, params);
case 'apps.logs':
return await toolAppsLogs(principal, params);
case 'apps.exec':
return await toolAppsExec(principal, params);
case 'databases.list':
return await toolDatabasesList(principal);
@@ -432,6 +437,78 @@ async function toolAppsLogs(principal: Principal, params: Record<string, any>) {
return NextResponse.json({ result });
}
/**
* apps.exec — run a one-shot command inside an app container.
*
* Requires COOLIFY_SSH_* env vars (same as apps.logs). The caller
* provides `uuid`, an optional `service` (required for compose apps
* with >1 container), and a `command` string. Output is capped at
* 1MB by default and 10-minute wall-clock timeout.
*
* Note: the command is NOT parsed or validated. It's executed as a
* single shell invocation inside the container via `sh -lc`. This is
* deliberate — this tool is the platform's trust-the-agent escape
* hatch for migrations, CLI invocations, and ad-hoc debugging. It's
* authenticated per-workspace (tenant check above) and rate-limited
* by the SSH session's timeout/byte caps.
*/
async function toolAppsExec(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;
if (!isCoolifySshConfigured()) {
return NextResponse.json(
{
error:
'apps.exec requires SSH access to the Coolify host, which is not configured on this deployment.',
},
{ status: 501 },
);
}
const appUuid = String(params.uuid ?? params.appUuid ?? '').trim();
const command = typeof params.command === 'string' ? params.command : '';
if (!appUuid || !command) {
return NextResponse.json(
{ error: 'Params "uuid" and "command" are required' },
{ status: 400 },
);
}
await getApplicationInProject(appUuid, projectUuid);
const service = typeof params.service === 'string' && params.service.trim()
? params.service.trim()
: undefined;
const user = typeof params.user === 'string' && params.user.trim()
? params.user.trim()
: undefined;
const workdir = typeof params.workdir === 'string' && params.workdir.trim()
? params.workdir.trim()
: undefined;
const timeoutMs = Number.isFinite(Number(params.timeout_ms))
? Number(params.timeout_ms)
: undefined;
const maxBytes = Number.isFinite(Number(params.max_bytes))
? Number(params.max_bytes)
: undefined;
try {
const result = await execInCoolifyApp({
appUuid,
service,
command,
user,
workdir,
timeoutMs,
maxBytes,
});
return NextResponse.json({ result });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 400 });
}
}
async function toolAppsEnvsList(principal: Principal, params: Record<string, any>) {
const projectUuid = requireCoolifyProject(principal);
if (projectUuid instanceof NextResponse) return projectUuid;

View 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 },
);
}
}