@justmpm/mmx-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/utils.js ADDED
@@ -0,0 +1,743 @@
1
+ /**
2
+ * @justmpm/mmx-cli - Utilitários compartilhados
3
+ *
4
+ * Helpers para executar comandos do CLI `mmx` via child_process:
5
+ * - resolveMmxInvocation(): estratégia em cascata (mmx global → npx)
6
+ * - runMmx(): spawn seguro com timeout
7
+ * - isAuthError(): detecção de erros de autenticação
8
+ * - isQuotaError(): detecção de erro de quota
9
+ * - checkMmxVersion(): health check no boot
10
+ * - Mensagens de erro padronizadas
11
+ * - runMmxVideoWithPolling(): polling async para vídeo com timeout
12
+ * - Validação de paths (security: path traversal, realpath, size)
13
+ * - Sanitização de erros (redact API keys)
14
+ * - Concurrency limiter
15
+ */
16
+ import { spawn, spawnSync } from "node:child_process";
17
+ import { setTimeout as sleep } from "node:timers/promises";
18
+ import { existsSync, mkdirSync, realpathSync, statSync } from "node:fs";
19
+ import { join, resolve as resolvePath } from "node:path";
20
+ import { MINIMUM_MMX_VERSION } from "./version.js";
21
+ // =============================================================================
22
+ // Resolução do binário (cascata)
23
+ // =============================================================================
24
+ let cachedInvocation = null;
25
+ /**
26
+ * Procura um comando no PATH. Cross-platform sem dependência externa.
27
+ * - Windows: usa `where <cmd>` (via shell para resolver .cmd/.bat).
28
+ * - Unix: usa `command -v <cmd>` (POSIX).
29
+ *
30
+ * @returns Path absoluto do executável, ou `null` se não encontrado.
31
+ */
32
+ export function findInPath(command) {
33
+ const isWindows = process.platform === "win32";
34
+ if (isWindows) {
35
+ const result = spawnSync("where", [command], {
36
+ shell: true,
37
+ stdio: ["ignore", "pipe", "pipe"],
38
+ });
39
+ if (result.status !== 0)
40
+ return null;
41
+ // `where` pode retornar múltiplas linhas; pegar a primeira absoluta.
42
+ const lines = result.stdout.toString().trim().split(/\r?\n/);
43
+ const first = lines[0]?.trim();
44
+ return first || null;
45
+ }
46
+ const result = spawnSync("command", ["-v", command], {
47
+ shell: true,
48
+ stdio: ["ignore", "pipe", "pipe"],
49
+ });
50
+ if (result.status !== 0)
51
+ return null;
52
+ const out = result.stdout.toString().trim();
53
+ return out || null;
54
+ }
55
+ /**
56
+ * Determina como invocar o `mmx`. Estratégia em cascata:
57
+ * 1. `mmx` no PATH global (caso o cliente MCP tenha npm globals no PATH)
58
+ * 2. `npx -y mmx-cli` (sempre funciona em qualquer máquina com Node ≥ 18)
59
+ *
60
+ * O resultado é cacheado em memória para evitar resolução repetida.
61
+ */
62
+ export function resolveMmxInvocation() {
63
+ if (cachedInvocation)
64
+ return cachedInvocation;
65
+ const mmxPath = findInPath("mmx");
66
+ if (mmxPath) {
67
+ cachedInvocation = { command: "mmx", prefixArgs: [] };
68
+ }
69
+ else {
70
+ cachedInvocation = { command: "npx", prefixArgs: ["-y", "mmx-cli", "mmx"] };
71
+ }
72
+ return cachedInvocation;
73
+ }
74
+ /**
75
+ * Reseta o cache de invocação. Útil para testes.
76
+ */
77
+ export function _resetMmxInvocationCache() {
78
+ cachedInvocation = null;
79
+ }
80
+ // =============================================================================
81
+ // Execução do CLI
82
+ // =============================================================================
83
+ /**
84
+ * Executa um comando do CLI `mmx` e retorna stdout, stderr e exitCode.
85
+ *
86
+ * Usa child_process.spawn (NÃO exec) por causa do limite de 1MB do exec.
87
+ * Suporta AbortSignal para cancelamento cooperativo.
88
+ * Limitado a 3 execuções simultâneas via `mmxConcurrencyLimiter`.
89
+ *
90
+ * @param args - Argumentos para o mmx (ex: ["image", "generate", "--prompt", "..."])
91
+ * @param options - cwd, env, timeoutMs (default 300_000 = 5min), signal (AbortSignal)
92
+ */
93
+ export function runMmx(args, options = {}) {
94
+ return withConcurrency(() => runMmxInner(args, options));
95
+ }
96
+ /**
97
+ * Implementação interna de `runMmx` (sem concurrency limit).
98
+ * Usada por `runMmx` (com limit) e por testes.
99
+ */
100
+ export function runMmxInner(args, options = {}) {
101
+ const invocation = resolveMmxInvocation();
102
+ const fullArgs = [...invocation.prefixArgs, ...args];
103
+ const timeoutMs = options.timeoutMs ?? 300_000;
104
+ const signal = options.signal;
105
+ return new Promise((resolve) => {
106
+ // Se signal já abortado, retornar imediatamente
107
+ if (signal?.aborted) {
108
+ resolve({ stdout: "", stderr: "[mmx-cli MCP] Operação cancelada antes de iniciar.", exitCode: 1 });
109
+ return;
110
+ }
111
+ const proc = spawn(invocation.command, fullArgs, {
112
+ stdio: ["ignore", "pipe", "pipe"],
113
+ shell: false,
114
+ cwd: options.cwd,
115
+ env: { ...process.env, ...options.env },
116
+ windowsHide: true,
117
+ });
118
+ let stdout = "";
119
+ let stderr = "";
120
+ let resolved = false;
121
+ const finish = (payload) => {
122
+ if (resolved)
123
+ return;
124
+ resolved = true;
125
+ resolve(payload);
126
+ };
127
+ proc.stdout.on("data", (chunk) => {
128
+ stdout += chunk.toString();
129
+ });
130
+ proc.stderr.on("data", (chunk) => {
131
+ stderr += chunk.toString();
132
+ });
133
+ proc.on("close", (code) => {
134
+ finish({ stdout, stderr, exitCode: code ?? 1 });
135
+ });
136
+ proc.on("error", (err) => {
137
+ finish({ stdout: "", stderr: err.message, exitCode: 1 });
138
+ });
139
+ // Timeout
140
+ if (timeoutMs > 0) {
141
+ const timer = setTimeout(() => {
142
+ proc.kill("SIGTERM");
143
+ // Se ainda não morreu em 5s, manda SIGKILL
144
+ setTimeout(() => proc.kill("SIGKILL"), 5_000).unref();
145
+ finish({
146
+ stdout,
147
+ stderr: `${stderr}\n[mmx-cli MCP] Timeout após ${Math.round(timeoutMs / 1000)}s. Processo encerrado.`,
148
+ exitCode: 124, // conventional timeout exit code
149
+ });
150
+ }, timeoutMs);
151
+ proc.on("close", () => clearTimeout(timer));
152
+ }
153
+ // AbortSignal — cancelamento cooperativo
154
+ if (signal) {
155
+ const onAbort = () => {
156
+ proc.kill("SIGTERM");
157
+ setTimeout(() => proc.kill("SIGKILL"), 5_000).unref();
158
+ finish({
159
+ stdout,
160
+ stderr: `${stderr}\n[mmx-cli MCP] Operação cancelada pelo cliente.`,
161
+ exitCode: 1,
162
+ });
163
+ };
164
+ signal.addEventListener("abort", onAbort, { once: true });
165
+ proc.on("close", () => signal.removeEventListener("abort", onAbort));
166
+ }
167
+ });
168
+ }
169
+ // =============================================================================
170
+ // Polling de vídeo (async task com timeout)
171
+ // =============================================================================
172
+ const DEFAULT_MAX_WAIT_MS = 300_000; // 5min
173
+ const DEFAULT_POLL_INTERVAL_MS = 10_000; // 10s (recomendado MiniMax notebook)
174
+ /**
175
+ * Submete um vídeo para geração async, faz polling até conclusão, e
176
+ * baixa o vídeo IMEDIATAMENTE (file_id expira em 9h conforme notebook MiniMax).
177
+ *
178
+ * Retorna:
179
+ * - downloadPath: caminho do vídeo baixado em disco.
180
+ *
181
+ * Lança erro com mensagem amigável se timeout, fail, expired ou cancelamento.
182
+ *
183
+ * @param options.outputDir - Diretório onde salvar o vídeo (obrigatório)
184
+ * @param options.signal - AbortSignal para cancelamento cooperativo
185
+ */
186
+ export async function runMmxVideoWithPolling(args, options = {
187
+ outputDir: "",
188
+ }) {
189
+ const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
190
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
191
+ const outputDir = options.outputDir;
192
+ const signal = options.signal;
193
+ const checkAbort = () => {
194
+ if (signal?.aborted) {
195
+ throw new Error("Operação de vídeo cancelada pelo cliente MCP.");
196
+ }
197
+ };
198
+ // 1) Submit --async para receber task_id
199
+ checkAbort();
200
+ const submitArgs = [...args, "--async", "--quiet", "--output", "json"];
201
+ const { stdout: submitOut, exitCode: submitExit } = await runMmx(submitArgs, { signal });
202
+ if (submitExit !== 0) {
203
+ throw new Error(`Falha ao submeter vídeo: ${submitOut.trim()}`);
204
+ }
205
+ let taskId;
206
+ try {
207
+ const parsed = JSON.parse(submitOut.trim());
208
+ if (typeof parsed.taskId !== "string") {
209
+ throw new Error(`taskId ausente na resposta: ${submitOut}`);
210
+ }
211
+ taskId = parsed.taskId;
212
+ }
213
+ catch (err) {
214
+ const msg = err instanceof Error ? err.message : String(err);
215
+ throw new Error(`Resposta inesperada ao submeter vídeo: ${submitOut}\n${msg}`);
216
+ }
217
+ // 2) Poll até conclusão ou timeout
218
+ const deadline = Date.now() + maxWaitMs;
219
+ let taskInfo;
220
+ while (Date.now() < deadline) {
221
+ checkAbort();
222
+ await sleep(pollIntervalMs);
223
+ checkAbort();
224
+ const { stdout: pollOut, exitCode: pollExit } = await runMmx(["video", "task", "get", "--task-id", taskId, "--output", "json"], { signal });
225
+ if (pollExit !== 0) {
226
+ // Continua tentando — pode ser flakiness transitória da rede.
227
+ continue;
228
+ }
229
+ try {
230
+ const parsed = JSON.parse(pollOut.trim());
231
+ taskInfo = parsed;
232
+ if (parsed.status === "Success") {
233
+ break;
234
+ }
235
+ if (parsed.status === "Fail" || parsed.status === "Expired") {
236
+ const reason = parsed.status === "Fail"
237
+ ? parsed.error_message ?? "falha desconhecida"
238
+ : "task expirada antes de ser processada";
239
+ throw new Error(`Geração de vídeo falhou (${parsed.status}): ${reason}`);
240
+ }
241
+ }
242
+ catch (err) {
243
+ if (err instanceof Error && err.message.startsWith("Geração de vídeo falhou")) {
244
+ throw err;
245
+ }
246
+ // Erro de parse — ignora e continua polling
247
+ }
248
+ }
249
+ checkAbort();
250
+ if (!taskInfo || taskInfo.status !== "Success") {
251
+ throw new Error(`Timeout (${Math.round(maxWaitMs / 1000)}s) aguardando vídeo. Task ${taskId} ainda em ${taskInfo?.status ?? "desconhecido"}. Use a tool mmx_video_task_get para continuar o polling manualmente.`);
252
+ }
253
+ if (!taskInfo.file_id) {
254
+ throw new Error(`Sucesso mas file_id ausente na task ${taskId}.`);
255
+ }
256
+ // 3) Download imediato (file_id expira em 9h — notebook MiniMax) no outputDir do user
257
+ const outPath = join(outputDir, `mmx-video-${taskId}.mp4`);
258
+ checkAbort();
259
+ const dlResult = await runMmx(["video", "download", "--file-id", taskInfo.file_id, "--out", outPath], { signal });
260
+ if (dlResult.exitCode !== 0) {
261
+ throw new Error(`Falha ao baixar vídeo: ${dlResult.stderr || dlResult.stdout}`);
262
+ }
263
+ const downloadPath = dlResult.stdout.trim();
264
+ return { downloadPath, taskInfo };
265
+ }
266
+ // =============================================================================
267
+ // Detecção de erros
268
+ // =============================================================================
269
+ const AUTH_ERROR_PATTERNS = [
270
+ "401",
271
+ "403 forbidden", // algumas APIs retornam 403 quando auth inválida
272
+ "unauthorized",
273
+ "authentication",
274
+ "auth failed",
275
+ "session expired",
276
+ "not authenticated",
277
+ "credenciais",
278
+ "api key",
279
+ "invalid key",
280
+ "key inválida",
281
+ ];
282
+ export function isAuthError(message) {
283
+ if (!message)
284
+ return false;
285
+ const lower = message.toLowerCase();
286
+ return AUTH_ERROR_PATTERNS.some((pattern) => lower.includes(pattern));
287
+ }
288
+ const QUOTA_ERROR_PATTERNS = [
289
+ "quota",
290
+ "rate limit",
291
+ "429",
292
+ "spending limit", // comum em APIs pay-as-you-go
293
+ "insufficient balance", // billing
294
+ "insufficient credits",
295
+ "credits exhausted",
296
+ "limit reached",
297
+ "too many requests",
298
+ "daily limit",
299
+ "monthly limit",
300
+ "exceeded your current quota",
301
+ "resource_exhausted",
302
+ "billing",
303
+ ];
304
+ export function isQuotaError(message) {
305
+ if (!message)
306
+ return false;
307
+ const lower = message.toLowerCase();
308
+ return QUOTA_ERROR_PATTERNS.some((pattern) => lower.includes(pattern));
309
+ }
310
+ /**
311
+ * Detecta se o erro é devido ao CLI mmx não estar instalado.
312
+ * Útil para transformar `spawn ENOENT` em mensagem acionável.
313
+ */
314
+ export function isCliNotInstalled(message) {
315
+ if (!message)
316
+ return false;
317
+ const lower = message.toLowerCase();
318
+ return (lower.includes("enoent") ||
319
+ lower.includes("command not found") ||
320
+ lower.includes("not found") && lower.includes("mmx"));
321
+ }
322
+ /**
323
+ * Detecta timeout (já é um erro conhecido).
324
+ */
325
+ export function isTimeoutError(message) {
326
+ if (!message)
327
+ return false;
328
+ const lower = message.toLowerCase();
329
+ return lower.includes("timeout");
330
+ }
331
+ /**
332
+ * Detecta se o erro é "arquivo não existe" — comum em upload, vision, etc.
333
+ */
334
+ export function isFileNotFound(message) {
335
+ if (!message)
336
+ return false;
337
+ const lower = message.toLowerCase();
338
+ return (lower.includes("file not found") ||
339
+ lower.includes("no such file") ||
340
+ lower.includes("path not found") ||
341
+ lower.includes("enoent") && lower.includes(".png") ||
342
+ lower.includes("enoent") && lower.includes(".jpg"));
343
+ }
344
+ // =============================================================================
345
+ // Mensagens de erro padronizadas
346
+ // =============================================================================
347
+ /**
348
+ * Mensagem para erros de autenticação. Direciona para mmx_login
349
+ * e explica a diferença entre Pay-as-you-go e Token Plan.
350
+ */
351
+ export function authErrorMessage(detail) {
352
+ return [
353
+ "Erro de autenticação. A sessão do mmx-cli expirou ou a API key é inválida.",
354
+ "Para resolver:",
355
+ " 1. Defina a variável de ambiente MINIMAX_API_KEY com uma chave Pay-as-you-go (sk-...) e reinicie o cliente MCP",
356
+ " 2. OU chame a tool mmx_login passando apiKey='sk-xxx' como argumento",
357
+ "Nota: Use Pay-as-you-go (sk-...), NÃO Token Plan (sk-cp-...). O Token Plan foi feito para uso individual e aplica throttling agressivo em batch.",
358
+ `Detalhes: ${detail}`,
359
+ ].join("\n");
360
+ }
361
+ /**
362
+ * Mensagem genérica — fallback usado quando nenhum pattern específico casa.
363
+ * Agora inclui instrução genérica de correção.
364
+ */
365
+ export function genericErrorMessage(operation, detail) {
366
+ return [
367
+ `Erro ao ${operation}.`,
368
+ "Verifique os parâmetros e tente novamente. Se persistir, consulte o stderr abaixo para detalhes técnicos.",
369
+ `Detalhes: ${detail}`,
370
+ ].join("\n");
371
+ }
372
+ /**
373
+ * Mensagem específica quando o CLI mmx não está instalado.
374
+ * Guia o usuário a instalar.
375
+ */
376
+ export function cliNotInstalledMessage(operation, detail) {
377
+ return [
378
+ `Não foi possível executar ${operation}: o CLI 'mmx' não está instalado ou não está no PATH.`,
379
+ "",
380
+ "Para corrigir:",
381
+ " 1. Instale globalmente: npm install -g mmx-cli",
382
+ " 2. Verifique com: mmx --version (deve retornar 1.0.0+)",
383
+ " 3. Configure a API key: export MINIMAX_API_KEY='sk-...'",
384
+ " 4. Reinicie o cliente MCP (Claude Desktop / Cursor)",
385
+ "",
386
+ `Detalhes técnicos: ${detail}`,
387
+ ].join("\n");
388
+ }
389
+ /**
390
+ * Mensagem específica para quota excedida — com janela de reset e alternativas.
391
+ */
392
+ export function quotaErrorMessage(operation, detail) {
393
+ return [
394
+ `Quota ou rate limit excedido em ${operation}.`,
395
+ "Como resolver:",
396
+ " - Aguarde a janela de rate limit resetar (geralmente 1 minuto para RPM)",
397
+ " - Se for Token Plan (janela 5h), aguarde reset ou faça upgrade",
398
+ " - Se for Pay-as-you-go, verifique saldo em platform.minimax.io",
399
+ " - Para operações caras, use mmx_quota_show antes de fazer batch",
400
+ `Detalhes: ${detail}`,
401
+ ].join("\n");
402
+ }
403
+ /**
404
+ * Mensagem específica para timeout.
405
+ */
406
+ export function timeoutErrorMessage(operation, detail) {
407
+ return [
408
+ `Timeout em ${operation}.`,
409
+ "Como resolver:",
410
+ " - Reduza o tamanho do input (texto menor, prompt mais simples)",
411
+ " - Aumente o timeout se a operação for pesada (ex: vídeo longo)",
412
+ `Detalhes: ${detail}`,
413
+ ].join("\n");
414
+ }
415
+ /**
416
+ * Mensagem específica para arquivo não encontrado.
417
+ */
418
+ export function fileNotFoundMessage(operation, filePath, detail) {
419
+ return [
420
+ `Arquivo não encontrado em ${operation}: "${filePath}".`,
421
+ "Verifique se:",
422
+ " - O path existe e está acessível",
423
+ " - Você usou path absoluto (recomendado) ou relativo ao cwd do MCP",
424
+ " - O arquivo tem permissão de leitura",
425
+ `Detalhes: ${detail}`,
426
+ ].join("\n");
427
+ }
428
+ // =============================================================================
429
+ // Helper unificado para handlers
430
+ // =============================================================================
431
+ /**
432
+ * Helper central para tratamento de erros em handlers de tool.
433
+ * Detecta automaticamente:
434
+ * - mmx não instalado → cliNotInstalledMessage
435
+ * - autenticação → authErrorMessage
436
+ * - quota/rate limit → quotaErrorMessage
437
+ * - timeout → timeoutErrorMessage
438
+ * - arquivo não encontrado → fileNotFoundMessage
439
+ * - genérico → genericErrorMessage
440
+ *
441
+ * Uso:
442
+ * ```ts
443
+ * const result = await runMmx(["image", "generate", "--prompt", prompt]);
444
+ * if (result.exitCode !== 0) {
445
+ * return handleToolError("gerar imagem", result);
446
+ * }
447
+ * ```
448
+ */
449
+ export function handleToolError(operation, result, options = {}) {
450
+ const errMsg = sanitizeErrorMessage((result.stderr || result.stdout || "").trim());
451
+ if (!errMsg) {
452
+ return {
453
+ content: [
454
+ {
455
+ type: "text",
456
+ text: genericErrorMessage(operation, `Comando saiu com exitCode ${result.exitCode} sem mensagem de erro.`),
457
+ },
458
+ ],
459
+ isError: true,
460
+ };
461
+ }
462
+ if (isCliNotInstalled(errMsg)) {
463
+ return {
464
+ content: [{ type: "text", text: cliNotInstalledMessage(operation, errMsg) }],
465
+ isError: true,
466
+ };
467
+ }
468
+ if (isAuthError(errMsg)) {
469
+ return {
470
+ content: [{ type: "text", text: authErrorMessage(errMsg) }],
471
+ isError: true,
472
+ };
473
+ }
474
+ if (isQuotaError(errMsg)) {
475
+ return {
476
+ content: [{ type: "text", text: quotaErrorMessage(operation, errMsg) }],
477
+ isError: true,
478
+ };
479
+ }
480
+ if (isTimeoutError(errMsg)) {
481
+ return {
482
+ content: [{ type: "text", text: timeoutErrorMessage(operation, errMsg) }],
483
+ isError: true,
484
+ };
485
+ }
486
+ if (options.filePath && isFileNotFound(errMsg)) {
487
+ return {
488
+ content: [{ type: "text", text: fileNotFoundMessage(operation, options.filePath, errMsg) }],
489
+ isError: true,
490
+ };
491
+ }
492
+ return {
493
+ content: [{ type: "text", text: genericErrorMessage(operation, errMsg) }],
494
+ isError: true,
495
+ };
496
+ }
497
+ /**
498
+ * Compara duas versões semânticas (X.Y.Z). Retorna negativo se a < b.
499
+ */
500
+ export function compareVersions(a, b) {
501
+ const pa = a.split(".").map((n) => Number.parseInt(n, 10));
502
+ const pb = b.split(".").map((n) => Number.parseInt(n, 10));
503
+ for (let i = 0; i < 3; i++) {
504
+ const va = pa[i] ?? 0;
505
+ const vb = pb[i] ?? 0;
506
+ if (va !== vb)
507
+ return va - vb;
508
+ }
509
+ return 0;
510
+ }
511
+ /**
512
+ * Roda `mmx --version` e extrai X.Y.Z.
513
+ * Retorna `installed: null` se mmx não está disponível.
514
+ */
515
+ export async function checkMmxVersion() {
516
+ try {
517
+ const invocation = resolveMmxInvocation();
518
+ const args = [...invocation.prefixArgs, "--version"];
519
+ const invocationCommand = invocation.command;
520
+ const stdout = await new Promise((resolve, reject) => {
521
+ const proc = spawn(invocationCommand, args, {
522
+ stdio: ["ignore", "pipe", "pipe"],
523
+ shell: false,
524
+ windowsHide: true,
525
+ });
526
+ let out = "";
527
+ proc.stdout.on("data", (chunk) => {
528
+ out += chunk.toString();
529
+ });
530
+ proc.on("error", reject);
531
+ proc.on("close", (code) => {
532
+ if (code === 0)
533
+ resolve(out.trim());
534
+ else
535
+ reject(new Error(`exit ${code}`));
536
+ });
537
+ });
538
+ const match = stdout.match(/(\d+\.\d+\.\d+)/);
539
+ if (!match || !match[1]) {
540
+ return {
541
+ installed: null,
542
+ minimum: MINIMUM_MMX_VERSION,
543
+ ok: false,
544
+ message: `Não foi possível parsear a versão do mmx: "${stdout}"`,
545
+ };
546
+ }
547
+ const installed = match[1];
548
+ const ok = compareVersions(installed, MINIMUM_MMX_VERSION) >= 0;
549
+ return {
550
+ installed,
551
+ minimum: MINIMUM_MMX_VERSION,
552
+ ok,
553
+ message: ok
554
+ ? `mmx-cli v${installed} (>= ${MINIMUM_MMX_VERSION}).`
555
+ : `mmx-cli v${installed} detectado. Recomendado >= ${MINIMUM_MMX_VERSION}. Algumas tools podem falhar.`,
556
+ };
557
+ }
558
+ catch {
559
+ return {
560
+ installed: null,
561
+ minimum: MINIMUM_MMX_VERSION,
562
+ ok: false,
563
+ message: "mmx-cli não encontrado no PATH. Instale com: npm install -g mmx-cli (O MCP tentará usar npx mmx-cli como fallback).",
564
+ };
565
+ }
566
+ }
567
+ // =============================================================================
568
+ // Path validation (security: path traversal + size + realpath)
569
+ // =============================================================================
570
+ /** Tamanho máximo padrão de arquivo de entrada (10MB). Acima disso, rejeitar. */
571
+ export const MAX_INPUT_FILE_BYTES = 10 * 1024 * 1024;
572
+ /**
573
+ * Valida um diretório de saída. Cria se não existir. Defesa contra path traversal.
574
+ *
575
+ * @param dir - Path do diretório (relativo ou absoluto)
576
+ * @param create - Se true, cria o diretório se não existir (default: true)
577
+ * @returns PathValidationResult com resolved path ou error
578
+ */
579
+ export function validateOutputDir(dir, create = true) {
580
+ if (!dir || typeof dir !== "string") {
581
+ return { ok: false, resolved: "", error: "Diretório vazio." };
582
+ }
583
+ // Defesa contra path traversal óbvio (defesa em profundidade — IA deveria passar path válido)
584
+ if (dir.includes("\0")) {
585
+ return { ok: false, resolved: "", error: "Path contém caractere nulo." };
586
+ }
587
+ const resolved = resolvePath(dir);
588
+ try {
589
+ if (!existsSync(resolved)) {
590
+ if (!create) {
591
+ return { ok: false, resolved, error: `Diretório não existe: "${resolved}"` };
592
+ }
593
+ mkdirSync(resolved, { recursive: true });
594
+ }
595
+ else {
596
+ const stat = statSync(resolved);
597
+ if (!stat.isDirectory()) {
598
+ return { ok: false, resolved, error: `Path não é um diretório: "${resolved}"` };
599
+ }
600
+ }
601
+ }
602
+ catch (err) {
603
+ const msg = err instanceof Error ? err.message : String(err);
604
+ return { ok: false, resolved, error: `Não foi possível criar/acessar diretório: ${msg}` };
605
+ }
606
+ return { ok: true, resolved };
607
+ }
608
+ /**
609
+ * Valida um arquivo de entrada. Defesa contra:
610
+ * - Path traversal (`../../../etc/passwd`)
611
+ * - Device files (`/dev/zero`, `\\.\NUL`)
612
+ * - Arquivos excessivamente grandes (>MAX_INPUT_FILE_BYTES)
613
+ *
614
+ * @param filePath - Path do arquivo
615
+ * @param maxBytes - Limite custom (default: MAX_INPUT_FILE_BYTES = 10MB)
616
+ */
617
+ export function validateInputFile(filePath, maxBytes = MAX_INPUT_FILE_BYTES) {
618
+ if (!filePath || typeof filePath !== "string") {
619
+ return { ok: false, resolved: "", error: "Path vazio." };
620
+ }
621
+ // Null byte injection
622
+ if (filePath.includes("\0")) {
623
+ return { ok: false, resolved: "", error: "Path contém caractere nulo." };
624
+ }
625
+ const resolved = resolvePath(filePath);
626
+ let stat;
627
+ try {
628
+ stat = statSync(resolved);
629
+ }
630
+ catch (err) {
631
+ const msg = err instanceof Error ? err.message : String(err);
632
+ return { ok: false, resolved, error: `Arquivo não encontrado: "${resolved}". ${msg}` };
633
+ }
634
+ if (!stat.isFile()) {
635
+ return {
636
+ ok: false,
637
+ resolved,
638
+ error: `Path não é um arquivo regular: "${resolved}". Device files e diretórios são rejeitados.`,
639
+ };
640
+ }
641
+ if (stat.size > maxBytes) {
642
+ const mb = Math.round(stat.size / 1024 / 1024);
643
+ const limitMb = Math.round(maxBytes / 1024 / 1024);
644
+ return {
645
+ ok: false,
646
+ resolved,
647
+ size: stat.size,
648
+ error: `Arquivo muito grande: ${mb}MB (limite: ${limitMb}MB).`,
649
+ };
650
+ }
651
+ // Resolve symlinks para garantir path final conhecido (defesa em profundidade)
652
+ let realpath = resolved;
653
+ try {
654
+ realpath = realpathSync(resolved);
655
+ }
656
+ catch {
657
+ // realpath pode falhar em alguns FS — manter resolved
658
+ }
659
+ return { ok: true, resolved: realpath, size: stat.size };
660
+ }
661
+ // =============================================================================
662
+ // Error message sanitization (redact API keys)
663
+ // =============================================================================
664
+ /**
665
+ * Regex que captura API keys no estilo MiniMax (`sk-...` ou `sk-cp-...`).
666
+ * Pelo menos 20 chars alfanuméricos após o prefixo.
667
+ */
668
+ const API_KEY_PATTERN = /sk-[a-zA-Z0-9_-]{20,}/gi;
669
+ /**
670
+ * Redacta possíveis API keys em uma mensagem de erro antes de retornar pro LLM.
671
+ * Defesa contra keys vazadas via stderr do CLI.
672
+ */
673
+ export function sanitizeErrorMessage(message) {
674
+ if (!message)
675
+ return message;
676
+ return message.replace(API_KEY_PATTERN, "sk-***REDACTED***");
677
+ }
678
+ // =============================================================================
679
+ // Concurrency limiter
680
+ // =============================================================================
681
+ /**
682
+ * Semáforo simples para limitar número de execuções simultâneas do CLI `mmx`.
683
+ * Evita DoS local (10 tools em paralelo = 10 processos simultâneos).
684
+ *
685
+ * IMPORTANTE: `acquire()` rejeita após `queueTimeoutMs` para evitar
686
+ * tools travando indefinidamente em fila cheia.
687
+ */
688
+ class ConcurrencyLimiter {
689
+ active = 0;
690
+ max;
691
+ queue = [];
692
+ queueTimeoutMs;
693
+ constructor(max, queueTimeoutMs = 60_000) {
694
+ this.max = max;
695
+ this.queueTimeoutMs = queueTimeoutMs;
696
+ }
697
+ async acquire() {
698
+ if (this.active < this.max) {
699
+ this.active++;
700
+ return;
701
+ }
702
+ return new Promise((resolve, reject) => {
703
+ const timer = setTimeout(() => {
704
+ const idx = this.queue.findIndex((q) => q.resolve === resolve);
705
+ if (idx !== -1)
706
+ this.queue.splice(idx, 1);
707
+ reject(new Error(`[mmx-cli MCP] Fila de concorrência saturada (${this.active}/${this.max}). ` +
708
+ `Timeout de ${Math.round(this.queueTimeoutMs / 1000)}s aguardando slot. ` +
709
+ `Reduza o paralelismo ou aguarde tools em andamento.`));
710
+ }, this.queueTimeoutMs);
711
+ this.queue.push({ resolve, reject, timer });
712
+ });
713
+ }
714
+ release() {
715
+ this.active--;
716
+ const next = this.queue.shift();
717
+ if (next) {
718
+ this.active++;
719
+ clearTimeout(next.timer);
720
+ next.resolve();
721
+ }
722
+ }
723
+ }
724
+ /** Limiter global — max 3 chamadas simultâneas ao CLI `mmx`. */
725
+ export const mmxConcurrencyLimiter = new ConcurrencyLimiter(3);
726
+ /**
727
+ * Helper: executa uma função sob o limiter de concorrência.
728
+ * Garante release() mesmo em caso de throw.
729
+ * Se acquire() for rejeitado por timeout de fila, propaga o erro.
730
+ */
731
+ export async function withConcurrency(fn) {
732
+ let acquired = false;
733
+ try {
734
+ await mmxConcurrencyLimiter.acquire();
735
+ acquired = true;
736
+ return await fn();
737
+ }
738
+ finally {
739
+ if (acquired)
740
+ mmxConcurrencyLimiter.release();
741
+ }
742
+ }
743
+ //# sourceMappingURL=utils.js.map