@danieltmn/tamnora 0.6.1 → 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 (34) hide show
  1. package/CHANGELOG.md +29 -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 +222 -18
  22. package/harness/packages/server/public/index.html +93 -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/index.js +11 -4
  26. package/harness/packages/server/src/lib/channels/telegram.js +28 -10
  27. package/harness/packages/server/src/routes/channels.js +1 -0
  28. package/harness/packages/server/src/routes/chat.js +8 -1
  29. package/harness/packages/server/src/routes/mcp.js +29 -5
  30. package/harness/packages/server/src/routes/providers.js +16 -15
  31. package/harness/packages/server/src/server.js +13 -0
  32. package/harness/packages/server/test/server.test.js +78 -2
  33. package/harness/tamnora.example.json +17 -1
  34. package/package.json +1 -1
@@ -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}).`;
@@ -36,6 +36,7 @@ export function createChannelsManager(ctx, { log = () => {}, enabled = true } =
36
36
  if (cfg.token) tg.token = cfg.token;
37
37
  if (Array.isArray(cfg.allowedChatIds) && cfg.allowedChatIds.length) tg.allowedChatIds = cfg.allowedChatIds;
38
38
  if (cfg.transcribeModel) tg.transcribeModel = cfg.transcribeModel;
39
+ if (cfg.transcribeProvider) tg.transcribeProvider = cfg.transcribeProvider;
39
40
  return tg;
40
41
  };
41
42
 
@@ -43,7 +44,12 @@ export function createChannelsManager(ctx, { log = () => {}, enabled = true } =
43
44
  const tg = readConfig();
44
45
  const token = tg.token || process.env.TAMNORA_TELEGRAM_TOKEN || '';
45
46
  const allowedChatIds = parseAllowed(tg.allowedChatIds ?? process.env.TAMNORA_TELEGRAM_CHAT_IDS ?? []);
46
- return { token, allowedChatIds, transcribeModel: tg.transcribeModel };
47
+ return {
48
+ token,
49
+ allowedChatIds,
50
+ transcribeModel: tg.transcribeModel,
51
+ transcribeProvider: tg.transcribeProvider,
52
+ };
47
53
  }
48
54
 
49
55
  function stop() {
@@ -56,9 +62,9 @@ export function createChannelsManager(ctx, { log = () => {}, enabled = true } =
56
62
  function load() {
57
63
  stop();
58
64
  if (!enabled) return { enabled: false, channels: [] };
59
- const { token, allowedChatIds, transcribeModel } = effective();
65
+ const { token, allowedChatIds, transcribeModel, transcribeProvider } = effective();
60
66
  const channels = [];
61
- const telegram = startTelegramChannel(ctx, { token, allowedChatIds, transcribeModel, log });
67
+ const telegram = startTelegramChannel(ctx, { token, allowedChatIds, transcribeModel, transcribeProvider, log });
62
68
  if (telegram) channels.push(telegram);
63
69
  const unsubscribe = ctx.runs?.subscribeAll?.((sessionId, event) => {
64
70
  for (const channel of channels) {
@@ -75,12 +81,13 @@ export function createChannelsManager(ctx, { log = () => {}, enabled = true } =
75
81
  }
76
82
 
77
83
  function status() {
78
- const { token, allowedChatIds, transcribeModel } = effective();
84
+ const { token, allowedChatIds, transcribeModel, transcribeProvider } = effective();
79
85
  return {
80
86
  tokenSet: Boolean(token),
81
87
  tokenHint: tokenHint(token),
82
88
  allowedChatIds,
83
89
  transcribeModel: transcribeModel || 'whisper-1',
90
+ transcribeProvider: transcribeProvider || '',
84
91
  running: Boolean(active && active.channels.length),
85
92
  };
86
93
  }
@@ -75,14 +75,17 @@ 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
  /**
83
83
  * Arranca el canal de Telegram. Devuelve `{ stop, notify }` o `null` sin token.
84
84
  */
85
- export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcribeModel = 'whisper-1', log = () => {} } = {}) {
85
+ export function startTelegramChannel(
86
+ ctx,
87
+ { token, allowedChatIds = [], transcribeModel = 'whisper-1', transcribeProvider = '', log = () => {} } = {}
88
+ ) {
86
89
  if (!token) return null;
87
90
  const allowed = new Set(allowedChatIds.map(String));
88
91
  const controllers = new Map();
@@ -130,10 +133,11 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
130
133
  }
131
134
 
132
135
  async function transcribe(buffer, filename) {
133
- const providerId = ctx.effectiveProviderId();
134
- const baseUrl = (ctx.providerSettings(providerId).baseUrl || '').replace(/\/+$/, '');
136
+ const providerId = transcribeProvider || ctx.effectiveProviderId();
137
+ const settings = ctx.providerSettings(providerId) ?? {};
138
+ const baseUrl = (settings.baseUrl || '').replace(/\/+$/, '');
135
139
  const apiKey = ctx.apiKeyFor(providerId);
136
- if (!baseUrl) throw new Error('el proveedor no tiene baseUrl para transcribir');
140
+ if (!baseUrl) throw new Error(`el proveedor "${providerId}" no tiene baseUrl para transcribir`);
137
141
  const form = new FormData();
138
142
  form.append('file', new Blob([buffer]), filename);
139
143
  form.append('model', transcribeModel);
@@ -142,7 +146,15 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
142
146
  headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
143
147
  body: form,
144
148
  });
145
- if (!res.ok) throw new Error(`transcripción falló (HTTP ${res.status})`);
149
+ if (!res.ok) {
150
+ const hint =
151
+ res.status === 404
152
+ ? ` — "${providerId}" no expone /audio/transcriptions`
153
+ : '';
154
+ throw new Error(
155
+ `transcripción falló (HTTP ${res.status})${hint}. Elegí un proveedor con voz (OpenAI, Groq…) en Ajustes → Telegram.`
156
+ );
157
+ }
146
158
  const data = await res.json();
147
159
  return String(data.text ?? '').trim();
148
160
  }
@@ -237,7 +249,9 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
237
249
  tools: definitions,
238
250
  execute,
239
251
  signal: controller.signal,
240
- maxRounds: ctx.config.maxRounds,
252
+ maxRounds: ctx.effectiveMaxRounds?.() ?? ctx.config.maxRounds,
253
+ maxTokens: ctx.effectiveMaxTokens?.() ?? 0,
254
+ maxDurationMs: ctx.effectiveMaxDurationMs?.() ?? 0,
241
255
  onEvent(event) {
242
256
  if (event.type === 'text') {
243
257
  full = event.full;
@@ -260,10 +274,14 @@ export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcri
260
274
  const durationMs = Date.now() - startedAt;
261
275
  let body = result.content?.trim() || full.trim();
262
276
  if (!body) {
263
- 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') {
264
282
  body = `⚠ El agente alcanzó el máximo de rondas (${result.rounds}) sin respuesta final.${
265
283
  toolCalls ? ` Hizo ${toolCalls} herramientas.` : ''
266
- } Pedile que continúe o subí maxRounds.`;
284
+ }`;
267
285
  } else if (toolCalls) {
268
286
  body = '✓ El agente terminó sus herramientas, pero no devolvió un mensaje final.';
269
287
  } else {
@@ -30,6 +30,7 @@ export function registerChannels(route, ctx) {
30
30
  }
31
31
  if ('allowedChatIds' in body) next.allowedChatIds = sanitizeAllowed(body.allowedChatIds);
32
32
  if ('transcribeModel' in body) next.transcribeModel = String(body.transcribeModel ?? '').trim() || 'whisper-1';
33
+ if ('transcribeProvider' in body) next.transcribeProvider = String(body.transcribeProvider ?? '').trim();
33
34
 
34
35
  app.telegram = next;
35
36
  writeApp(app);
@@ -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 () => {
@@ -287,10 +299,11 @@ test('configures Telegram from the console (masked token)', async () => {
287
299
  res = await fetch(`${base}/api/channels/telegram`, {
288
300
  method: 'PUT',
289
301
  headers: json,
290
- body: JSON.stringify({ token: '123456:ABCDEFghij' }),
302
+ body: JSON.stringify({ token: '123456:ABCDEFghij', transcribeProvider: 'openai' }),
291
303
  });
292
304
  data = await res.json();
293
305
  assert.equal(data.telegram.tokenSet, true);
306
+ assert.equal(data.telegram.transcribeProvider, 'openai');
294
307
  assert.match(data.telegram.tokenHint, /ghij$/);
295
308
  assert.doesNotMatch(JSON.stringify(data), /ABCDEF/);
296
309
 
@@ -342,6 +355,69 @@ test('manages MCP servers from the console', async () => {
342
355
  }
343
356
  });
344
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
+
345
421
  test('imports an MCP server from a JSON snippet', async () => {
346
422
  const { instance, base } = await startServer();
347
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)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",