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;
}

118
lib/coolify-exec.ts Normal file
View File

@@ -0,0 +1,118 @@
/**
* Run a one-shot command inside a Coolify-managed app container.
*
* Same SSH backbone as lib/coolify-logs.ts: we connect to the Coolify
* host as the locked-down `vibn-logs` user, resolve the target
* container via listContainersForApp, and execute `docker exec` in
* non-interactive mode. No TTY, no stdin — this is purpose-built for
* "run a command, return the output" operator actions (running
* migrations, sanity checks, one-off CLI invocations).
*
* Tenant safety: every caller must verify the app uuid belongs to the
* calling workspace BEFORE invoking this helper (via
* getApplicationInProject). This file trusts that check has already
* happened.
*/
import { runOnCoolifyHost } from '@/lib/coolify-ssh';
import { resolveAppTargetContainer, type ComposeContainer } from '@/lib/coolify-containers';
const DEFAULT_TIMEOUT_MS = 60_000;
const MAX_TIMEOUT_MS = 600_000; // 10 min — enough for migrations / seeds
const DEFAULT_MAX_BYTES = 1_000_000; // 1 MB combined stdout+stderr
const MAX_BYTES_CAP = 5_000_000; // 5 MB hard ceiling
export interface ExecInAppOptions {
appUuid: string;
/** Compose service name (`server`, `db`, `worker`, …). Required when the app has >1 container. */
service?: string;
/** The command to run. Passed through `sh -lc`, so shell syntax (pipes, redirects, `&&`) works. */
command: string;
/** Kill the channel after this many ms. Defaults to 60s, max 10 min. */
timeoutMs?: number;
/** Cap combined stdout+stderr. Defaults to 1 MB, max 5 MB. */
maxBytes?: number;
/** Optional `--user` passed to `docker exec`, e.g. `root` or `1000:1000`. */
user?: string;
/** Optional `--workdir` passed to `docker exec`, e.g. `/app`. */
workdir?: string;
}
export interface ExecInAppResult {
container: string;
service: string;
code: number | null;
stdout: string;
stderr: string;
truncated: boolean;
durationMs: number;
/** Container health at time of exec (parsed from `docker ps`). */
containerHealth: ComposeContainer['health'];
/** The command as it was actually executed (post-escape, for logs). */
executedCommand: string;
}
/** Shell-escape a single token for bash. */
function sq(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
export async function execInCoolifyApp(opts: ExecInAppOptions): Promise<ExecInAppResult> {
if (!opts.command || typeof opts.command !== 'string') {
throw new Error('command is required');
}
const timeoutMs = Math.min(
Math.max(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, 1_000),
MAX_TIMEOUT_MS,
);
const maxBytes = Math.min(
Math.max(opts.maxBytes ?? DEFAULT_MAX_BYTES, 1_024),
MAX_BYTES_CAP,
);
const container = await resolveAppTargetContainer(opts.appUuid, opts.service);
// Build the `docker exec` invocation. We run the payload through
// `sh -lc` so callers can use shell syntax naturally. The payload
// itself is passed as a single-quoted argv token — no interpolation
// happens outside of what the child shell does.
const flags: string[] = [];
if (opts.user) flags.push(`--user ${sq(opts.user)}`);
if (opts.workdir) flags.push(`--workdir ${sq(opts.workdir)}`);
const executedCommand = `docker exec ${flags.join(' ')} ${sq(container.name)} sh -lc ${sq(opts.command)}`.replace(/\s+/g, ' ').trim();
const startedAt = Date.now();
// Audit log: record the command + target, NOT the output (output
// may contain secrets). Structured so downstream log shipping can
// parse it.
console.log(
'[apps.exec]',
JSON.stringify({
app_uuid: opts.appUuid,
container: container.name,
service: container.service,
timeout_ms: timeoutMs,
command: opts.command,
}),
);
const res = await runOnCoolifyHost(executedCommand, { timeoutMs, maxBytes });
const durationMs = Date.now() - startedAt;
// Our ssh helper merges stream metadata; we didn't redirect stderr
// to stdout in the command, so the ssh-layer separation is already
// correct. But docker-exec uses exit code 126/127 for "cannot exec"
// and >0 for the user's command failing; surface all of them.
return {
container: container.name,
service: container.service,
code: res.code,
stdout: res.stdout,
stderr: res.stderr,
truncated: res.truncated,
durationMs,
containerHealth: container.health,
executedCommand,
};
}

View File

@@ -19,6 +19,7 @@
import { getApplication, getApplicationRuntimeLogsFromApi, type CoolifyApplication } from '@/lib/coolify';
import { isCoolifySshConfigured, runOnCoolifyHost } from '@/lib/coolify-ssh';
import { listContainersForApp } from '@/lib/coolify-containers';
export type LogsSource = 'coolify_api' | 'ssh_docker' | 'empty';
@@ -133,65 +134,45 @@ async function fetchComposeLogsViaSsh(
const uuid = app.uuid as string;
const buildPack = (app.build_pack ?? 'dockercompose') as string;
// 1) Enumerate containers that belong to this app. Coolify's naming
// convention always includes the app uuid in the container name
// (either as suffix for compose or as the name itself for single-
// container apps).
const ps = await runOnCoolifyHost(
`docker ps -a --filter ${sq('name=' + uuid)} --format '{{.Names}}\t{{.Status}}'`,
{ timeoutMs: 8_000 },
);
if (ps.code !== 0) {
warnings.push(`docker ps exited ${ps.code}: ${ps.stderr.trim()}`);
return { uuid, buildPack, source: 'empty', services: {}, warnings, truncated: ps.truncated };
// 1) Enumerate containers that belong to this app via the shared
// helper. Coolify's naming convention always includes the app
// uuid (suffix for compose, embedded for single-container).
let containers: Array<{ name: string; status: string; service: string }>;
try {
const raw = await listContainersForApp(uuid);
containers = raw.map(c => ({ name: c.name, status: c.status, service: c.service }));
} catch (err) {
warnings.push(err instanceof Error ? err.message : String(err));
return { uuid, buildPack, source: 'empty', services: {}, warnings, truncated: false };
}
const containers = ps.stdout
.split('\n')
.map(l => l.trim())
.filter(Boolean)
.map(l => {
const [name, ...rest] = l.split('\t');
return { name, status: rest.join('\t') };
});
if (containers.length === 0) {
warnings.push('No containers found on Coolify host for this app uuid. Deployment may not have started yet.');
return { uuid, buildPack, source: 'ssh_docker', services: {}, warnings, truncated: false };
}
// 2) Derive a service name from each container.
//
// Coolify names compose containers like `{service}-{appUuid}-{short}`
// For single-container apps it's just `{appSlug}-{appUuid}` or similar.
// We strip the uuid to recover the service name.
// 2) Tail logs per container. listContainersForApp already derives
// the service name from each container; we just need to filter
// by the caller's requested service (if any).
const services: Record<string, ServiceLogs> = {};
let anyTruncated = false;
const targets = containers.filter(c => {
if (!filterService) return true;
return c.name.startsWith(filterService + '-') || c.name === filterService;
});
const targets = containers.filter(c => !filterService || c.service === filterService);
if (filterService && targets.length === 0) {
warnings.push(`No container matched service=${filterService}. Available: ${containers.map(c => c.name).join(', ')}`);
warnings.push(
`No container matched service=${filterService}. Available: ${containers.map(c => c.service).join(', ')}`,
);
}
for (const c of targets) {
// service name = name with -<uuid>-<hash> suffix stripped
let service = c.name;
const idx = c.name.indexOf(`-${uuid}`);
if (idx > 0) service = c.name.slice(0, idx);
// Guard against empty or duplicate service keys
if (!service) service = c.name;
if (services[service]) service = c.name;
const key = services[c.service] ? c.name : c.service;
const logsRes = await runOnCoolifyHost(
`docker logs --tail ${Math.floor(lines)} --timestamps ${sq(c.name)} 2>&1`,
{ timeoutMs: 10_000, maxBytes: 1_000_000 },
);
if (logsRes.truncated) anyTruncated = true;
const text = logsRes.stdout; // stderr is merged via `2>&1`
services[service] = {
const text = logsRes.stdout; // stderr merged via `2>&1`
services[key] = {
container: c.name,
lines: text ? text.split('\n').length : 0,
bytes: Buffer.byteLength(text, 'utf8'),