@danieltmn/tamnora 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +37 -3
- package/SECURITY.md +8 -0
- package/harness/packages/cli/src/commands/mcp.js +31 -4
- package/harness/packages/cli/src/commands/repl.js +7 -2
- package/harness/packages/cli/src/commands/run.js +5 -2
- package/harness/packages/cli/src/commands/tui.js +9 -2
- package/harness/packages/cli/src/lib/runtime.js +23 -1
- package/harness/packages/cli/test/tunnel.test.js +13 -1
- package/harness/packages/core/src/agent/modes.js +3 -0
- package/harness/packages/core/src/config/index.js +3 -1
- package/harness/packages/core/src/index.js +15 -1
- package/harness/packages/core/src/mcp/catalog.js +79 -0
- package/harness/packages/core/src/mcp/index.js +14 -2
- package/harness/packages/core/src/mcp/registry.js +29 -1
- package/harness/packages/core/src/redact.js +25 -0
- package/harness/packages/core/src/skills/catalog.js +78 -0
- package/harness/packages/core/src/skills/remote.js +51 -0
- package/harness/packages/core/src/tools/executors.js +93 -2
- package/harness/packages/core/src/tools/index.js +27 -6
- package/harness/packages/core/src/tools/labels.js +2 -0
- package/harness/packages/core/src/tools/mcp.js +91 -0
- package/harness/packages/core/src/tools/permissions.js +53 -8
- package/harness/packages/core/src/tools/registry.js +14 -7
- package/harness/packages/core/src/tools/schema.js +86 -0
- package/harness/packages/core/src/tts/edge.js +28 -7
- package/harness/packages/core/src/tunnel.js +51 -5
- package/harness/packages/core/test/compact.test.js +2 -0
- package/harness/packages/core/test/labels.test.js +1 -0
- package/harness/packages/core/test/mcp-registry.test.js +37 -1
- package/harness/packages/core/test/mcp-routing.test.js +115 -0
- package/harness/packages/core/test/redact.test.js +22 -0
- package/harness/packages/core/test/skills.test.js +11 -0
- package/harness/packages/core/test/tools.test.js +97 -3
- package/harness/packages/server/public/app.js +501 -23
- package/harness/packages/server/public/index.html +41 -2
- package/harness/packages/server/public/styles.css +99 -3
- package/harness/packages/server/src/lib/agentTools.js +45 -3
- package/harness/packages/server/src/lib/channels/telegram.js +2 -0
- package/harness/packages/server/src/lib/prompt.js +4 -1
- package/harness/packages/server/src/lib/questions.js +72 -0
- package/harness/packages/server/src/lib/scheduler.js +12 -1
- package/harness/packages/server/src/lib/tasks.js +3 -1
- package/harness/packages/server/src/routes/chat.js +31 -2
- package/harness/packages/server/src/routes/mcp.js +54 -2
- package/harness/packages/server/src/routes/preview.js +49 -28
- package/harness/packages/server/src/routes/providers.js +12 -0
- package/harness/packages/server/src/routes/sessions.js +3 -1
- package/harness/packages/server/src/routes/skills.js +48 -1
- package/harness/packages/server/src/routes/state.js +8 -1
- package/harness/packages/server/src/server.js +37 -1
- package/harness/packages/server/test/preview.test.js +111 -0
- package/harness/packages/server/test/server.test.js +186 -19
- package/harness/packages/server/test/telegram-features.test.js +2 -0
- package/harness/packages/server/test/telegram-voz.test.js +2 -0
- package/harness/packages/server/test/telegram.test.js +2 -0
- package/harness/tamnora.example.json +3 -1
- package/package.json +1 -1
|
@@ -30,7 +30,11 @@ export const MAX_TEXTO = 2000;
|
|
|
30
30
|
/** Voz por defecto del harness. */
|
|
31
31
|
export const VOZ_POR_DEFECTO = 'es-AR-ElenaNeural';
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// Desde octubre de 2026 el host viejo (`speech.platform.bing.com`) responde 403
|
|
34
|
+
// a la sintesis. Edge 155 sintetiza por `api.msedgeservices.com`, que acepta el
|
|
35
|
+
// mismo token legacy pero como `Ocp-Apim-Subscription-Key`. La lista de voces
|
|
36
|
+
// sigue sirviendose desde el host viejo.
|
|
37
|
+
const URL_SINTESIS = 'wss://api.msedgeservices.com/tts/cognitiveservices/websocket/v1';
|
|
34
38
|
const URL_VOCES = `https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list?trustedclienttoken=${TRUSTED_CLIENT_TOKEN}`;
|
|
35
39
|
const ORIGEN_EDGE = 'chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold';
|
|
36
40
|
const SEGUNDOS_EPOCA_WINDOWS = 11644473600;
|
|
@@ -80,12 +84,27 @@ export function generarSsml({ voz, lang, rate, pitch, volume, texto }) {
|
|
|
80
84
|
|
|
81
85
|
function urlSintesis() {
|
|
82
86
|
const parametros = new URLSearchParams({
|
|
83
|
-
|
|
87
|
+
'Ocp-Apim-Subscription-Key': TRUSTED_CLIENT_TOKEN,
|
|
84
88
|
'Sec-MS-GEC': generarSecMsGec(),
|
|
85
89
|
'Sec-MS-GEC-Version': `1-${EDGE_VERSION}`,
|
|
86
90
|
ConnectionId: randomBytes(16).toString('hex'),
|
|
87
91
|
});
|
|
88
|
-
return `${
|
|
92
|
+
return `${URL_SINTESIS}?${parametros.toString()}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Marca de tiempo con el formato que espera el servicio (UTC, en ingles).
|
|
97
|
+
* No se usa `toUTCString` porque el servicio pide este formato exacto.
|
|
98
|
+
*/
|
|
99
|
+
function marcaDeTiempo(ahora = new Date()) {
|
|
100
|
+
const dias = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
101
|
+
const meses = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
102
|
+
const dos = (n) => String(n).padStart(2, '0');
|
|
103
|
+
return (
|
|
104
|
+
`${dias[ahora.getUTCDay()]} ${meses[ahora.getUTCMonth()]} ${dos(ahora.getUTCDate())} ` +
|
|
105
|
+
`${ahora.getUTCFullYear()} ${dos(ahora.getUTCHours())}:${dos(ahora.getUTCMinutes())}:${dos(ahora.getUTCSeconds())} ` +
|
|
106
|
+
'GMT+0000 (Coordinated Universal Time)'
|
|
107
|
+
);
|
|
89
108
|
}
|
|
90
109
|
|
|
91
110
|
/**
|
|
@@ -162,20 +181,22 @@ export function sintetizar(texto, opciones = {}) {
|
|
|
162
181
|
|
|
163
182
|
ws.addEventListener('open', () => {
|
|
164
183
|
ws.send(
|
|
165
|
-
|
|
184
|
+
`X-Timestamp:${marcaDeTiempo()}\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n` +
|
|
166
185
|
JSON.stringify({
|
|
167
186
|
context: {
|
|
168
187
|
synthesis: {
|
|
169
188
|
audio: {
|
|
170
|
-
metadataoptions: { sentenceBoundaryEnabled: false, wordBoundaryEnabled: false },
|
|
189
|
+
metadataoptions: { sentenceBoundaryEnabled: 'false', wordBoundaryEnabled: 'false' },
|
|
171
190
|
outputFormat,
|
|
172
191
|
},
|
|
173
192
|
},
|
|
174
193
|
},
|
|
175
|
-
})
|
|
194
|
+
}) +
|
|
195
|
+
'\r\n'
|
|
176
196
|
);
|
|
177
197
|
ws.send(
|
|
178
|
-
`X-RequestId:${randomBytes(16).toString('hex')}\r\nContent-Type:application/ssml+xml\r\
|
|
198
|
+
`X-RequestId:${randomBytes(16).toString('hex')}\r\nContent-Type:application/ssml+xml\r\n` +
|
|
199
|
+
`X-Timestamp:${marcaDeTiempo()}Z\r\nPath:ssml\r\n\r\n` +
|
|
179
200
|
generarSsml({ voz, rate, pitch, volume, texto: contenido })
|
|
180
201
|
);
|
|
181
202
|
});
|
|
@@ -184,8 +184,8 @@ export function parseTailscaleStatus(json) {
|
|
|
184
184
|
return { dnsName, ipv4, connected: Boolean(dnsName) };
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
|
|
188
|
-
|
|
187
|
+
/** DNSName del nodo si Tailscale esta instalado y conectado. */
|
|
188
|
+
async function tailscaleDnsName() {
|
|
189
189
|
let status;
|
|
190
190
|
try {
|
|
191
191
|
status = await runTailscale(['status', '--json']);
|
|
@@ -201,6 +201,18 @@ export async function startTailscaleServe(port, opts = {}) {
|
|
|
201
201
|
if (!parsed.connected) {
|
|
202
202
|
throw new Error('Tailscale no esta conectado. Inicia sesion con `tailscale up`.');
|
|
203
203
|
}
|
|
204
|
+
return parsed.dnsName;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** URL del handler HTTPS de `tailscale serve` (sin `:443`). */
|
|
208
|
+
export function tailscalePreviewUrl(dnsName, httpsPort = 8443) {
|
|
209
|
+
const port = Number(httpsPort) || 8443;
|
|
210
|
+
return `https://${dnsName}${port === 443 ? '' : `:${port}`}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function startTailscaleServe(port, opts = {}) {
|
|
214
|
+
const log = opts.log ?? (() => {});
|
|
215
|
+
const dnsName = await tailscaleDnsName();
|
|
204
216
|
const backend = `http://127.0.0.1:${port}`;
|
|
205
217
|
try {
|
|
206
218
|
await runTailscale(['serve', '--bg', backend], { timeoutMs: 30000 });
|
|
@@ -211,7 +223,7 @@ export async function startTailscaleServe(port, opts = {}) {
|
|
|
211
223
|
throw new Error(`no pude publicar con \`tailscale serve\`: ${first.message}`);
|
|
212
224
|
}
|
|
213
225
|
}
|
|
214
|
-
const url = `https://${
|
|
226
|
+
const url = `https://${dnsName}`;
|
|
215
227
|
log(`tailscale: ${url} -> ${backend}`);
|
|
216
228
|
return {
|
|
217
229
|
url,
|
|
@@ -223,6 +235,34 @@ export async function startTailscaleServe(port, opts = {}) {
|
|
|
223
235
|
};
|
|
224
236
|
}
|
|
225
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Publica un puerto de dev en el tailnet para el panel de vista previa, en un
|
|
240
|
+
* puerto HTTPS propio (default 8443) para NO pisar el `serve` de la consola en
|
|
241
|
+
* 443. El `stop` es puntual (`--https=<p> off`): un `serve reset` borraria
|
|
242
|
+
* tambien el bridge de la consola.
|
|
243
|
+
*/
|
|
244
|
+
export async function startTailscalePreview(port, opts = {}) {
|
|
245
|
+
const log = opts.log ?? (() => {});
|
|
246
|
+
const httpsPort = Number(opts.httpsPort) || 8443;
|
|
247
|
+
const dnsName = await tailscaleDnsName();
|
|
248
|
+
const backend = `http://127.0.0.1:${port}`;
|
|
249
|
+
try {
|
|
250
|
+
await runTailscale(['serve', '--bg', `--https=${httpsPort}`, backend], { timeoutMs: 30000 });
|
|
251
|
+
} catch (error) {
|
|
252
|
+
throw new Error(`no pude publicar la vista previa con \`tailscale serve\`: ${error.message}`);
|
|
253
|
+
}
|
|
254
|
+
const url = tailscalePreviewUrl(dnsName, httpsPort);
|
|
255
|
+
log(`tailscale (preview): ${url} -> ${backend}`);
|
|
256
|
+
return {
|
|
257
|
+
url,
|
|
258
|
+
provider: 'tailscale',
|
|
259
|
+
port,
|
|
260
|
+
pid: 0,
|
|
261
|
+
child: null,
|
|
262
|
+
stop: () => runTailscale(['serve', `--https=${httpsPort}`, 'off']).catch(() => {}),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
226
266
|
/** Estado de Tailscale (lanza si no esta instalado o no responde). */
|
|
227
267
|
export async function probeTailscale() {
|
|
228
268
|
const { stdout } = await runTailscale(['status', '--json']);
|
|
@@ -234,11 +274,17 @@ export function resetTailscaleServe() {
|
|
|
234
274
|
return runTailscale(['serve', 'reset'], { timeoutMs: 30000 });
|
|
235
275
|
}
|
|
236
276
|
|
|
237
|
-
/**
|
|
277
|
+
/**
|
|
278
|
+
* Devuelve { url, provider, pid, stop }. provider 'none' no abre nada.
|
|
279
|
+
* `opts.preview` usa el `serve` de un puerto HTTPS propio (8443) para no pisar el
|
|
280
|
+
* bridge de la consola en 443.
|
|
281
|
+
*/
|
|
238
282
|
export async function startTunnel(port, provider = 'tunnelmole', opts = {}) {
|
|
239
283
|
const p = String(provider || 'tunnelmole').toLowerCase();
|
|
240
284
|
if (p === 'none' || p === 'off' || p === '') return { url: '', provider: 'none', pid: 0, stop: () => {} };
|
|
241
|
-
if (p === 'tailscale' || p === 'ts')
|
|
285
|
+
if (p === 'tailscale' || p === 'ts') {
|
|
286
|
+
return opts.preview ? startTailscalePreview(port, opts) : startTailscaleServe(port, opts);
|
|
287
|
+
}
|
|
242
288
|
if (p === 'tunnelmole' || p === 'tmole') return startTunnelmole(port, opts);
|
|
243
289
|
throw new Error(`proveedor de tunel no soportado: ${provider}`);
|
|
244
290
|
}
|
|
@@ -8,7 +8,9 @@ test('liveHistory saca al asesor y recorta hasta el cursor', () => {
|
|
|
8
8
|
{ id: 'b', role: 'assistant', name: 'advisor', content: 'charla' },
|
|
9
9
|
{ id: 'c', role: 'assistant', content: '2' },
|
|
10
10
|
{ id: 'd', role: 'user', content: '3' },
|
|
11
|
+
{ id: 'e', role: 'assistant', name: 'advisor', mode: 'summary', content: 'resumen de lo hecho' },
|
|
11
12
|
];
|
|
13
|
+
// El consejo y el resumen del asesor nunca entran al contexto del modelo.
|
|
12
14
|
assert.deepEqual(liveHistory(history).map((m) => m.id), ['a', 'c', 'd']);
|
|
13
15
|
assert.deepEqual(liveHistory(history, 'a').map((m) => m.id), ['c', 'd']);
|
|
14
16
|
// Cursor inexistente: no perdemos contexto, devolvemos todo (sin asesor).
|
|
@@ -14,6 +14,7 @@ test('summarizeToolCall describe acciones y comandos', () => {
|
|
|
14
14
|
assert.equal(summarizeToolCall('bash', { command: 'npm test\n--watch' }), 'npm test --watch');
|
|
15
15
|
assert.equal(summarizeToolCall('write', { path: 'a/b.txt' }), 'a/b.txt');
|
|
16
16
|
assert.equal(summarizeToolCall('git_commit', { message: 'feat: algo' }), 'feat: algo');
|
|
17
|
+
assert.equal(summarizeToolCall('mcp_call', { tool: 'mcp__postgres__query' }), 'mcp__postgres__query');
|
|
17
18
|
});
|
|
18
19
|
|
|
19
20
|
test('summarizeToolCall tolera args invalidos y recorta', () => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { registryServerToConfig, normalizeMcpConfig } from '../src/mcp/registry.js';
|
|
3
|
+
import { registryServerToConfig, registryServerToCandidate, normalizeMcpConfig } from '../src/mcp/registry.js';
|
|
4
|
+
import { MCP_CATALOG, mcpCatalogEntry } from '../src/mcp/catalog.js';
|
|
4
5
|
|
|
5
6
|
test('registryServerToConfig maps a remote server to http', () => {
|
|
6
7
|
const cfg = registryServerToConfig({
|
|
@@ -32,6 +33,41 @@ test('registryServerToConfig maps an npm package to stdio and lists required env
|
|
|
32
33
|
assert.equal(cfg.command, 'npx');
|
|
33
34
|
assert.deepEqual(cfg.args, ['-y', 'remote-filesystem-mcp-server@0.1.2']);
|
|
34
35
|
assert.deepEqual(cfg.requiredEnv, ['GCS_BUCKET']);
|
|
36
|
+
assert.equal(cfg.env.GCS_BUCKET, 'env:GCS_BUCKET');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('registryServerToCandidate arma una vista liviana sin instalar', () => {
|
|
40
|
+
const candidate = registryServerToCandidate({
|
|
41
|
+
name: 'com.pulsemcp/remote-filesystem',
|
|
42
|
+
title: 'Remote Filesystem',
|
|
43
|
+
description: 'Archivos remotos',
|
|
44
|
+
packages: [
|
|
45
|
+
{
|
|
46
|
+
registryType: 'npm',
|
|
47
|
+
identifier: 'remote-filesystem-mcp-server',
|
|
48
|
+
version: '0.1.2',
|
|
49
|
+
runtimeHint: 'npx',
|
|
50
|
+
runtimeArguments: [{ value: '-y', type: 'positional' }],
|
|
51
|
+
environmentVariables: [{ name: 'GCS_BUCKET', isRequired: true }],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
});
|
|
55
|
+
assert.equal(candidate.id, 'remote-filesystem');
|
|
56
|
+
assert.equal(candidate.name, 'Remote Filesystem');
|
|
57
|
+
assert.equal(candidate.registry, 'com.pulsemcp/remote-filesystem');
|
|
58
|
+
assert.equal(candidate.transport, 'stdio');
|
|
59
|
+
assert.deepEqual(candidate.requiredEnv, ['GCS_BUCKET']);
|
|
60
|
+
assert.deepEqual(candidate.args, ['-y', 'remote-filesystem-mcp-server@0.1.2']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('el catálogo de MCP trae entradas deshabilitadas y copias', () => {
|
|
64
|
+
assert.ok(MCP_CATALOG.length > 0);
|
|
65
|
+
assert.ok(MCP_CATALOG.every((entry) => entry.enabled === false));
|
|
66
|
+
const entry = mcpCatalogEntry('playwright');
|
|
67
|
+
assert.equal(entry.command, 'npx');
|
|
68
|
+
entry.args.push('mutado');
|
|
69
|
+
assert.ok(!MCP_CATALOG.find((e) => e.id === 'playwright').args.includes('mutado'));
|
|
70
|
+
assert.equal(mcpCatalogEntry('nope'), null);
|
|
35
71
|
});
|
|
36
72
|
|
|
37
73
|
test('normalizeMcpConfig handles mcpServers snippets and plain configs', () => {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
createTools,
|
|
5
|
+
useLazyMcp,
|
|
6
|
+
buildMcpIndex,
|
|
7
|
+
planMcpTools,
|
|
8
|
+
mcpRouterDefinition,
|
|
9
|
+
MCP_ROUTER_NAME,
|
|
10
|
+
MCP_AUTO_THRESHOLD,
|
|
11
|
+
} from '../src/index.js';
|
|
12
|
+
|
|
13
|
+
function fakeMcp(names) {
|
|
14
|
+
const definitions = names.map((name) => ({
|
|
15
|
+
type: 'function',
|
|
16
|
+
function: { name, description: `hace ${name}`, parameters: { type: 'object', properties: {} } },
|
|
17
|
+
}));
|
|
18
|
+
const tools = names.map((name) => ({ name, server: 'srv', serverName: 'Servidor', description: `hace ${name}` }));
|
|
19
|
+
const handlers = new Map(names.map((name) => [name, async (args) => `ok:${name}:${JSON.stringify(args)}`]));
|
|
20
|
+
return {
|
|
21
|
+
definitions,
|
|
22
|
+
tools,
|
|
23
|
+
handles: (name) => handlers.has(name),
|
|
24
|
+
call: (name, args) => handlers.get(name)(args),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('useLazyMcp decide por modo y umbral', () => {
|
|
29
|
+
assert.equal(useLazyMcp('eager', 100), false);
|
|
30
|
+
assert.equal(useLazyMcp('lazy', 1), true);
|
|
31
|
+
assert.equal(useLazyMcp('lazy', 0), false);
|
|
32
|
+
assert.equal(useLazyMcp('auto', MCP_AUTO_THRESHOLD), false);
|
|
33
|
+
assert.equal(useLazyMcp('auto', MCP_AUTO_THRESHOLD + 1), true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('buildMcpIndex agrupa por servidor y recorta la descripcion', () => {
|
|
37
|
+
const index = buildMcpIndex([
|
|
38
|
+
{ name: 'mcp__a__x', server: 'a', serverName: 'Alpha', description: 'x'.repeat(400) },
|
|
39
|
+
{ name: 'mcp__a__y', server: 'a', serverName: 'Alpha', description: 'corta' },
|
|
40
|
+
{ name: 'mcp__b__z', server: 'b', serverName: 'Beta', description: '' },
|
|
41
|
+
]);
|
|
42
|
+
assert.match(index, /## Alpha/);
|
|
43
|
+
assert.match(index, /## Beta/);
|
|
44
|
+
assert.match(index, /- mcp__a__x — x+…/);
|
|
45
|
+
assert.match(index, /- mcp__b__z$/m);
|
|
46
|
+
assert.equal(buildMcpIndex([]), '');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('planMcpTools: eager y auto chico conservan todos los esquemas', () => {
|
|
50
|
+
const mcp = fakeMcp(['mcp__srv__a', 'mcp__srv__b']);
|
|
51
|
+
const eager = planMcpTools(mcp, { mode: 'eager' });
|
|
52
|
+
assert.equal(eager.routed, false);
|
|
53
|
+
assert.equal(eager.definitions.length, 2);
|
|
54
|
+
assert.equal(planMcpTools(mcp, { mode: 'auto' }).routed, false);
|
|
55
|
+
assert.deepEqual(planMcpTools(null, { mode: 'lazy' }), { definitions: [], routed: false });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('planMcpTools: lazy expone las fijadas + el router', () => {
|
|
59
|
+
const names = Array.from({ length: 20 }, (_, i) => `mcp__srv__t${i}`);
|
|
60
|
+
const mcp = fakeMcp(names);
|
|
61
|
+
const plan = planMcpTools(mcp, { mode: 'lazy', pinned: ['mcp__srv__t3', 'srv'] });
|
|
62
|
+
assert.equal(plan.routed, true);
|
|
63
|
+
const exposed = plan.definitions.map((d) => d.function.name);
|
|
64
|
+
// 'srv' fija todas las del servidor
|
|
65
|
+
assert.equal(exposed.filter((n) => n !== MCP_ROUTER_NAME).length, 20);
|
|
66
|
+
assert.ok(exposed.includes(MCP_ROUTER_NAME));
|
|
67
|
+
|
|
68
|
+
const one = planMcpTools(mcp, { mode: 'auto', pinned: ['mcp__srv__t5'], threshold: 10 });
|
|
69
|
+
assert.equal(one.routed, true);
|
|
70
|
+
assert.deepEqual(one.definitions.map((d) => d.function.name).sort(), ['mcp__srv__t5', MCP_ROUTER_NAME].sort());
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('createTools enruta mcp_call solo cuando esta diferido', async () => {
|
|
74
|
+
const names = Array.from({ length: 20 }, (_, i) => `mcp__srv__t${i}`);
|
|
75
|
+
const mcp = fakeMcp(names);
|
|
76
|
+
const lazy = createTools({ root: '/tmp', mcp, mcpMode: 'lazy', yes: true });
|
|
77
|
+
assert.ok(lazy.definitions.some((d) => d.function.name === MCP_ROUTER_NAME));
|
|
78
|
+
assert.equal(await lazy.execute(MCP_ROUTER_NAME, { tool: 'mcp__srv__t7', args: { q: 1 } }), 'ok:mcp__srv__t7:{"q":1}');
|
|
79
|
+
assert.match(await lazy.execute(MCP_ROUTER_NAME, { tool: 'nope' }), /desconocida/);
|
|
80
|
+
|
|
81
|
+
const eager = createTools({ root: '/tmp', mcp, mcpMode: 'eager', yes: true });
|
|
82
|
+
assert.ok(!eager.definitions.some((d) => d.function.name === MCP_ROUTER_NAME));
|
|
83
|
+
assert.equal(await eager.execute('mcp__srv__t7', { q: 1 }), 'ok:mcp__srv__t7:{"q":1}');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('las tools MCP pasan por permisos (default ask)', async () => {
|
|
87
|
+
const names = Array.from({ length: 20 }, (_, i) => `mcp__srv__t${i}`);
|
|
88
|
+
const mcp = fakeMcp(names);
|
|
89
|
+
|
|
90
|
+
// Sin onAsk y sin yes: denegado (como cualquier 'ask' en headless).
|
|
91
|
+
const denied = createTools({ root: '/tmp', mcp, mcpMode: 'eager' });
|
|
92
|
+
assert.match(await denied.execute('mcp__srv__t7', { q: 1 }), /denegado/);
|
|
93
|
+
|
|
94
|
+
// allow explicito por tool.
|
|
95
|
+
const allowed = createTools({ root: '/tmp', mcp, mcpMode: 'eager', permissions: { mcp__srv__t7: 'allow' } });
|
|
96
|
+
assert.equal(await allowed.execute('mcp__srv__t7', { q: 1 }), 'ok:mcp__srv__t7:{"q":1}');
|
|
97
|
+
|
|
98
|
+
// El router consulta con el nombre real de la tool.
|
|
99
|
+
const asked = [];
|
|
100
|
+
const routed = createTools({
|
|
101
|
+
root: '/tmp',
|
|
102
|
+
mcp,
|
|
103
|
+
mcpMode: 'lazy',
|
|
104
|
+
onAsk: async (name, args) => {
|
|
105
|
+
asked.push([name, args]);
|
|
106
|
+
return true;
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
assert.equal(await routed.execute(MCP_ROUTER_NAME, { tool: 'mcp__srv__t2', args: { x: 1 } }), 'ok:mcp__srv__t2:{"x":1}');
|
|
110
|
+
assert.deepEqual(asked, [['mcp__srv__t2', { x: 1 }]]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('mcpRouterDefinition usa el nombre reservado', () => {
|
|
114
|
+
assert.equal(mcpRouterDefinition().function.name, MCP_ROUTER_NAME);
|
|
115
|
+
});
|
|
@@ -3,10 +3,12 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import {
|
|
4
4
|
REDACTED,
|
|
5
5
|
isSecretName,
|
|
6
|
+
isSensitiveEnvName,
|
|
6
7
|
maskEnv,
|
|
7
8
|
maskSecret,
|
|
8
9
|
redactDeep,
|
|
9
10
|
redactSecrets,
|
|
11
|
+
scrubEnv,
|
|
10
12
|
secretValues,
|
|
11
13
|
} from '../src/redact.js';
|
|
12
14
|
|
|
@@ -43,3 +45,23 @@ test('maskSecret y maskEnv enmascaran sin exponer el valor', () => {
|
|
|
43
45
|
MYSQL_PASSWORD: '••••cret',
|
|
44
46
|
});
|
|
45
47
|
});
|
|
48
|
+
|
|
49
|
+
test('scrubEnv quita credenciales pero conserva vars legitimas', () => {
|
|
50
|
+
const env = {
|
|
51
|
+
PATH: '/usr/bin',
|
|
52
|
+
HOME: '/home/ana',
|
|
53
|
+
OPENROUTER_API_KEY: 'sk-xxx',
|
|
54
|
+
GITHUB_TOKEN: 'ghp_yyy',
|
|
55
|
+
MYSQL_PASSWORD: 'zzz',
|
|
56
|
+
SSH_AUTH_SOCK: '/tmp/agent.sock',
|
|
57
|
+
};
|
|
58
|
+
const clean = scrubEnv(env);
|
|
59
|
+
assert.equal(clean.PATH, '/usr/bin');
|
|
60
|
+
assert.equal(clean.HOME, '/home/ana');
|
|
61
|
+
assert.equal(clean.SSH_AUTH_SOCK, '/tmp/agent.sock');
|
|
62
|
+
assert.equal(clean.OPENROUTER_API_KEY, undefined);
|
|
63
|
+
assert.equal(clean.GITHUB_TOKEN, undefined);
|
|
64
|
+
assert.equal(clean.MYSQL_PASSWORD, undefined);
|
|
65
|
+
assert.equal(isSensitiveEnvName('SSH_AUTH_SOCK'), false);
|
|
66
|
+
assert.equal(isSensitiveEnvName('OPENAI_API_KEY'), true);
|
|
67
|
+
});
|
|
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { parseSkillMarkdown, loadSkills, mergeSkills, buildSkillsPrompt, buildSkillsIndex } from '../src/skills/index.js';
|
|
7
7
|
import { parseSkillRef } from '../src/skills/remote.js';
|
|
8
|
+
import { SKILLS_CATALOG, skillCatalogEntry } from '../src/skills/catalog.js';
|
|
8
9
|
|
|
9
10
|
test('parseSkillMarkdown reads frontmatter and body', () => {
|
|
10
11
|
const parsed = parseSkillMarkdown(
|
|
@@ -76,3 +77,13 @@ test('buildSkillsIndex lists id + when without the body (progressive disclosure)
|
|
|
76
77
|
assert.match(text, /skill\(\{ name \}\)/);
|
|
77
78
|
assert.doesNotMatch(text, /SECRETO_BODY/);
|
|
78
79
|
});
|
|
80
|
+
|
|
81
|
+
test('el catálogo de skills trae cuerpo con frontmatter parseable', () => {
|
|
82
|
+
assert.ok(SKILLS_CATALOG.length > 0);
|
|
83
|
+
for (const entry of SKILLS_CATALOG) {
|
|
84
|
+
assert.match(entry.id, /^[a-z0-9][a-z0-9_-]*$/);
|
|
85
|
+
assert.ok(entry.body.trim().length > 0);
|
|
86
|
+
}
|
|
87
|
+
assert.equal(skillCatalogEntry('code-review').id, 'code-review');
|
|
88
|
+
assert.equal(skillCatalogEntry('nope'), null);
|
|
89
|
+
});
|
|
@@ -4,9 +4,17 @@ import { mkdtemp, readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { resolveInRoot, toRel } from '../src/tools/paths.js';
|
|
7
|
-
import { evaluatePermission } from '../src/tools/permissions.js';
|
|
7
|
+
import { evaluatePermission, bashGuard } from '../src/tools/permissions.js';
|
|
8
8
|
import { createTools, createToolRegistry } from '../src/tools/index.js';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
fetchUrlTool,
|
|
11
|
+
previewUrlTool,
|
|
12
|
+
screenshotTool,
|
|
13
|
+
consoleTool,
|
|
14
|
+
askUserTool,
|
|
15
|
+
offerChoicesTool,
|
|
16
|
+
showPlanTool,
|
|
17
|
+
} from '../src/tools/executors.js';
|
|
10
18
|
|
|
11
19
|
async function makeWorkspace() {
|
|
12
20
|
const root = await mkdtemp(path.join(tmpdir(), 'tamnora-ws-'));
|
|
@@ -36,6 +44,23 @@ test('evaluatePermission applies defaults and pattern maps', () => {
|
|
|
36
44
|
);
|
|
37
45
|
});
|
|
38
46
|
|
|
47
|
+
test('bashGuard marca comandos peligrosos (piso de seguridad)', () => {
|
|
48
|
+
assert.equal(bashGuard('rm -rf /'), 'deny');
|
|
49
|
+
assert.equal(bashGuard('sudo rm -rf ~'), 'deny');
|
|
50
|
+
assert.equal(bashGuard('mkfs.ext4 /dev/sda1'), 'deny');
|
|
51
|
+
assert.equal(bashGuard('dd if=/dev/zero of=/dev/sda'), 'deny');
|
|
52
|
+
assert.equal(bashGuard('git push --force origin main'), 'ask');
|
|
53
|
+
assert.equal(bashGuard('git reset --hard HEAD~3'), 'ask');
|
|
54
|
+
assert.equal(bashGuard('rm -rf .'), 'ask');
|
|
55
|
+
assert.equal(bashGuard('rm -rf ./build'), null);
|
|
56
|
+
assert.equal(bashGuard('npm test'), null);
|
|
57
|
+
|
|
58
|
+
// El piso no se saltea con una policy permisiva.
|
|
59
|
+
assert.equal(evaluatePermission('bash', { command: 'rm -rf /' }, { bash: 'allow' }), 'deny');
|
|
60
|
+
assert.equal(evaluatePermission('bash', { command: 'git push -f' }, { bash: 'allow' }), 'ask');
|
|
61
|
+
assert.equal(evaluatePermission('bash', { command: 'rm -rf ./dist' }, { bash: 'allow' }), 'allow');
|
|
62
|
+
});
|
|
63
|
+
|
|
39
64
|
test('local tools read, write, list, glob, grep and edit', async () => {
|
|
40
65
|
const root = await makeWorkspace();
|
|
41
66
|
const reg = createToolRegistry({ root, permissions: { write: 'allow', edit: 'allow' } });
|
|
@@ -144,7 +169,10 @@ test('createTools combines local and MCP tool routing', async () => {
|
|
|
144
169
|
handles: (n) => n === 'mcp__x__ping',
|
|
145
170
|
call: async () => 'pong',
|
|
146
171
|
};
|
|
147
|
-
const
|
|
172
|
+
const denied = createTools({ root, mcp });
|
|
173
|
+
assert.match(await denied.execute('mcp__x__ping', {}), /denegado/);
|
|
174
|
+
|
|
175
|
+
const { definitions, execute } = createTools({ root, mcp, permissions: { 'mcp__x__ping': 'allow' } });
|
|
148
176
|
assert.ok(definitions.some((d) => d.function.name === 'mcp__x__ping'));
|
|
149
177
|
assert.equal(await execute('mcp__x__ping', {}), 'pong');
|
|
150
178
|
assert.match(await execute('nope', {}), /desconocida/);
|
|
@@ -158,6 +186,23 @@ test('bash runs inside the workspace root', async () => {
|
|
|
158
186
|
assert.match(out, /\[exit 0\]/);
|
|
159
187
|
});
|
|
160
188
|
|
|
189
|
+
test('bash no hereda variables con pinta de secreto', async () => {
|
|
190
|
+
const root = await makeWorkspace();
|
|
191
|
+
process.env.TAMNORA_TEST_API_KEY = 'supersecret123';
|
|
192
|
+
process.env.TAMNORA_TEST_OK = 'visible';
|
|
193
|
+
try {
|
|
194
|
+
const reg = createToolRegistry({ root, permissions: { bash: 'allow' } });
|
|
195
|
+
const out = await reg.execute('bash', {
|
|
196
|
+
command: `node -e "process.stdout.write('K='+String(process.env.TAMNORA_TEST_API_KEY)+' O='+process.env.TAMNORA_TEST_OK)"`,
|
|
197
|
+
});
|
|
198
|
+
assert.doesNotMatch(out, /supersecret123/);
|
|
199
|
+
assert.match(out, /visible/);
|
|
200
|
+
} finally {
|
|
201
|
+
delete process.env.TAMNORA_TEST_API_KEY;
|
|
202
|
+
delete process.env.TAMNORA_TEST_OK;
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
161
206
|
test('bash se cancela cuando aborta la señal del run', async () => {
|
|
162
207
|
const root = await makeWorkspace();
|
|
163
208
|
const ac = new AbortController();
|
|
@@ -202,3 +247,52 @@ test('screenshot, console_errors y preview_url validan la URL sin lanzar el nave
|
|
|
202
247
|
assert.ok(definitions.some((d) => d.function.name === name), `falta ${name}`);
|
|
203
248
|
}
|
|
204
249
|
});
|
|
250
|
+
|
|
251
|
+
test('ask_user: fallback sin canal y respuestas formateadas con onQuestion', async () => {
|
|
252
|
+
assert.match(await askUserTool(null, {}), /Error/);
|
|
253
|
+
assert.match(await askUserTool(null, { questions: [{ id: 'n', label: 'Nombre' }] }), /canal/);
|
|
254
|
+
|
|
255
|
+
const out = await askUserTool(
|
|
256
|
+
null,
|
|
257
|
+
{
|
|
258
|
+
questions: [
|
|
259
|
+
{ id: 'n', label: 'Nombre' },
|
|
260
|
+
{ id: 'f', label: 'Framework', type: 'select', options: ['React', 'Vue'] },
|
|
261
|
+
],
|
|
262
|
+
},
|
|
263
|
+
{ onQuestion: async () => ({ ok: true, answers: { n: 'Ana', f: 'Vue' } }) }
|
|
264
|
+
);
|
|
265
|
+
assert.match(out, /Nombre: Ana/);
|
|
266
|
+
assert.match(out, /Framework: Vue/);
|
|
267
|
+
|
|
268
|
+
const cancelled = await askUserTool(null, { questions: [{ id: 'n', label: 'N' }] }, { onQuestion: async () => ({ ok: false }) });
|
|
269
|
+
assert.match(cancelled, /no respondi/i);
|
|
270
|
+
|
|
271
|
+
assert.equal(evaluatePermission('ask_user', {}, {}), 'allow');
|
|
272
|
+
const { definitions } = createTools({ root: await makeWorkspace() });
|
|
273
|
+
assert.ok(definitions.some((d) => d.function.name === 'ask_user'));
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('offer_choices valida question y options', async () => {
|
|
277
|
+
assert.match(offerChoicesTool(null, {}), /Error/);
|
|
278
|
+
const out = offerChoicesTool(null, { question: '¿Sigo?', options: ['sí', 'no'] });
|
|
279
|
+
assert.match(out, /sí/);
|
|
280
|
+
assert.match(out, /no/);
|
|
281
|
+
assert.equal(evaluatePermission('offer_choices', {}, {}), 'allow');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('show_plan valida steps y marca el estado', () => {
|
|
285
|
+
assert.match(showPlanTool(null, {}), /Error/);
|
|
286
|
+
const out = showPlanTool(null, {
|
|
287
|
+
title: 'Plan',
|
|
288
|
+
steps: [
|
|
289
|
+
{ text: 'Uno', status: 'done' },
|
|
290
|
+
{ text: 'Dos', status: 'active' },
|
|
291
|
+
{ text: 'Tres' },
|
|
292
|
+
],
|
|
293
|
+
});
|
|
294
|
+
assert.match(out, /\[x\] Uno/);
|
|
295
|
+
assert.match(out, /\[~\] Dos/);
|
|
296
|
+
assert.match(out, /\[ \] Tres/);
|
|
297
|
+
assert.equal(evaluatePermission('show_plan', {}, {}), 'allow');
|
|
298
|
+
});
|