feat(mcp): apps.logs — compose-aware runtime logs

Adds apps.logs MCP tool + session REST endpoint for tailing runtime
container logs. Unblocks cold-start debugging for agent-deployed
compose apps (Twenty, Cal.com, Plane, etc.) where Coolify's own
/applications/{uuid}/logs endpoint returns empty.

Architecture:
  - dockerfile / nixpacks / static apps → Coolify's REST logs API
  - dockercompose apps                  → SSH into Coolify host,
                                          `docker logs` per service

New SSH path uses a dedicated `vibn-logs` user (docker group, no
sudo, no pty, no port-forwarding, single ed25519 key). Private key
lives in COOLIFY_SSH_PRIVATE_KEY_B64 on the vibn-frontend Coolify
app; authorized_key is installed by scripts/setup-vibn-logs-user.sh
on the Coolify host.

Tool shape:
  params:   { uuid, service?, lines? (default 200, max 5000) }
  returns:  { uuid, buildPack, source: 'coolify_api'|'ssh_docker'|'empty',
              services: { [name]: { container, lines, bytes, logs, status? } },
              warnings: string[], truncated: boolean }

Made-with: Cursor
This commit is contained in:
2026-04-23 13:21:52 -07:00
parent 9959eaeeaa
commit d86f2bea03
7 changed files with 541 additions and 0 deletions

133
lib/coolify-ssh.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* One-shot SSH-exec helper into the Coolify host.
*
* Used to pull docker runtime data that the Coolify REST API doesn't
* surface cleanly (per-service logs for compose apps, container state,
* etc.). Strictly command-in / string-out — we never hold a persistent
* channel and we never expose an interactive shell to callers.
*
* Required env (injected into vibn-frontend at deploy time):
* COOLIFY_SSH_HOST e.g. 34.19.250.135 or coolify.vibnai.com
* COOLIFY_SSH_PORT default 22
* COOLIFY_SSH_USER default "vibn-logs"
* COOLIFY_SSH_PRIVATE_KEY_B64 base64-encoded PEM private key
*
* The authorised key lives in ~vibn-logs/.ssh/authorized_keys on the
* Coolify host. That user must be a member of the `docker` group so
* `docker logs`/`docker ps` work without sudo; it should NOT have
* sudo rights.
*/
import { Client, type ConnectConfig } from 'ssh2';
const DEFAULT_EXEC_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_BYTES = 1_000_000; // 1 MB more than enough for `docker logs --tail 2000`
export interface CoolifySshResult {
code: number | null;
stdout: string;
stderr: string;
truncated: boolean;
}
export interface CoolifySshOptions {
/** Millis to wait for the command to finish before killing the channel. */
timeoutMs?: number;
/** Cap combined stdout+stderr. Extra bytes are dropped and `truncated=true`. */
maxBytes?: number;
}
function loadConnectConfig(): ConnectConfig {
const host = process.env.COOLIFY_SSH_HOST;
const keyB64 = process.env.COOLIFY_SSH_PRIVATE_KEY_B64;
if (!host) throw new Error('COOLIFY_SSH_HOST is not set');
if (!keyB64) throw new Error('COOLIFY_SSH_PRIVATE_KEY_B64 is not set');
const privateKey = Buffer.from(keyB64, 'base64').toString('utf8');
const port = Number(process.env.COOLIFY_SSH_PORT ?? 22);
const username = process.env.COOLIFY_SSH_USER ?? 'vibn-logs';
return {
host,
port,
username,
privateKey,
readyTimeout: 8_000,
// We accept the host key on first connect; we're always connecting
// to infra we own, but a future hardening pass should pin the host
// key fingerprint via `hostVerifier`.
};
}
/**
* Run a single command on the Coolify host and collect its output.
*
* The helper enforces:
* - a wall-clock timeout (default 10s)
* - a combined-output byte cap (default 1 MB)
* - always-close semantics (connection is destroyed on return / throw)
*/
export function runOnCoolifyHost(
command: string,
opts: CoolifySshOptions = {},
): Promise<CoolifySshResult> {
const cfg = loadConnectConfig();
const timeoutMs = opts.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
return new Promise((resolve, reject) => {
const conn = new Client();
let settled = false;
let stdout = '';
let stderr = '';
let truncated = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try { conn.end(); } catch { /* noop */ }
fn();
};
const timer = setTimeout(() => {
settle(() => reject(new Error(`[coolify-ssh] timeout after ${timeoutMs}ms running: ${command}`)));
}, timeoutMs);
conn
.on('ready', () => {
conn.exec(command, (err, stream) => {
if (err) return settle(() => reject(err));
stream
.on('close', (code: number | null) => {
settle(() => resolve({ code, stdout, stderr, truncated }));
})
.on('data', (chunk: Buffer) => {
if (stdout.length + stderr.length + chunk.length > maxBytes) {
truncated = true;
const room = Math.max(0, maxBytes - stdout.length - stderr.length);
stdout += chunk.slice(0, room).toString('utf8');
} else {
stdout += chunk.toString('utf8');
}
})
.stderr.on('data', (chunk: Buffer) => {
if (stdout.length + stderr.length + chunk.length > maxBytes) {
truncated = true;
const room = Math.max(0, maxBytes - stdout.length - stderr.length);
stderr += chunk.slice(0, room).toString('utf8');
} else {
stderr += chunk.toString('utf8');
}
});
});
})
.on('error', (err) => settle(() => reject(err)))
.connect(cfg);
});
}
/**
* True if the env is configured to talk to the Coolify host.
* Callers use this to gracefully degrade when SSH isn't available
* (e.g. local dev without the key pair mounted).
*/
export function isCoolifySshConfigured(): boolean {
return !!(process.env.COOLIFY_SSH_HOST && process.env.COOLIFY_SSH_PRIVATE_KEY_B64);
}