@danieltmn/tamnora 0.16.1 → 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 +45 -0
- package/README.md +58 -4
- 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 +17 -3
- 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/procs/detect.js +59 -8
- package/harness/packages/core/src/procs/kill.js +26 -5
- 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/procs.test.js +70 -2
- 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 +629 -28
- package/harness/packages/server/public/index.html +44 -2
- package/harness/packages/server/public/styles.css +106 -7
- 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/procs.js +9 -3
- 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/runManager.js +29 -2
- 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 +40 -3
- 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/procs.js +20 -3
- 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 +285 -34
- 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
|
@@ -17,6 +17,9 @@ export const DEFAULT_PERMISSIONS = {
|
|
|
17
17
|
screenshot: 'allow',
|
|
18
18
|
console_errors: 'allow',
|
|
19
19
|
preview_url: 'allow',
|
|
20
|
+
ask_user: 'allow',
|
|
21
|
+
offer_choices: 'allow',
|
|
22
|
+
show_plan: 'allow',
|
|
20
23
|
write: 'ask',
|
|
21
24
|
edit: 'ask',
|
|
22
25
|
bash: 'ask',
|
|
@@ -50,27 +53,69 @@ function subjectFor(name, args) {
|
|
|
50
53
|
return args?.path ?? '';
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
// Guardas de `bash`: comandos catastroficos o irreversibles que no deberian
|
|
57
|
+
// colarse aunque la policy sea `allow` (o `--yes`). `deny` = nunca legitimo;
|
|
58
|
+
// `ask` = nunca se autoaprueba (siempre consulta, aunque bash sea `allow`).
|
|
59
|
+
const BASH_DENY = [
|
|
60
|
+
/:\s*\(\s*\)\s*\{[\s\S]*\}\s*;?\s*:/, // fork bomb :(){ :|:& };:
|
|
61
|
+
/\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)(\s+--?[\w-]+)*\s+(\/|~|\$HOME|\/\*)(\s|$)/i,
|
|
62
|
+
/\bmkfs(\.|\s|$)/i,
|
|
63
|
+
/\bdd\b[^\n]*\bof=\/dev\//i,
|
|
64
|
+
/>\s*\/dev\/(sd|nvme|disk)/i,
|
|
65
|
+
/\bchmod\s+-R\s+777\s+\/(\s|$)/i,
|
|
66
|
+
/\b(?:format|del|erase|rd|rmdir)\b[^\n]*\b[a-z]:[\\/]\*/i, // Windows: borrar/formatear unidad
|
|
67
|
+
/\b(?:remove-item|del|rd|rmdir|format)\b[^\n]*(?:-recurse|-r\b)[^\n]*\b[a-z]:\\?\s*$/i,
|
|
68
|
+
];
|
|
69
|
+
const BASH_ASK = [
|
|
70
|
+
/\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)(\s+--?[\w-]+)*\s+(\*|\.|\.\/)(\s|$)/i, // rm -rf * | . (workspace)
|
|
71
|
+
/\bgit\s+push\b[^\n]*(\s-f\b|\s--force\b)/i,
|
|
72
|
+
/\bgit\s+reset\s+--hard\b/i,
|
|
73
|
+
/\bgit\s+clean\s+-[a-z]*f/i,
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/** Devuelve `'deny'`/`'ask'` si el comando es peligroso, o `null`. */
|
|
77
|
+
export function bashGuard(command) {
|
|
78
|
+
const text = String(command ?? '');
|
|
79
|
+
if (!text.trim()) return null;
|
|
80
|
+
for (const re of BASH_DENY) if (re.test(text)) return 'deny';
|
|
81
|
+
for (const re of BASH_ASK) if (re.test(text)) return 'ask';
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const RANK = { allow: 0, ask: 1, deny: 2 };
|
|
86
|
+
|
|
53
87
|
/**
|
|
54
88
|
* Decide the permission for a tool call.
|
|
55
89
|
* @returns {'allow'|'ask'|'deny'}
|
|
56
90
|
*/
|
|
57
91
|
export function evaluatePermission(name, args = {}, permissions = {}) {
|
|
58
92
|
const value = permissions[name] ?? DEFAULT_PERMISSIONS[name] ?? 'ask';
|
|
93
|
+
let decision = 'ask';
|
|
59
94
|
|
|
60
|
-
if (typeof value === 'string')
|
|
61
|
-
|
|
62
|
-
if (value && typeof value === 'object') {
|
|
95
|
+
if (typeof value === 'string') {
|
|
96
|
+
decision = value;
|
|
97
|
+
} else if (value && typeof value === 'object') {
|
|
63
98
|
const subject = subjectFor(name, args);
|
|
64
99
|
let fallback = 'ask';
|
|
65
|
-
|
|
100
|
+
let matched = false;
|
|
101
|
+
for (const [pattern, d] of Object.entries(value)) {
|
|
66
102
|
if (pattern === '*') {
|
|
67
|
-
fallback =
|
|
103
|
+
fallback = d;
|
|
68
104
|
continue;
|
|
69
105
|
}
|
|
70
|
-
if (matchPattern(pattern, subject))
|
|
106
|
+
if (matchPattern(pattern, subject)) {
|
|
107
|
+
decision = d;
|
|
108
|
+
matched = true;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
71
111
|
}
|
|
72
|
-
|
|
112
|
+
if (!matched) decision = fallback;
|
|
73
113
|
}
|
|
74
114
|
|
|
75
|
-
|
|
115
|
+
// Piso de seguridad: un comando peligroso no se puede autoaprobar por policy.
|
|
116
|
+
if (name === 'bash') {
|
|
117
|
+
const guard = bashGuard(args?.command);
|
|
118
|
+
if (guard && RANK[guard] > RANK[decision]) decision = guard;
|
|
119
|
+
}
|
|
120
|
+
return decision;
|
|
76
121
|
}
|
|
@@ -10,29 +10,35 @@ import { evaluatePermission } from './permissions.js';
|
|
|
10
10
|
* @param {string[]} [opts.enabled] - tool names to expose (default: all)
|
|
11
11
|
* @param {object} [opts.permissions] - policy per tool ('allow'|'ask'|'deny' or pattern map)
|
|
12
12
|
* @param {(name:string, args:object)=>Promise<boolean>} [opts.onAsk] - interactive approval
|
|
13
|
+
* @param {(payload:{title:string, questions:object[]})=>Promise<{answers:object,cancelled?:boolean}>} [opts.onQuestion] - interactive form
|
|
13
14
|
* @param {boolean} [opts.yes] - auto-approve 'ask' (non-interactive)
|
|
14
15
|
* @param {AbortSignal} [opts.signal] - cancela las tools de larga duracion (bash, git)
|
|
15
16
|
*/
|
|
16
|
-
export function createToolRegistry({ root, enabled, permissions = {}, onAsk, yes = false, signal } = {}) {
|
|
17
|
+
export function createToolRegistry({ root, enabled, permissions = {}, onAsk, onQuestion, yes = false, signal } = {}) {
|
|
17
18
|
const names = new Set(enabled?.length ? enabled : TOOL_NAMES);
|
|
18
19
|
const definitions = TOOL_DEFINITIONS.filter((d) => names.has(d.function.name));
|
|
19
20
|
|
|
20
|
-
async function
|
|
21
|
-
if (!names.has(name) || !(name in EXECUTORS)) {
|
|
22
|
-
return `Error: herramienta desconocida "${name}".`;
|
|
23
|
-
}
|
|
24
|
-
|
|
21
|
+
async function authorize(name, args = {}) {
|
|
25
22
|
let decision = evaluatePermission(name, args, permissions);
|
|
26
23
|
if (decision === 'ask') {
|
|
27
24
|
if (onAsk) decision = (await onAsk(name, args)) ? 'allow' : 'deny';
|
|
28
25
|
else decision = yes ? 'allow' : 'deny';
|
|
29
26
|
}
|
|
27
|
+
return decision;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function execute(name, args = {}) {
|
|
31
|
+
if (!names.has(name) || !(name in EXECUTORS)) {
|
|
32
|
+
return `Error: herramienta desconocida "${name}".`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const decision = await authorize(name, args);
|
|
30
36
|
if (decision === 'deny') {
|
|
31
37
|
const detail = args?.command ?? args?.path ?? '';
|
|
32
38
|
return `Permiso denegado para "${name}"${detail ? ` (${detail})` : ''}.`;
|
|
33
39
|
}
|
|
34
40
|
|
|
35
|
-
return EXECUTORS[name](root, args, { signal });
|
|
41
|
+
return EXECUTORS[name](root, args, { signal, onQuestion });
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
return {
|
|
@@ -40,6 +46,7 @@ export function createToolRegistry({ root, enabled, permissions = {}, onAsk, yes
|
|
|
40
46
|
definitions,
|
|
41
47
|
names: [...names],
|
|
42
48
|
handles: (name) => names.has(name) && name in EXECUTORS,
|
|
49
|
+
authorize,
|
|
43
50
|
execute,
|
|
44
51
|
};
|
|
45
52
|
}
|
|
@@ -197,6 +197,92 @@ export const TOOL_DEFINITIONS = [
|
|
|
197
197
|
},
|
|
198
198
|
},
|
|
199
199
|
},
|
|
200
|
+
{
|
|
201
|
+
type: 'function',
|
|
202
|
+
function: {
|
|
203
|
+
name: 'ask_user',
|
|
204
|
+
description:
|
|
205
|
+
'Ask the user with an interactive form when you need input before continuing (choices, confirmations, short texts). Use this instead of listing numbered questions in the text: the console renders the fields and the user answers them in one go. Each question has id, label and type (text|textarea|select|confirm); for select pass options. For a single choice, prefer type "select" (or "confirm" for yes/no).',
|
|
206
|
+
parameters: {
|
|
207
|
+
type: 'object',
|
|
208
|
+
properties: {
|
|
209
|
+
title: { type: 'string', description: 'Short form heading (optional)' },
|
|
210
|
+
questions: {
|
|
211
|
+
type: 'array',
|
|
212
|
+
description: 'Questions to show the user',
|
|
213
|
+
items: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
id: { type: 'string', description: 'Short id (slug) for the answer' },
|
|
217
|
+
label: { type: 'string', description: 'Question text' },
|
|
218
|
+
type: {
|
|
219
|
+
type: 'string',
|
|
220
|
+
enum: ['text', 'textarea', 'select', 'confirm'],
|
|
221
|
+
description: 'Field type (default text)',
|
|
222
|
+
},
|
|
223
|
+
options: { type: 'array', items: { type: 'string' }, description: 'Options for type=select' },
|
|
224
|
+
placeholder: { type: 'string', description: 'Placeholder for text inputs' },
|
|
225
|
+
required: { type: 'boolean', description: 'Whether an answer is required' },
|
|
226
|
+
},
|
|
227
|
+
required: ['id', 'label'],
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
required: ['questions'],
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
type: 'function',
|
|
237
|
+
function: {
|
|
238
|
+
name: 'offer_choices',
|
|
239
|
+
description:
|
|
240
|
+
'Offer the user 2-6 options as tappable buttons when you need them to pick a direction (instead of writing "opción A/B/C" in the text). Tapping one sends it as the user message. Use it for quick forks; for free-form input use ask_user.',
|
|
241
|
+
parameters: {
|
|
242
|
+
type: 'object',
|
|
243
|
+
properties: {
|
|
244
|
+
question: { type: 'string', description: 'Short prompt above the buttons' },
|
|
245
|
+
options: {
|
|
246
|
+
type: 'array',
|
|
247
|
+
items: { type: 'string' },
|
|
248
|
+
description: 'Option labels (2-6)',
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
required: ['question', 'options'],
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
type: 'function',
|
|
257
|
+
function: {
|
|
258
|
+
name: 'show_plan',
|
|
259
|
+
description:
|
|
260
|
+
'Show the user your plan or task list as an interactive checklist, so they can follow the steps and what is left. Re-emit it as you progress to update the status of each step. Read-only: it only displays, it does not track state by itself.',
|
|
261
|
+
parameters: {
|
|
262
|
+
type: 'object',
|
|
263
|
+
properties: {
|
|
264
|
+
title: { type: 'string', description: 'Short heading (optional)' },
|
|
265
|
+
steps: {
|
|
266
|
+
type: 'array',
|
|
267
|
+
description: 'Ordered steps',
|
|
268
|
+
items: {
|
|
269
|
+
type: 'object',
|
|
270
|
+
properties: {
|
|
271
|
+
text: { type: 'string', description: 'What the step is' },
|
|
272
|
+
status: {
|
|
273
|
+
type: 'string',
|
|
274
|
+
enum: ['pending', 'active', 'done'],
|
|
275
|
+
description: 'Step status (default pending)',
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
required: ['text'],
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
required: ['steps'],
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
200
286
|
{
|
|
201
287
|
type: 'function',
|
|
202
288
|
function: {
|
|
@@ -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
|
+
});
|
|
@@ -5,8 +5,8 @@ import net from 'node:net';
|
|
|
5
5
|
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
-
import { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW } from '../src/procs/detect.js';
|
|
9
|
-
import { killPort } from '../src/procs/kill.js';
|
|
8
|
+
import { detectProcs, loadProjectProcs, packageManager, DEFAULT_PROCESS_ALLOW, PROCS_FILE } from '../src/procs/detect.js';
|
|
9
|
+
import { killPort, portInfo } from '../src/procs/kill.js';
|
|
10
10
|
|
|
11
11
|
async function waitForPort(port, attempts = 50) {
|
|
12
12
|
for (let i = 0; i < attempts; i++) {
|
|
@@ -99,3 +99,71 @@ test('detectProcs does not add the static chip when there is a dev script', asyn
|
|
|
99
99
|
await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: { dev: 'vite' } }));
|
|
100
100
|
assert.ok(!detectProcs(dir).some((c) => c.args.some((a) => String(a).endsWith('static-server.mjs'))));
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
test('loadProjectProcs reads .tamnora/procs.json and confines dir to the root', async () => {
|
|
104
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
|
|
105
|
+
await mkdir(path.join(dir, '.tamnora'), { recursive: true });
|
|
106
|
+
await mkdir(path.join(dir, 'backend'));
|
|
107
|
+
await writeFile(
|
|
108
|
+
path.join(dir, PROCS_FILE),
|
|
109
|
+
JSON.stringify({
|
|
110
|
+
processes: [
|
|
111
|
+
{ label: 'backend', dir: 'backend', command: 'node', args: ['index.js'], port: 3000 },
|
|
112
|
+
{ label: 'afuera', dir: '..', command: 'node', args: ['evil.js'] },
|
|
113
|
+
{ label: 'sin dir', command: 'npm', args: ['run', 'dev'] },
|
|
114
|
+
],
|
|
115
|
+
})
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const chips = loadProjectProcs(dir);
|
|
119
|
+
assert.equal(chips.length, 2, 'descarta el dir que se escapa');
|
|
120
|
+
assert.equal(chips[0].cwd, path.join(dir, 'backend'));
|
|
121
|
+
assert.equal(chips[0].port, 3000);
|
|
122
|
+
assert.equal(chips[0].source, 'project');
|
|
123
|
+
assert.equal(chips[1].cwd, dir);
|
|
124
|
+
assert.ok(chips.every((c) => c.source === 'project'));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('detectProcs puts project chips first and dedupes against auto-detection', async () => {
|
|
128
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
|
|
129
|
+
await mkdir(path.join(dir, '.tamnora'), { recursive: true });
|
|
130
|
+
await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: { dev: 'vite' } }));
|
|
131
|
+
await writeFile(
|
|
132
|
+
path.join(dir, PROCS_FILE),
|
|
133
|
+
JSON.stringify({
|
|
134
|
+
processes: [
|
|
135
|
+
{ label: 'backend', command: 'node', args: ['server.js'], port: 3000 },
|
|
136
|
+
{ label: 'dev repetido', command: 'npm', args: ['run', 'dev'] },
|
|
137
|
+
],
|
|
138
|
+
})
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const chips = detectProcs(dir);
|
|
142
|
+
assert.equal(chips[0].source, 'project');
|
|
143
|
+
assert.equal(chips[0].command, 'node');
|
|
144
|
+
const devChips = chips.filter((c) => c.command === 'npm' && c.args.join(' ') === 'run dev');
|
|
145
|
+
assert.equal(devChips.length, 1, 'el chip manual gana al autodetectado');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('portInfo reports whether a port is listening', async (t) => {
|
|
149
|
+
const free = await portInfo(47599);
|
|
150
|
+
assert.equal(free.listening, false);
|
|
151
|
+
assert.deepEqual(free.pids, []);
|
|
152
|
+
await assert.rejects(() => portInfo(0), /Puerto inválido/);
|
|
153
|
+
|
|
154
|
+
const port = 48000 + Math.floor(Math.random() * 1000);
|
|
155
|
+
const script = `const http=require('node:http');http.createServer((q,r)=>r.end('ok')).listen(${port},'127.0.0.1');setInterval(()=>{},1000);`;
|
|
156
|
+
const child = spawn(process.execPath, ['-e', script], { stdio: 'ignore', windowsHide: true });
|
|
157
|
+
t.after(() => {
|
|
158
|
+
try {
|
|
159
|
+
child.kill('SIGKILL');
|
|
160
|
+
} catch {
|
|
161
|
+
// already gone
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
await waitForPort(port);
|
|
165
|
+
const info = await portInfo(port);
|
|
166
|
+
assert.equal(info.port, port);
|
|
167
|
+
assert.equal(info.listening, true);
|
|
168
|
+
assert.ok(info.pids.length >= 1);
|
|
169
|
+
});
|