@danieltmn/tamnora 0.6.2 → 0.7.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/SECURITY.md +6 -0
  3. package/harness/packages/cli/src/commands/repl.js +2 -0
  4. package/harness/packages/cli/src/commands/run.js +2 -0
  5. package/harness/packages/cli/src/commands/tui.js +2 -0
  6. package/harness/packages/cli/src/lib/runtime.js +4 -0
  7. package/harness/packages/cli/src/tui/app.js +4 -0
  8. package/harness/packages/core/src/agent/runAgent.js +131 -47
  9. package/harness/packages/core/src/config/index.js +3 -1
  10. package/harness/packages/core/src/config/secrets.js +36 -0
  11. package/harness/packages/core/src/eval/runTask.js +2 -11
  12. package/harness/packages/core/src/index.js +13 -1
  13. package/harness/packages/core/src/mcp/common.js +15 -11
  14. package/harness/packages/core/src/mcp/index.js +1 -1
  15. package/harness/packages/core/src/mcp/registry.js +1 -0
  16. package/harness/packages/core/src/mcp/stdio.js +41 -2
  17. package/harness/packages/core/src/redact.js +104 -0
  18. package/harness/packages/core/test/agent.test.js +93 -0
  19. package/harness/packages/core/test/mcp.test.js +67 -0
  20. package/harness/packages/core/test/redact.test.js +45 -0
  21. package/harness/packages/server/public/app.js +217 -18
  22. package/harness/packages/server/public/index.html +89 -26
  23. package/harness/packages/server/public/styles.css +61 -1
  24. package/harness/packages/server/src/lib/agentTools.js +9 -3
  25. package/harness/packages/server/src/lib/channels/telegram.js +11 -5
  26. package/harness/packages/server/src/routes/chat.js +8 -1
  27. package/harness/packages/server/src/routes/mcp.js +29 -5
  28. package/harness/packages/server/src/routes/providers.js +16 -15
  29. package/harness/packages/server/src/server.js +13 -0
  30. package/harness/packages/server/test/server.test.js +76 -1
  31. package/harness/tamnora.example.json +17 -1
  32. package/package.json +1 -1
@@ -33,7 +33,7 @@
33
33
  </head>
34
34
  <body>
35
35
  <header class="topbar">
36
- <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.6.2</span></div>
36
+ <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.7.0</span></div>
37
37
  <nav class="tabs">
38
38
  <button class="tab active" data-tab="chat">chat</button>
39
39
  <button class="tab" data-tab="providers">proveedores</button>
@@ -118,6 +118,7 @@
118
118
  </div>
119
119
  </div>
120
120
  <form id="chat-form" class="composer">
121
+ <div id="slash-menu" class="slash-menu" hidden></div>
121
122
  <div id="attach-preview" class="attach-preview" hidden></div>
122
123
  <div class="composer-row">
123
124
  <span class="prompt-mark">❯</span>
@@ -134,7 +135,7 @@
134
135
  <button class="btn" type="button" id="stop-btn" title="Detener la respuesta" hidden>parar</button>
135
136
  </div>
136
137
  </form>
137
- <div class="statusbar">
138
+ <div id="statusbar" class="statusbar" title="cambiar modo, proveedor y modelo" role="button" tabindex="0">
138
139
  <span id="sb-mode">agent</span>
139
140
  <span class="dot">·</span>
140
141
  <span id="sb-provider">—</span>
@@ -343,7 +344,62 @@
343
344
  </section>
344
345
 
345
346
  <section id="view-ajustes" class="view">
346
- <div class="panel">
347
+ <div class="ajustes-wrap">
348
+ <nav class="ajustes-tabs" role="tablist" aria-label="secciones de ajustes">
349
+ <button type="button" class="ajustes-tab active" data-panel="agente" role="tab">Agente</button>
350
+ <button type="button" class="ajustes-tab" data-panel="sesiones" role="tab">Sesiones</button>
351
+ <button type="button" class="ajustes-tab" data-panel="mcp" role="tab">MCP</button>
352
+ <button type="button" class="ajustes-tab" data-panel="skills" role="tab">Skills</button>
353
+ <button type="button" class="ajustes-tab" data-panel="telegram" role="tab">Telegram</button>
354
+ <button type="button" class="ajustes-tab" data-panel="limites" role="tab">Límites</button>
355
+ </nav>
356
+
357
+ <div class="panel ajustes-panel active" data-panel="agente">
358
+ <div class="row between">
359
+ <h2>Agente</h2>
360
+ </div>
361
+ <p class="muted verify-line">
362
+ Modo, proveedor, modelo y aprobación automática del agente.
363
+ </p>
364
+ <div id="agent-controls-slot">
365
+ <div id="agent-settings-controls">
366
+ <label for="mode">Modo</label>
367
+ <select id="mode" title="modo de agente"></select>
368
+ <label for="provider">Proveedor</label>
369
+ <select id="provider" title="proveedor"></select>
370
+ <label for="model">Modelo</label>
371
+ <select id="model" title="modelo"></select>
372
+ <label class="check" title="aprobar automáticamente tools con permiso ask">
373
+ <input id="yes" type="checkbox" /> Aprobar automáticamente las tools (auto)
374
+ </label>
375
+ </div>
376
+ </div>
377
+ <div class="row">
378
+ <button type="button" id="agent-settings-open" class="btn">abrir en ventana</button>
379
+ <button type="button" id="agent-providers" class="btn">gestionar proveedores</button>
380
+ </div>
381
+ </div>
382
+
383
+ <div class="panel ajustes-panel" data-panel="sesiones">
384
+ <div class="row between">
385
+ <h2>Sesiones</h2>
386
+ </div>
387
+ <p class="muted verify-line">
388
+ Carpeta donde se abre el selector de carpetas y <code>/new</code> cuando no hay sesión.
389
+ </p>
390
+ <label for="default-cwd">Carpeta predeterminada para nuevas sesiones</label>
391
+ <div class="row default-cwd-row">
392
+ <input id="default-cwd" type="text" readonly placeholder="(la casa del usuario)" title="carpeta donde se abre el selector y /new sin sesión" />
393
+ <button type="button" id="default-cwd-pick" class="btn">elegir</button>
394
+ <button type="button" id="default-cwd-clear" class="btn icon" title="quitar la carpeta predeterminada" aria-label="quitar la carpeta predeterminada">
395
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
396
+ <path d="M18 6 6 18M6 6l12 12" />
397
+ </svg>
398
+ </button>
399
+ </div>
400
+ </div>
401
+
402
+ <div class="panel ajustes-panel" data-panel="mcp">
347
403
  <div class="row between">
348
404
  <h2>Servidores MCP</h2>
349
405
  <div class="row">
@@ -362,7 +418,7 @@
362
418
  <ul id="mcp-list" class="providers"></ul>
363
419
  </div>
364
420
 
365
- <div class="panel">
421
+ <div class="panel ajustes-panel" data-panel="skills">
366
422
  <div class="row between">
367
423
  <h2>Skills</h2>
368
424
  <button id="skill-new" class="btn">+ nueva</button>
@@ -378,7 +434,7 @@
378
434
  <ul id="skill-list" class="providers"></ul>
379
435
  </div>
380
436
 
381
- <div class="panel">
437
+ <div class="panel ajustes-panel" data-panel="telegram">
382
438
  <div class="row between">
383
439
  <h2>Telegram</h2>
384
440
  <span id="tg-status" class="summary">cargando…</span>
@@ -416,6 +472,30 @@
416
472
  <span id="tg-msg" class="summary"></span>
417
473
  </div>
418
474
  </div>
475
+
476
+ <div class="panel ajustes-panel" data-panel="limites">
477
+ <div class="row between">
478
+ <h2>Límites del agente</h2>
479
+ </div>
480
+ <p class="muted verify-line">
481
+ El agente trabaja hasta terminar solo (<code>end_turn</code>). Estos topes son
482
+ opcionales: <code>0</code> = sin límite. Al cortarse, cierra con un resumen.
483
+ </p>
484
+ <label class="field"><span>máximo de rondas de herramientas (0 = sin límite)</span>
485
+ <input id="limits-rounds" type="number" min="0" step="1" placeholder="0" />
486
+ </label>
487
+ <label class="field"><span>presupuesto de tokens (0 = sin límite)</span>
488
+ <input id="limits-tokens" type="number" min="0" step="1000" placeholder="0" />
489
+ </label>
490
+ <label class="field"><span>presupuesto de tiempo en segundos (0 = sin límite)</span>
491
+ <input id="limits-duration" type="number" min="0" step="1" placeholder="0" />
492
+ </label>
493
+ <div class="row">
494
+ <button id="limits-save" class="btn primary" type="button">guardar</button>
495
+ <span id="limits-msg" class="summary"></span>
496
+ </div>
497
+ </div>
498
+ </div>
419
499
  </section>
420
500
 
421
501
  <dialog id="folder-modal" class="modal folder-modal">
@@ -485,25 +565,7 @@
485
565
  </svg>
486
566
  </button>
487
567
  </div>
488
- <label for="mode">Modo</label>
489
- <select id="mode" title="modo de agente"></select>
490
- <label for="provider">Proveedor</label>
491
- <select id="provider" title="proveedor"></select>
492
- <label for="model">Modelo</label>
493
- <select id="model" title="modelo"></select>
494
- <label for="default-cwd">Carpeta predeterminada para nuevas sesiones</label>
495
- <div class="row default-cwd-row">
496
- <input id="default-cwd" type="text" readonly placeholder="(la casa del usuario)" title="carpeta donde se abre el selector y /new sin sesión" />
497
- <button type="button" id="default-cwd-pick" class="btn">elegir</button>
498
- <button type="button" id="default-cwd-clear" class="btn icon" title="quitar la carpeta predeterminada" aria-label="quitar la carpeta predeterminada">
499
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
500
- <path d="M18 6 6 18M6 6l12 12" />
501
- </svg>
502
- </button>
503
- </div>
504
- <label class="check" title="aprobar automáticamente tools con permiso ask">
505
- <input id="yes" type="checkbox" /> Aprobar automáticamente las tools (auto)
506
- </label>
568
+ <div id="settings-controls-slot"></div>
507
569
  <div class="row between settings-actions">
508
570
  <button type="button" id="settings-providers" class="btn">gestionar proveedores</button>
509
571
  <button type="button" id="settings-done" class="btn primary">listo</button>
@@ -538,9 +600,10 @@
538
600
  <div class="row">
539
601
  <label class="field grow" id="mf-args-field"><span>args (separados por espacio)</span><input id="mf-args" spellcheck="false" placeholder="harness/scripts/mcp-echo-server.mjs" /></label>
540
602
  </div>
541
- <label class="field"><span>env (KEY=valor por línea)</span>
542
- <textarea id="mf-env" rows="3" spellcheck="false" placeholder="API_KEY=..."></textarea>
603
+ <label class="field" id="mf-env-field"><span>env (KEY=valor por línea · usá <code>env:VAR</code> para no hardcodear secretos)</span>
604
+ <textarea id="mf-env" rows="3" spellcheck="false" placeholder="API_KEY=env:MY_API_KEY"></textarea>
543
605
  </label>
606
+ <label class="check" id="mf-inherit-field"><input id="mf-inherit-env" type="checkbox" /> heredar todo el entorno (<code>inheritEnv</code>, solo MCPs de confianza)</label>
544
607
  <label class="check"><input id="mf-enabled" type="checkbox" checked /> habilitado</label>
545
608
  <div class="row between settings-actions">
546
609
  <button type="button" id="mf-cancel" class="btn">cancelar</button>
@@ -621,7 +621,7 @@ details.reasoning .body { color: var(--muted); white-space: pre-wrap; margin-top
621
621
 
622
622
  @keyframes pulse { 50% { opacity: 0.35; } }
623
623
 
624
- .composer { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-top: 1px solid var(--border); background: var(--panel); backdrop-filter: blur(14px); }
624
+ .composer { position: relative; display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; border-top: 1px solid var(--border); background: var(--panel); backdrop-filter: blur(14px); }
625
625
  .composer.dragging { outline: 1px dashed var(--accent); outline-offset: -4px; }
626
626
  .composer-row { display: flex; align-items: flex-end; gap: 8px; }
627
627
  .prompt-mark { color: var(--mine); font-weight: 700; padding-bottom: 8px; }
@@ -707,6 +707,46 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
707
707
  }
708
708
  .statusbar .dot { opacity: 0.5; }
709
709
  .statusbar .spacer { flex: 1; }
710
+ .statusbar[role="button"] { cursor: pointer; }
711
+ .statusbar[role="button"]:hover { color: var(--fg); }
712
+
713
+ /* menú de comandos "/" en el composer */
714
+ .slash-menu {
715
+ position: absolute;
716
+ left: 14px;
717
+ right: 14px;
718
+ bottom: calc(100% - 6px);
719
+ max-height: 260px;
720
+ overflow-y: auto;
721
+ display: flex;
722
+ flex-direction: column;
723
+ gap: 2px;
724
+ padding: 6px;
725
+ border: 1px solid var(--border);
726
+ border-radius: 12px;
727
+ background: var(--panel);
728
+ box-shadow: 0 12px 32px rgb(0 0 0 / 0.22);
729
+ z-index: 30;
730
+ }
731
+ .slash-menu[hidden] { display: none; }
732
+ .slash-item {
733
+ display: flex;
734
+ align-items: baseline;
735
+ gap: 10px;
736
+ width: 100%;
737
+ padding: 7px 9px;
738
+ border: 0;
739
+ border-radius: 8px;
740
+ background: none;
741
+ color: var(--fg);
742
+ text-align: left;
743
+ cursor: pointer;
744
+ font: 500 13px var(--mono);
745
+ }
746
+ .slash-item.active,
747
+ .slash-item:hover { background: var(--theirs); }
748
+ .slash-cmd { color: var(--accent); font-weight: 700; }
749
+ .slash-desc { color: var(--muted); font: 400 12px var(--serif); }
710
750
  .ctx { display: inline-flex; align-items: center; gap: 8px; }
711
751
  .ctx-bar { width: 76px; height: 6px; border-radius: 999px; background: var(--border); overflow: hidden; }
712
752
  .ctx-fill { display: block; height: 100%; width: 0; border-radius: 999px; background: currentColor; transition: width 0.35s ease; }
@@ -724,6 +764,26 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
724
764
 
725
765
  .card { border: 1px solid var(--border); background: var(--panel); backdrop-filter: blur(14px); border-radius: 14px; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
726
766
 
767
+ /* ajustes: pestañas internas ---------------------------------------------- */
768
+ #view-ajustes.active { display: block; overflow-y: auto; }
769
+ .ajustes-wrap { width: 100%; max-width: 780px; margin: 0 auto; padding: 18px; display: flex; flex-direction: column; gap: 12px; min-height: 100%; }
770
+ .ajustes-tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
771
+ .ajustes-tab {
772
+ appearance: none; background: none; border: 0; border-bottom: 2px solid transparent;
773
+ padding: 8px 14px; margin-bottom: -1px; color: var(--muted); cursor: pointer;
774
+ font: 600 13px var(--serif); letter-spacing: 0.01em;
775
+ }
776
+ .ajustes-tab:hover { color: var(--fg); }
777
+ .ajustes-tab.active { color: var(--fg); border-bottom-color: var(--accent); }
778
+ .ajustes-panel { display: none; }
779
+ .ajustes-panel.active { display: flex; }
780
+ .ajustes-panel.panel { flex: none; max-width: none; overflow: visible; padding: 6px 2px 18px; }
781
+ .ajustes-panel .verify-line code { font-size: 11px; }
782
+ #agent-settings-controls { display: flex; flex-direction: column; gap: 7px; }
783
+ #agent-settings-controls label { color: var(--muted); font-size: 12px; }
784
+ #agent-settings-controls select { width: 100%; }
785
+ #agent-controls-slot:empty { display: none; }
786
+
727
787
  .providers { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
728
788
  .providers li {
729
789
  display: flex;
@@ -42,6 +42,8 @@ const DEFINITIONS = [
42
42
  transport: { type: 'string', enum: ['stdio', 'http'] },
43
43
  command: { type: 'string', description: 'Para stdio, ej. "node" o "npx"' },
44
44
  args: { type: 'array', items: { type: 'string' } },
45
+ env: { type: 'object', description: 'Variables para el MCP (usá "env:VAR" para no hardcodear secretos)' },
46
+ inheritEnv: { type: 'boolean', description: 'Heredar process.env (solo MCPs de confianza; default false)' },
45
47
  url: { type: 'string', description: 'Para http' },
46
48
  enabled: { type: 'boolean' },
47
49
  },
@@ -133,7 +135,7 @@ export function createAgentTools(ctx) {
133
135
  command: cfg.command ?? null,
134
136
  url: cfg.url ?? null,
135
137
  enabled: true,
136
- config: { args: cfg.args ?? [], env: cfg.env ?? null },
138
+ config: { args: cfg.args ?? [], env: cfg.env ?? null, inheritEnv: cfg.inheritEnv === true },
137
139
  });
138
140
  await ctx.reloadMcp();
139
141
  const required = (cfg.requiredEnv ?? []).length ? ` Requiere env: ${cfg.requiredEnv.join(', ')}.` : '';
@@ -154,7 +156,7 @@ export function createAgentTools(ctx) {
154
156
  command: cfg.command ?? null,
155
157
  url: cfg.url ?? null,
156
158
  enabled: cfg.enabled !== false,
157
- config: { args: cfg.args ?? [], env: cfg.env ?? null },
159
+ config: { args: cfg.args ?? [], env: cfg.env ?? null, inheritEnv: cfg.inheritEnv === true },
158
160
  });
159
161
  await ctx.reloadMcp();
160
162
  return `MCP "${cfg.id}" importado.`;
@@ -174,7 +176,11 @@ export function createAgentTools(ctx) {
174
176
  command: transport === 'stdio' ? command : null,
175
177
  url: transport === 'http' ? url : null,
176
178
  enabled: args.enabled !== false,
177
- config: { args: Array.isArray(args.args) ? args.args : [], env: args.env ?? null },
179
+ config: {
180
+ args: Array.isArray(args.args) ? args.args : [],
181
+ env: args.env ?? null,
182
+ inheritEnv: args.inheritEnv === true,
183
+ },
178
184
  });
179
185
  await ctx.reloadMcp();
180
186
  return `MCP "${id}" registrado (${transport}).`;
@@ -75,8 +75,8 @@ export function completionFooter(stopReason, rounds, toolCalls, durationMs) {
75
75
  const parts = [`${rounds ?? '?'} ronda${rounds === 1 ? '' : 's'}`];
76
76
  if (toolCalls) parts.push(`${toolCalls} herramienta${toolCalls === 1 ? '' : 's'}`);
77
77
  if (durationMs > 0) parts.push(durationMs >= 1000 ? `${(durationMs / 1000).toFixed(1)}s` : `${durationMs}ms`);
78
- const maxed = stopReason === 'max_rounds';
79
- return `${maxed ? '⚠ máximo de rondas' : '✓ terminado'} · ${parts.join(' · ')}`;
78
+ const maxed = ['max_rounds', 'no_progress', 'budget'].includes(stopReason);
79
+ return `${maxed ? '⚠ corte de seguridad' : '✓ terminado'} · ${parts.join(' · ')}`;
80
80
  }
81
81
 
82
82
  /**
@@ -249,7 +249,9 @@ export function startTelegramChannel(
249
249
  tools: definitions,
250
250
  execute,
251
251
  signal: controller.signal,
252
- maxRounds: ctx.config.maxRounds,
252
+ maxRounds: ctx.effectiveMaxRounds?.() ?? ctx.config.maxRounds,
253
+ maxTokens: ctx.effectiveMaxTokens?.() ?? 0,
254
+ maxDurationMs: ctx.effectiveMaxDurationMs?.() ?? 0,
253
255
  onEvent(event) {
254
256
  if (event.type === 'text') {
255
257
  full = event.full;
@@ -272,10 +274,14 @@ export function startTelegramChannel(
272
274
  const durationMs = Date.now() - startedAt;
273
275
  let body = result.content?.trim() || full.trim();
274
276
  if (!body) {
275
- if (result.stopReason === 'max_rounds') {
277
+ if (result.stopReason === 'no_progress') {
278
+ body = '⚠ El agente se quedó repitiendo las mismas herramientas sin avanzar.';
279
+ } else if (result.stopReason === 'budget') {
280
+ body = '⚠ El agente alcanzó el presupuesto de tokens/tiempo sin respuesta final.';
281
+ } else if (result.stopReason === 'max_rounds') {
276
282
  body = `⚠ El agente alcanzó el máximo de rondas (${result.rounds}) sin respuesta final.${
277
283
  toolCalls ? ` Hizo ${toolCalls} herramientas.` : ''
278
- } Pedile que continúe o subí maxRounds.`;
284
+ }`;
279
285
  } else if (toolCalls) {
280
286
  body = '✓ El agente terminó sus herramientas, pero no devolvió un mensaje final.';
281
287
  } else {
@@ -250,7 +250,9 @@ export function registerChat(route, ctx) {
250
250
  tools: definitions,
251
251
  execute,
252
252
  signal: ctx.runs.signal(sessionId),
253
- maxRounds: Number(body.maxRounds) || ctx.config.maxRounds || MAX_TOOL_ROUNDS,
253
+ maxRounds: Number(body.maxRounds) || ctx.effectiveMaxRounds?.() || MAX_TOOL_ROUNDS,
254
+ maxTokens: Number(body.maxTokens) || ctx.effectiveMaxTokens?.() || 0,
255
+ maxDurationMs: Number(body.maxDurationMs) || ctx.effectiveMaxDurationMs?.() || 0,
254
256
  stallWarnMs: body.stallWarnMs ?? ctx.config.stallWarnMs,
255
257
  contextTokens: Number(body.contextTokens) || ctx.config.contextTokens || 0,
256
258
  onEvent(event) {
@@ -281,6 +283,9 @@ export function registerChat(route, ctx) {
281
283
  const { preview, truncated } = previewToolResult(event.name, event.result);
282
284
  const failed = /^Error|^Permiso denegado/i.test(String(event.result));
283
285
  ctx.runs.emit(sessionId, 'tool_result', { id: event.id, name: event.name, preview, truncated, failed });
286
+ } else if (event.type === 'repeat') {
287
+ partials.flush();
288
+ ctx.runs.emit(sessionId, 'repeat', { name: event.name, count: event.count });
284
289
  }
285
290
  },
286
291
  });
@@ -303,6 +308,8 @@ export function registerChat(route, ctx) {
303
308
  content: result.content,
304
309
  rounds: result.rounds,
305
310
  stopReason: result.stopReason,
311
+ wrappedUp: result.wrappedUp === true,
312
+ continuable: result.stopReason !== 'end_turn',
306
313
  tokens: result.usage ?? null,
307
314
  toolCalls,
308
315
  durationMs,
@@ -4,6 +4,7 @@ import {
4
4
  getMcpRegistryServer,
5
5
  registryServerToConfig,
6
6
  normalizeMcpConfig,
7
+ maskEnv,
7
8
  } from '../lib/core.js';
8
9
  import { readJson, sendJson, sendError } from '../lib/http.js';
9
10
 
@@ -27,11 +28,26 @@ function listServers(ctx) {
27
28
  command: server.command ?? null,
28
29
  args: Array.isArray(server.args) ? server.args : [],
29
30
  url: server.url ?? null,
30
- env: server.env ?? null,
31
+ inheritEnv: server.inheritEnv === true,
32
+ env: maskEnv(server.env),
31
33
  }));
32
34
  }
33
35
 
34
- function parseServer(body, id) {
36
+ /**
37
+ * Conserva los valores secretos que la API devuelve enmascarados (`••••…`):
38
+ * un cliente que solo cambia `enabled` o un campo no debe pisar el secreto.
39
+ */
40
+ function mergeEnv(existing, incoming) {
41
+ const stored = existing?.config?.env ?? existing?.env ?? {};
42
+ const result = {};
43
+ for (const [key, value] of Object.entries(incoming ?? {})) {
44
+ const text = String(value ?? '');
45
+ result[key] = text.startsWith('••••') && stored[key] !== undefined ? stored[key] : value;
46
+ }
47
+ return result;
48
+ }
49
+
50
+ function parseServer(body, id, existing = null) {
35
51
  const transport = body.transport === 'http' ? 'http' : 'stdio';
36
52
  const command = String(body.command ?? '').trim();
37
53
  const url = String(body.url ?? '').trim();
@@ -49,7 +65,14 @@ function parseServer(body, id) {
49
65
  command: transport === 'stdio' ? command : null,
50
66
  url: transport === 'http' ? url : null,
51
67
  enabled: body.enabled !== false,
52
- config: { args, env: body.env ?? null },
68
+ config: {
69
+ args,
70
+ env: mergeEnv(existing, body.env),
71
+ inheritEnv:
72
+ body.inheritEnv === undefined
73
+ ? Boolean(existing?.config?.inheritEnv ?? existing?.inheritEnv)
74
+ : body.inheritEnv === true,
75
+ },
53
76
  };
54
77
  }
55
78
 
@@ -128,7 +151,7 @@ export function registerMcp(route, ctx) {
128
151
  command: record.command ?? null,
129
152
  url: record.url ?? null,
130
153
  enabled: record.enabled !== false,
131
- config: { args: record.args ?? [], env: record.env ?? null },
154
+ config: { args: record.args ?? [], env: record.env ?? null, inheritEnv: record.inheritEnv === true },
132
155
  });
133
156
  await ctx.reloadMcp();
134
157
  sendJson(res, 200, {
@@ -142,9 +165,10 @@ export function registerMcp(route, ctx) {
142
165
  const id = String(params.id);
143
166
  if (!ID_PATTERN.test(id)) return sendError(res, 400, 'Id de servidor inválido.');
144
167
  const body = await readJson(req);
168
+ const existing = ctx.mergedMcpServers().find((s) => s.id === id) ?? null;
145
169
  let record;
146
170
  try {
147
- record = parseServer(body, id);
171
+ record = parseServer(body, id, existing);
148
172
  } catch (error) {
149
173
  return sendError(res, 400, error.message);
150
174
  }
@@ -68,15 +68,17 @@ export function registerProviders(route, ctx) {
68
68
  });
69
69
  });
70
70
 
71
+ const settingsView = () => ({
72
+ provider: ctx.effectiveProviderId(),
73
+ model: ctx.effectiveModel(),
74
+ mode: ctx.effectiveMode(),
75
+ maxRounds: ctx.effectiveMaxRounds(),
76
+ maxTokens: ctx.effectiveMaxTokens(),
77
+ maxDurationMs: ctx.effectiveMaxDurationMs(),
78
+ });
79
+
71
80
  route('GET', '/api/settings', ({ res }) => {
72
- sendJson(res, 200, {
73
- settings: {
74
- provider: ctx.effectiveProviderId(),
75
- model: ctx.effectiveModel(),
76
- mode: ctx.effectiveMode(),
77
- maxRounds: ctx.config.maxRounds,
78
- },
79
- });
81
+ sendJson(res, 200, { settings: settingsView() });
80
82
  });
81
83
 
82
84
  route('PUT', '/api/settings', async ({ req, res }) => {
@@ -84,13 +86,12 @@ export function registerProviders(route, ctx) {
84
86
  if (body.provider !== undefined) ctx.store.setSetting('activeProvider', String(body.provider) || null);
85
87
  if (body.model !== undefined) ctx.store.setSetting('activeModel', String(body.model) || null);
86
88
  if (body.mode !== undefined) ctx.store.setSetting('activeMode', resolveMode(String(body.mode)).id);
87
- sendJson(res, 200, {
88
- settings: {
89
- provider: ctx.effectiveProviderId(),
90
- model: ctx.effectiveModel(),
91
- mode: ctx.effectiveMode(),
92
- },
93
- });
89
+ for (const key of ['maxRounds', 'maxTokens', 'maxDurationMs']) {
90
+ if (body[key] === undefined) continue;
91
+ const value = Number(body[key]);
92
+ ctx.store.setSetting(key, Number.isFinite(value) && value > 0 ? Math.round(value) : 0);
93
+ }
94
+ sendJson(res, 200, { settings: settingsView() });
94
95
  });
95
96
 
96
97
  route('PUT', '/api/providers/:id', async ({ req, res, params }) => {
@@ -92,6 +92,7 @@ export async function createTamnoraServer({
92
92
  args: Array.isArray(row.config?.args) ? row.config.args : [],
93
93
  url: row.url ?? undefined,
94
94
  env: row.config?.env ?? undefined,
95
+ inheritEnv: row.config?.inheritEnv === true,
95
96
  enabled: row.enabled !== false,
96
97
  source: 'store',
97
98
  }));
@@ -137,6 +138,15 @@ export async function createTamnoraServer({
137
138
  const effectiveProviderId = () => store.getSetting('activeProvider') || config.provider;
138
139
  const effectiveModel = () => store.getSetting('activeModel') || config.model;
139
140
  const effectiveMode = () => store.getSetting('activeMode') || 'agent';
141
+ const settingNumber = (key, fallback) => {
142
+ const value = store.getSetting(key);
143
+ if (value === null || value === undefined || value === '') return fallback ?? 0;
144
+ const parsed = Number(value);
145
+ return Number.isFinite(parsed) ? parsed : fallback ?? 0;
146
+ };
147
+ const effectiveMaxRounds = () => settingNumber('maxRounds', config.maxRounds);
148
+ const effectiveMaxTokens = () => settingNumber('maxTokens', config.maxTokens);
149
+ const effectiveMaxDurationMs = () => settingNumber('maxDurationMs', config.maxDurationMs);
140
150
 
141
151
  const ctx = {
142
152
  cwd,
@@ -152,6 +162,9 @@ export async function createTamnoraServer({
152
162
  effectiveProviderId,
153
163
  effectiveModel,
154
164
  effectiveMode,
165
+ effectiveMaxRounds,
166
+ effectiveMaxTokens,
167
+ effectiveMaxDurationMs,
155
168
  providerSettings,
156
169
  customProviderDefs,
157
170
  apiKeyFor: (id) => resolveApiKey(providerSettings(id).apiKey, process.env),
@@ -43,6 +43,17 @@ test('serves health, providers and the console', async () => {
43
43
 
44
44
  const html = await (await fetch(`${base}/`)).text();
45
45
  assert.match(html, /Tamnora Console/);
46
+ for (const hook of [
47
+ 'data-panel="agente"',
48
+ 'data-panel="sesiones"',
49
+ 'id="settings-controls-slot"',
50
+ 'id="slash-menu"',
51
+ 'id="statusbar"',
52
+ ]) {
53
+ assert.ok(html.includes(hook), `falta ${hook}`);
54
+ }
55
+ // Los controles de agente se declaran una sola vez (se mueven entre slots).
56
+ assert.equal((html.match(/id="mode"/g) ?? []).length, 1);
46
57
 
47
58
  const missing = await fetch(`${base}/api/nope`);
48
59
  assert.equal(missing.status, 404);
@@ -264,7 +275,8 @@ test('done reports max_rounds and the tool count when the model never answers',
264
275
  test('completionFooter resume el cierre del turno', () => {
265
276
  assert.equal(completionFooter('end_turn', 1, 0, 0), '✓ terminado · 1 ronda');
266
277
  assert.equal(completionFooter('end_turn', 3, 5, 1200), '✓ terminado · 3 rondas · 5 herramientas · 1.2s');
267
- assert.match(completionFooter('max_rounds', 2, 2, 300), /^⚠ máximo de rondas · 2 rondas · 2 herramientas · 300ms$/);
278
+ assert.match(completionFooter('max_rounds', 2, 2, 300), /^⚠ corte de seguridad · 2 rondas · 2 herramientas · 300ms$/);
279
+ assert.match(completionFooter('no_progress', 2, 2, 300), /^⚠ corte de seguridad/);
268
280
  });
269
281
 
270
282
  test('configures Telegram from the console (masked token)', async () => {
@@ -343,6 +355,69 @@ test('manages MCP servers from the console', async () => {
343
355
  }
344
356
  });
345
357
 
358
+ test('MCP: el env se enmascara y no se pisa al guardar sin cambios', async () => {
359
+ const { instance, base } = await startServer();
360
+ const json = { 'Content-Type': 'application/json' };
361
+ try {
362
+ await fetch(`${base}/api/mcp/servers/db`, {
363
+ method: 'PUT',
364
+ headers: json,
365
+ body: JSON.stringify({
366
+ name: 'DB',
367
+ transport: 'stdio',
368
+ command: 'node',
369
+ args: ['db.mjs'],
370
+ env: { MYSQL_HOST: 'localhost', MYSQL_PASSWORD: 'supersecret' },
371
+ }),
372
+ });
373
+
374
+ let data = await (await fetch(`${base}/api/mcp`)).json();
375
+ const db = data.servers.find((s) => s.id === 'db');
376
+ assert.equal(db.env.MYSQL_HOST, 'localhost');
377
+ assert.equal(db.env.MYSQL_PASSWORD, '••••cret');
378
+
379
+ // Un toggle manda de vuelta el env enmascarado: no debe pisar el secreto.
380
+ await fetch(`${base}/api/mcp/servers/db`, {
381
+ method: 'PUT',
382
+ headers: json,
383
+ body: JSON.stringify({
384
+ name: db.name,
385
+ transport: 'stdio',
386
+ command: db.command,
387
+ args: db.args,
388
+ env: db.env,
389
+ enabled: false,
390
+ }),
391
+ });
392
+ const stored = instance.store.listMcpServers().find((s) => s.id === 'db');
393
+ assert.equal(stored.config.env.MYSQL_PASSWORD, 'supersecret');
394
+ assert.equal(stored.enabled, false);
395
+ } finally {
396
+ await instance.close();
397
+ }
398
+ });
399
+
400
+ test('guarda los límites del agente desde Ajustes', async () => {
401
+ const { instance, base } = await startServer();
402
+ const json = { 'Content-Type': 'application/json' };
403
+ try {
404
+ const res = await fetch(`${base}/api/settings`, {
405
+ method: 'PUT',
406
+ headers: json,
407
+ body: JSON.stringify({ maxRounds: 20, maxTokens: 50000, maxDurationMs: 600000 }),
408
+ });
409
+ const data = await res.json();
410
+ assert.equal(data.settings.maxRounds, 20);
411
+ assert.equal(data.settings.maxTokens, 50000);
412
+ assert.equal(data.settings.maxDurationMs, 600000);
413
+
414
+ const again = await (await fetch(`${base}/api/settings`)).json();
415
+ assert.equal(again.settings.maxRounds, 20);
416
+ } finally {
417
+ await instance.close();
418
+ }
419
+ });
420
+
346
421
  test('imports an MCP server from a JSON snippet', async () => {
347
422
  const { instance, base } = await startServer();
348
423
  const json = { 'Content-Type': 'application/json' };
@@ -2,7 +2,9 @@
2
2
  "provider": "openrouter",
3
3
  "model": "meta-llama/llama-3.3-8b-instruct:free",
4
4
  "instructions": ["AGENTS.md"],
5
- "maxRounds": 8,
5
+ "maxRounds": 0,
6
+ "maxTokens": 0,
7
+ "maxDurationMs": 0,
6
8
  "tools": [],
7
9
  "permissions": {},
8
10
  "mcp": [
@@ -14,6 +16,20 @@
14
16
  "args": ["harness/scripts/mcp-echo-server.mjs"],
15
17
  "enabled": true
16
18
  },
19
+ {
20
+ "id": "database",
21
+ "name": "DB (ejemplo: credenciales por env, no por parametros)",
22
+ "type": "stdio",
23
+ "command": "node",
24
+ "args": ["ruta/al/servidor-mcp-db.mjs"],
25
+ "env": {
26
+ "MYSQL_HOST": "localhost",
27
+ "MYSQL_USER": "root",
28
+ "MYSQL_PASSWORD": "env:MYSQL_PASSWORD",
29
+ "MYSQL_DATABASE": "app"
30
+ },
31
+ "enabled": false
32
+ },
17
33
  {
18
34
  "id": "playwright",
19
35
  "name": "Playwright (navegador headless, para webs con JS)",