@danieltmn/tamnora 0.6.2 → 0.7.1

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 +49 -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 +368 -41
  22. package/harness/packages/server/public/index.html +102 -26
  23. package/harness/packages/server/public/markdown.js +43 -0
  24. package/harness/packages/server/public/styles.css +171 -21
  25. package/harness/packages/server/src/lib/agentTools.js +9 -3
  26. package/harness/packages/server/src/lib/channels/telegram.js +11 -5
  27. package/harness/packages/server/src/routes/chat.js +8 -1
  28. package/harness/packages/server/src/routes/mcp.js +29 -5
  29. package/harness/packages/server/src/routes/providers.js +16 -15
  30. package/harness/packages/server/src/server.js +13 -0
  31. package/harness/packages/server/test/markdown.test.js +33 -0
  32. package/harness/packages/server/test/server.test.js +77 -1
  33. package/harness/tamnora.example.json +17 -1
  34. package/package.json +1 -1
@@ -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),
@@ -31,6 +31,39 @@ test('only allows http(s) links', () => {
31
31
  assert.ok(!renderMarkdown('[x](javascript:alert(1))').includes('<a '));
32
32
  });
33
33
 
34
+ test('renders pipe tables with alignment and stops at the next block', () => {
35
+ const md = [
36
+ '| Archivo | Qué es | Centro | Derecha |',
37
+ '| --- | :-- | :-: | --: |',
38
+ '| index.html | Landing | x | y |',
39
+ '| `a.js` | **script** | z | w |',
40
+ '',
41
+ 'sigue el texto',
42
+ ].join('\n');
43
+ const html = renderMarkdown(md);
44
+ assert.match(html, /<div class="table-wrap"><table>/);
45
+ assert.match(html, /<th>Archivo<\/th>/);
46
+ assert.match(html, /<th style="text-align:center">Centro<\/th>/);
47
+ assert.match(html, /<th style="text-align:right">Derecha<\/th>/);
48
+ assert.match(html, /<td style="text-align:left">Landing<\/td>/);
49
+ assert.match(html, /<td><code>a\.js<\/code><\/td>/);
50
+ assert.match(html, /<td style="text-align:left"><strong>script<\/strong><\/td>/);
51
+ assert.match(html, /<\/tbody><\/table><\/div>\n<p>sigue el texto<\/p>/);
52
+ });
53
+
54
+ test('table cells are escaped and escaped pipes stay in one cell', () => {
55
+ const html = renderMarkdown('| col | col2 |\n| --- | --- |\n| a \\| b | <script> |');
56
+ assert.match(html, /<td>a \| b<\/td>/);
57
+ assert.ok(!html.includes('<script>'));
58
+ assert.match(html, /&lt;script&gt;/);
59
+ });
60
+
61
+ test('a lone pipe line without a delimiter row stays a paragraph', () => {
62
+ const html = renderMarkdown('foo | bar');
63
+ assert.ok(!html.includes('<table'));
64
+ assert.equal(html, '<p>foo | bar</p>');
65
+ });
66
+
34
67
  test('highlight escapes code content', () => {
35
68
  assert.equal(highlight('a < b', 'bash'), 'a &lt; b');
36
69
  });
@@ -43,6 +43,18 @@ 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
+ 'id="sb-activity"',
53
+ ]) {
54
+ assert.ok(html.includes(hook), `falta ${hook}`);
55
+ }
56
+ // Los controles de agente se declaran una sola vez (se mueven entre slots).
57
+ assert.equal((html.match(/id="mode"/g) ?? []).length, 1);
46
58
 
47
59
  const missing = await fetch(`${base}/api/nope`);
48
60
  assert.equal(missing.status, 404);
@@ -264,7 +276,8 @@ test('done reports max_rounds and the tool count when the model never answers',
264
276
  test('completionFooter resume el cierre del turno', () => {
265
277
  assert.equal(completionFooter('end_turn', 1, 0, 0), '✓ terminado · 1 ronda');
266
278
  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$/);
279
+ assert.match(completionFooter('max_rounds', 2, 2, 300), /^⚠ corte de seguridad · 2 rondas · 2 herramientas · 300ms$/);
280
+ assert.match(completionFooter('no_progress', 2, 2, 300), /^⚠ corte de seguridad/);
268
281
  });
269
282
 
270
283
  test('configures Telegram from the console (masked token)', async () => {
@@ -343,6 +356,69 @@ test('manages MCP servers from the console', async () => {
343
356
  }
344
357
  });
345
358
 
359
+ test('MCP: el env se enmascara y no se pisa al guardar sin cambios', async () => {
360
+ const { instance, base } = await startServer();
361
+ const json = { 'Content-Type': 'application/json' };
362
+ try {
363
+ await fetch(`${base}/api/mcp/servers/db`, {
364
+ method: 'PUT',
365
+ headers: json,
366
+ body: JSON.stringify({
367
+ name: 'DB',
368
+ transport: 'stdio',
369
+ command: 'node',
370
+ args: ['db.mjs'],
371
+ env: { MYSQL_HOST: 'localhost', MYSQL_PASSWORD: 'supersecret' },
372
+ }),
373
+ });
374
+
375
+ let data = await (await fetch(`${base}/api/mcp`)).json();
376
+ const db = data.servers.find((s) => s.id === 'db');
377
+ assert.equal(db.env.MYSQL_HOST, 'localhost');
378
+ assert.equal(db.env.MYSQL_PASSWORD, '••••cret');
379
+
380
+ // Un toggle manda de vuelta el env enmascarado: no debe pisar el secreto.
381
+ await fetch(`${base}/api/mcp/servers/db`, {
382
+ method: 'PUT',
383
+ headers: json,
384
+ body: JSON.stringify({
385
+ name: db.name,
386
+ transport: 'stdio',
387
+ command: db.command,
388
+ args: db.args,
389
+ env: db.env,
390
+ enabled: false,
391
+ }),
392
+ });
393
+ const stored = instance.store.listMcpServers().find((s) => s.id === 'db');
394
+ assert.equal(stored.config.env.MYSQL_PASSWORD, 'supersecret');
395
+ assert.equal(stored.enabled, false);
396
+ } finally {
397
+ await instance.close();
398
+ }
399
+ });
400
+
401
+ test('guarda los límites del agente desde Ajustes', async () => {
402
+ const { instance, base } = await startServer();
403
+ const json = { 'Content-Type': 'application/json' };
404
+ try {
405
+ const res = await fetch(`${base}/api/settings`, {
406
+ method: 'PUT',
407
+ headers: json,
408
+ body: JSON.stringify({ maxRounds: 20, maxTokens: 50000, maxDurationMs: 600000 }),
409
+ });
410
+ const data = await res.json();
411
+ assert.equal(data.settings.maxRounds, 20);
412
+ assert.equal(data.settings.maxTokens, 50000);
413
+ assert.equal(data.settings.maxDurationMs, 600000);
414
+
415
+ const again = await (await fetch(`${base}/api/settings`)).json();
416
+ assert.equal(again.settings.maxRounds, 20);
417
+ } finally {
418
+ await instance.close();
419
+ }
420
+ });
421
+
346
422
  test('imports an MCP server from a JSON snippet', async () => {
347
423
  const { instance, base } = await startServer();
348
424
  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.2",
3
+ "version": "0.7.1",
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",