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

109
lib/coolify-containers.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* Container enumeration for a Coolify-managed app.
*
* Coolify names compose containers as `{service}-{appUuid}-{shortHash}`
* and single-container apps as `{name}-{appUuid}` (or similar). To
* target a specific service we:
* 1. SSH to the Coolify host as the locked-down vibn-logs user
* 2. `docker ps --filter name={appUuid}` to list matching containers
* 3. Derive the service name by stripping the uuid suffix
*
* Shared between lib/coolify-logs.ts (read path) and
* lib/coolify-exec.ts (write path). Both pass through the same
* ssh2-based helper in lib/coolify-ssh.ts.
*/
import { runOnCoolifyHost } from '@/lib/coolify-ssh';
export interface ComposeContainer {
/** Full docker container name, e.g. `server-flz0i740ql0a-20113077`. */
name: string;
/** Derived compose service, e.g. `server`. */
service: string;
/** `Up 5 minutes (healthy)`, `Exited (1) 2 minutes ago`, etc. */
status: string;
/** `healthy` / `unhealthy` / `starting` / `none`. Parsed from status. */
health: 'healthy' | 'unhealthy' | 'starting' | 'none';
}
/**
* Shell-escape a single token for bash. We only ever pass Coolify-
* derived uuids through here, but defence-in-depth is cheap.
*/
function sq(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
function deriveService(name: string, appUuid: string): string {
const idx = name.indexOf(`-${appUuid}`);
return idx > 0 ? name.slice(0, idx) : name;
}
function parseHealth(status: string): ComposeContainer['health'] {
if (/\(healthy\)/i.test(status)) return 'healthy';
if (/\(unhealthy\)/i.test(status)) return 'unhealthy';
if (/\(health: starting\)/i.test(status) || /starting/i.test(status)) return 'starting';
return 'none';
}
/**
* List all docker containers belonging to a Coolify app (compose or
* single-container). Returns an empty array when the deployment hasn't
* started yet. Throws only on transport errors.
*/
export async function listContainersForApp(appUuid: string): Promise<ComposeContainer[]> {
const res = await runOnCoolifyHost(
`docker ps -a --filter ${sq('name=' + appUuid)} --format '{{.Names}}\t{{.Status}}'`,
{ timeoutMs: 8_000 },
);
if (res.code !== 0) {
throw new Error(`docker ps exited ${res.code}: ${res.stderr.trim()}`);
}
return res.stdout
.split('\n')
.map(l => l.trim())
.filter(Boolean)
.map(l => {
const [name, ...rest] = l.split('\t');
const status = rest.join('\t');
return {
name,
service: deriveService(name, appUuid),
status,
health: parseHealth(status),
};
});
}
/**
* Resolve a single target container for an action that only makes
* sense on one service (e.g. `apps.exec`). Behaviour:
* - No containers → throws "no containers"
* - One container total → returns it (service param optional)
* - N containers and `service` given → match by derived service
* - N containers, no `service` → throws "must specify service"
*/
export async function resolveAppTargetContainer(
appUuid: string,
service: string | undefined,
): Promise<ComposeContainer> {
const containers = await listContainersForApp(appUuid);
if (containers.length === 0) {
throw new Error(`No containers found for app ${appUuid}. Deploy may not have started.`);
}
if (containers.length === 1 && !service) {
return containers[0];
}
if (!service) {
throw new Error(
`App has ${containers.length} containers; specify service. Available: ${containers.map(c => c.service).join(', ')}`,
);
}
const match = containers.find(c => c.service === service);
if (!match) {
throw new Error(
`Service "${service}" not found. Available: ${containers.map(c => c.service).join(', ')}`,
);
}
return match;
}