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:
118
lib/coolify-exec.ts
Normal file
118
lib/coolify-exec.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user