@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.
- package/CHANGELOG.md +49 -0
- package/SECURITY.md +6 -0
- package/harness/packages/cli/src/commands/repl.js +2 -0
- package/harness/packages/cli/src/commands/run.js +2 -0
- package/harness/packages/cli/src/commands/tui.js +2 -0
- package/harness/packages/cli/src/lib/runtime.js +4 -0
- package/harness/packages/cli/src/tui/app.js +4 -0
- package/harness/packages/core/src/agent/runAgent.js +131 -47
- package/harness/packages/core/src/config/index.js +3 -1
- package/harness/packages/core/src/config/secrets.js +36 -0
- package/harness/packages/core/src/eval/runTask.js +2 -11
- package/harness/packages/core/src/index.js +13 -1
- package/harness/packages/core/src/mcp/common.js +15 -11
- package/harness/packages/core/src/mcp/index.js +1 -1
- package/harness/packages/core/src/mcp/registry.js +1 -0
- package/harness/packages/core/src/mcp/stdio.js +41 -2
- package/harness/packages/core/src/redact.js +104 -0
- package/harness/packages/core/test/agent.test.js +93 -0
- package/harness/packages/core/test/mcp.test.js +67 -0
- package/harness/packages/core/test/redact.test.js +45 -0
- package/harness/packages/server/public/app.js +368 -41
- package/harness/packages/server/public/index.html +102 -26
- package/harness/packages/server/public/markdown.js +43 -0
- package/harness/packages/server/public/styles.css +171 -21
- package/harness/packages/server/src/lib/agentTools.js +9 -3
- package/harness/packages/server/src/lib/channels/telegram.js +11 -5
- package/harness/packages/server/src/routes/chat.js +8 -1
- package/harness/packages/server/src/routes/mcp.js +29 -5
- package/harness/packages/server/src/routes/providers.js +16 -15
- package/harness/packages/server/src/server.js +13 -0
- package/harness/packages/server/test/markdown.test.js +33 -0
- package/harness/packages/server/test/server.test.js +77 -1
- package/harness/tamnora.example.json +17 -1
- 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.
|
|
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
|
-
|
|
31
|
+
inheritEnv: server.inheritEnv === true,
|
|
32
|
+
env: maskEnv(server.env),
|
|
31
33
|
}));
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
|
|
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: {
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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, /<script>/);
|
|
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 < 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), /^⚠
|
|
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":
|
|
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