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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user