@danieltmn/tamnora 0.21.2 → 0.23.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 +28 -0
- package/README.md +3 -1
- package/SECURITY.md +1 -1
- package/harness/packages/cli/bin/tamnora.mjs +7 -1
- package/harness/packages/cli/src/commands/doctor.js +42 -4
- package/harness/packages/cli/src/commands/repl.js +1 -0
- package/harness/packages/cli/src/commands/run.js +1 -0
- package/harness/packages/cli/src/commands/tui.js +1 -0
- package/harness/packages/cli/src/lib/runtime.js +2 -0
- package/harness/packages/cli/src/tui/app.js +2 -0
- package/harness/packages/core/src/agent/runAgent.js +9 -5
- package/harness/packages/core/src/browser/index.js +27 -13
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/mcp/stdio.js +15 -0
- package/harness/packages/core/src/net/hosts.js +70 -0
- package/harness/packages/core/src/redact.js +2 -0
- package/harness/packages/core/src/tools/executors.js +19 -24
- package/harness/packages/core/src/tools/index.js +1 -1
- package/harness/packages/core/src/tools/paths.js +35 -5
- package/harness/packages/core/src/tools/permissions.js +41 -2
- package/harness/packages/core/src/tools/registry.js +8 -3
- package/harness/packages/core/src/tts/edge.js +34 -4
- package/harness/packages/core/src/tunnel.js +15 -5
- package/harness/packages/core/test/agent.test.js +18 -0
- package/harness/packages/core/test/servicios-vivos.test.js +71 -0
- package/harness/packages/core/test/tools.test.js +44 -1
- package/harness/packages/core/test/tts.test.js +81 -0
- package/harness/packages/server/bin/tamnora-server.mjs +7 -1
- package/harness/packages/server/public/app.js +347 -6757
- package/harness/packages/server/public/cards.js +470 -0
- package/harness/packages/server/public/chat.js +1343 -0
- package/harness/packages/server/public/chatFormat.js +156 -0
- package/harness/packages/server/public/chrome.js +813 -0
- package/harness/packages/server/public/commands.js +403 -0
- package/harness/packages/server/public/core.js +209 -0
- package/harness/packages/server/public/explorer.js +625 -0
- package/harness/packages/server/public/icons/logo_dark.png +0 -0
- package/harness/packages/server/public/icons/logo_light.png +0 -0
- package/harness/packages/server/public/index.html +40 -41
- package/harness/packages/server/public/sessions.js +274 -0
- package/harness/packages/server/public/settings.js +1080 -0
- package/harness/packages/server/public/styles.css +45 -52
- package/harness/packages/server/public/system.js +495 -0
- package/harness/packages/server/public/voice.js +1058 -0
- package/harness/packages/server/src/auth.js +12 -10
- package/harness/packages/server/src/lib/agentRun.js +6 -1
- package/harness/packages/server/src/lib/channels/telegram/api.js +102 -0
- package/harness/packages/server/src/lib/channels/telegram/commands.js +309 -0
- package/harness/packages/server/src/lib/channels/telegram/media.js +64 -0
- package/harness/packages/server/src/lib/channels/telegram/permissions.js +0 -0
- package/harness/packages/server/src/lib/channels/telegram/projects.js +102 -0
- package/harness/packages/server/src/lib/channels/telegram/run.js +206 -0
- package/harness/packages/server/src/lib/channels/telegram/text.js +57 -0
- package/harness/packages/server/src/lib/channels/telegram.js +163 -932
- package/harness/packages/server/src/lib/presence.js +12 -1
- package/harness/packages/server/src/lib/scheduler.js +12 -2
- package/harness/packages/server/src/lib/tasks.js +21 -3
- package/harness/packages/server/src/lib/workspace.js +90 -2
- package/harness/packages/server/src/routes/auth.js +8 -4
- package/harness/packages/server/src/routes/chat.js +16 -5
- package/harness/packages/server/src/routes/preview.js +10 -2
- package/harness/packages/server/src/routes/providers.js +14 -9
- package/harness/packages/server/src/server.js +25 -0
- package/harness/packages/server/test/chat.test.js +586 -0
- package/harness/packages/server/test/chatFormat.test.js +119 -0
- package/harness/packages/server/test/consoleModules.test.js +301 -0
- package/harness/packages/server/test/consoleSmoke.test.js +34 -0
- package/harness/packages/server/test/helpers.js +121 -0
- package/harness/packages/server/test/ops.test.js +515 -0
- package/harness/packages/server/test/runs.test.js +816 -0
- package/harness/packages/server/test/security.test.js +112 -9
- package/harness/packages/server/test/sessions.test.js +437 -0
- package/harness/packages/server/test/settings.test.js +482 -0
- package/harness/scripts/canary.mjs +24 -0
- package/harness/tamnora.example.json +1 -1
- package/package.json +3 -2
- package/harness/packages/server/test/server.test.js +0 -2885
|
@@ -1,9 +1,36 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
|
|
4
|
+
/**
|
|
5
|
+
* realpath del segmento existente más profundo, más el resto del camino (que
|
|
6
|
+
* todavía no existe): `a/b/c` con `a/b` real devuelve `<real(a/b)>/c`.
|
|
7
|
+
*/
|
|
8
|
+
export function realpathDeep(target) {
|
|
9
|
+
let current = target;
|
|
10
|
+
const rest = [];
|
|
11
|
+
for (;;) {
|
|
12
|
+
try {
|
|
13
|
+
const real = fs.realpathSync(current);
|
|
14
|
+
return rest.length ? path.join(real, ...rest.reverse()) : real;
|
|
15
|
+
} catch {
|
|
16
|
+
const parent = path.dirname(current);
|
|
17
|
+
if (parent === current) return target;
|
|
18
|
+
rest.push(path.basename(current));
|
|
19
|
+
current = parent;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function escapes(base, target) {
|
|
25
|
+
const rel = path.relative(base, target);
|
|
26
|
+
return rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
|
|
27
|
+
}
|
|
28
|
+
|
|
3
29
|
/**
|
|
4
30
|
* Resolve a user-supplied path against the workspace root, refusing to escape
|
|
5
|
-
* it.
|
|
6
|
-
*
|
|
31
|
+
* it. Two guards: a lexical one (`..`, absolute paths outside the root, drive
|
|
32
|
+
* changes) and a real one over `fs.realpath`, because a symlink inside the
|
|
33
|
+
* workspace can point anywhere and the lexical check cannot see it.
|
|
7
34
|
*
|
|
8
35
|
* @param {string} root - absolute workspace root
|
|
9
36
|
* @param {string} input - relative (or absolute-inside-root) path
|
|
@@ -14,11 +41,14 @@ export function resolveInRoot(root, input) {
|
|
|
14
41
|
const raw = String(input ?? '.').trim() || '.';
|
|
15
42
|
const target = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(base, raw);
|
|
16
43
|
|
|
17
|
-
|
|
18
|
-
const escapes = rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
|
|
19
|
-
if (escapes) {
|
|
44
|
+
if (escapes(base, target)) {
|
|
20
45
|
throw new Error(`Ruta fuera del workspace: "${input}"`);
|
|
21
46
|
}
|
|
47
|
+
const realBase = realpathDeep(base);
|
|
48
|
+
const realTarget = realpathDeep(target);
|
|
49
|
+
if (escapes(realBase, realTarget)) {
|
|
50
|
+
throw new Error(`Ruta fuera del workspace (symlink): "${input}"`);
|
|
51
|
+
}
|
|
22
52
|
return target;
|
|
23
53
|
}
|
|
24
54
|
|
|
@@ -58,7 +58,6 @@ function subjectFor(name, args) {
|
|
|
58
58
|
// `ask` = nunca se autoaprueba (siempre consulta, aunque bash sea `allow`).
|
|
59
59
|
const BASH_DENY = [
|
|
60
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
61
|
/\bmkfs(\.|\s|$)/i,
|
|
63
62
|
/\bdd\b[^\n]*\bof=\/dev\//i,
|
|
64
63
|
/>\s*\/dev\/(sd|nvme|disk)/i,
|
|
@@ -67,18 +66,58 @@ const BASH_DENY = [
|
|
|
67
66
|
/\b(?:remove-item|del|rd|rmdir|format)\b[^\n]*(?:-recurse|-r\b)[^\n]*\b[a-z]:\\?\s*$/i,
|
|
68
67
|
];
|
|
69
68
|
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
69
|
/\bgit\s+push\b[^\n]*(\s-f\b|\s--force\b)/i,
|
|
72
70
|
/\bgit\s+reset\s+--hard\b/i,
|
|
73
71
|
/\bgit\s+clean\s+-[a-z]*f/i,
|
|
74
72
|
];
|
|
75
73
|
|
|
74
|
+
// `rm` con flags peligrosos: se parsean los tokens en vez de mirar la grafia,
|
|
75
|
+
// porque `rm --recursive --force /`, `rm -r -f /` y `rm -rf /` son lo mismo y
|
|
76
|
+
// las regex de un solo cluster de flags solo cazaban la ultima.
|
|
77
|
+
const RM_DEADLY_TARGETS = /^(\/|\/\*|\/\.\.|~|\$HOME|\$\{HOME\}|[a-z]:\\?(\*)?)$/i;
|
|
78
|
+
const RM_WORKSPACE_TARGETS = /^(\*|\.{1,2}|\.\/?)$/i;
|
|
79
|
+
|
|
80
|
+
/** Invocaciones de `rm` del comando: flags normalizados + primer argumento. */
|
|
81
|
+
function rmInvocations(command) {
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const segment of String(command).split(/[;&|\n]/)) {
|
|
84
|
+
const m = /(?:^|\s)(?:sudo\s+)?rm\s+(.*)$/i.exec(segment);
|
|
85
|
+
if (!m) continue;
|
|
86
|
+
let recursive = false;
|
|
87
|
+
let force = false;
|
|
88
|
+
let target = '';
|
|
89
|
+
for (const token of m[1].trim().split(/\s+/).filter(Boolean)) {
|
|
90
|
+
if (token === '--') continue;
|
|
91
|
+
if (token.startsWith('--')) {
|
|
92
|
+
if (/^--(recursive|no-preserve-root)$/i.test(token)) recursive = true;
|
|
93
|
+
if (/^--force$/i.test(token)) force = true;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (token.startsWith('-')) {
|
|
97
|
+
const letters = token.slice(1);
|
|
98
|
+
if (/[rR]/.test(letters)) recursive = true;
|
|
99
|
+
if (/f/.test(letters)) force = true;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
target = token.replace(/^["']|["']$/g, '');
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
out.push({ recursive, force, target });
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
76
110
|
/** Devuelve `'deny'`/`'ask'` si el comando es peligroso, o `null`. */
|
|
77
111
|
export function bashGuard(command) {
|
|
78
112
|
const text = String(command ?? '');
|
|
79
113
|
if (!text.trim()) return null;
|
|
80
114
|
for (const re of BASH_DENY) if (re.test(text)) return 'deny';
|
|
81
115
|
for (const re of BASH_ASK) if (re.test(text)) return 'ask';
|
|
116
|
+
for (const rm of rmInvocations(text)) {
|
|
117
|
+
if (!rm.recursive) continue;
|
|
118
|
+
if (RM_DEADLY_TARGETS.test(rm.target)) return 'deny';
|
|
119
|
+
if (rm.force && RM_WORKSPACE_TARGETS.test(rm.target)) return 'ask';
|
|
120
|
+
}
|
|
82
121
|
return null;
|
|
83
122
|
}
|
|
84
123
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { TOOL_DEFINITIONS, TOOL_NAMES } from './schema.js';
|
|
3
3
|
import { EXECUTORS } from './executors.js';
|
|
4
|
-
import { evaluatePermission } from './permissions.js';
|
|
4
|
+
import { evaluatePermission, bashGuard } from './permissions.js';
|
|
5
5
|
|
|
6
6
|
const WRITE_TOOLS = new Set(['write', 'edit']);
|
|
7
7
|
|
|
@@ -24,7 +24,9 @@ function extensionOf(p) {
|
|
|
24
24
|
* @param {AbortSignal} [opts.signal] - cancela las tools de larga duracion (bash, git)
|
|
25
25
|
*/
|
|
26
26
|
export function createToolRegistry({ root, enabled, permissions = {}, onAsk, onQuestion, yes = false, signal, writeExtensions } = {}) {
|
|
27
|
-
|
|
27
|
+
// `enabled` undefined = todas; una lista se respeta tal cual (vacía = ninguna,
|
|
28
|
+
// no "todas": un modo restringido no puede quedar sin tools por un filtro).
|
|
29
|
+
const names = new Set(Array.isArray(enabled) ? enabled : TOOL_NAMES);
|
|
28
30
|
const definitions = TOOL_DEFINITIONS.filter((d) => names.has(d.function.name));
|
|
29
31
|
const allowedWrite = Array.isArray(writeExtensions) && writeExtensions.length
|
|
30
32
|
? new Set(writeExtensions.map((ext) => String(ext).toLowerCase()))
|
|
@@ -38,8 +40,11 @@ export function createToolRegistry({ root, enabled, permissions = {}, onAsk, onQ
|
|
|
38
40
|
if (!writeAllowed(name, args)) return 'deny';
|
|
39
41
|
let decision = evaluatePermission(name, args, permissions);
|
|
40
42
|
if (decision === 'ask') {
|
|
43
|
+
// Un `ask` del piso de seguridad (bashGuard) nunca se autoaprueba con
|
|
44
|
+
// `yes`: sin UI que pregunte (onAsk) queda denegado.
|
|
45
|
+
const guarded = name === 'bash' && bashGuard(args?.command) === 'ask';
|
|
41
46
|
if (onAsk) decision = (await onAsk(name, args)) ? 'allow' : 'deny';
|
|
42
|
-
else decision = yes ? 'allow' : 'deny';
|
|
47
|
+
else decision = !guarded && yes ? 'allow' : 'deny';
|
|
43
48
|
}
|
|
44
49
|
return decision;
|
|
45
50
|
}
|
|
@@ -18,8 +18,10 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
18
18
|
/** Token publico del cliente de Edge (via en el codigo del navegador). */
|
|
19
19
|
export const TRUSTED_CLIENT_TOKEN = '6A5AA1D4EAFF4E9FB37E23D68491D6F4';
|
|
20
20
|
|
|
21
|
-
/** Version de Edge que declaramos
|
|
22
|
-
|
|
21
|
+
/** Version de Edge que declaramos (UA + `Sec-MS-GEC-Version`). Tiene que acompanar
|
|
22
|
+
* al endpoint que se use: el de `api.msedgeservices.com` espera un Edge moderno.
|
|
23
|
+
* Si el servicio empieza a rechazar, subirla o pisarla con `TTS_EDGE_VERSION`. */
|
|
24
|
+
export const EDGE_VERSION = process.env.TTS_EDGE_VERSION ?? '155.0.3000.102';
|
|
23
25
|
|
|
24
26
|
/** Formato por defecto: MP3 a 24 kHz, 48 kbps, mono (CBR). */
|
|
25
27
|
export const FORMATO_POR_DEFECTO = 'audio-24khz-48kbitrate-mono-mp3';
|
|
@@ -122,6 +124,30 @@ function marcaDeTiempo(ahora = new Date()) {
|
|
|
122
124
|
* @returns {Promise<Buffer>} MP3.
|
|
123
125
|
*/
|
|
124
126
|
export function sintetizar(texto, opciones = {}) {
|
|
127
|
+
// Estas validaciones fallan sin abrir socket, asi que las decide el llamador
|
|
128
|
+
// (y se propagan igual que siempre: en seco, no como promesa rechazada).
|
|
129
|
+
const contenido = String(texto ?? '').slice(0, MAX_TEXTO).trim();
|
|
130
|
+
if (!contenido) throw new Error('El texto está vacío: no hay nada que sintetizar.');
|
|
131
|
+
if (opciones?.signal?.aborted) throw new Error('La síntesis se canceló antes de empezar.');
|
|
132
|
+
return reintentar(() => sintetizarUnaVez(texto, opciones), opciones);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Reintenta mientras el fallo sea del transporte y no haya llegado audio. */
|
|
136
|
+
function reintentar(intento, opciones) {
|
|
137
|
+
const reintentos = Math.max(0, Number(opciones?.reintentos ?? 1));
|
|
138
|
+
const pausaMs = Math.max(0, Number(opciones?.pausaReintentoMs ?? 500));
|
|
139
|
+
return new Promise((resolve, reject) => {
|
|
140
|
+
const correr = (restante) => {
|
|
141
|
+
intento().then(resolve, (error) => {
|
|
142
|
+
if (!restante || !error?.reintento) return reject(error);
|
|
143
|
+
setTimeout(() => correr(restante - 1), pausaMs);
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
correr(reintentos);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function sintetizarUnaVez(texto, opciones = {}) {
|
|
125
151
|
const {
|
|
126
152
|
voice: voz = VOZ_POR_DEFECTO,
|
|
127
153
|
rate = '+0%',
|
|
@@ -133,14 +159,13 @@ export function sintetizar(texto, opciones = {}) {
|
|
|
133
159
|
} = opciones;
|
|
134
160
|
|
|
135
161
|
const contenido = String(texto ?? '').slice(0, MAX_TEXTO).trim();
|
|
136
|
-
if (!contenido) throw new Error('El texto está vacío: no hay nada que sintetizar.');
|
|
137
|
-
if (signal?.aborted) throw new Error('La síntesis se canceló antes de empezar.');
|
|
138
162
|
if (typeof WebSocket === 'undefined') {
|
|
139
163
|
throw new Error('Este Node no trae WebSocket global (se necesita Node 22+).');
|
|
140
164
|
}
|
|
141
165
|
|
|
142
166
|
return new Promise((resolve, reject) => {
|
|
143
167
|
const chunks = [];
|
|
168
|
+
let abrio = false;
|
|
144
169
|
const ws = new WebSocket(urlSintesis(), {
|
|
145
170
|
origin: ORIGEN_EDGE,
|
|
146
171
|
headers: {
|
|
@@ -166,6 +191,10 @@ export function sintetizar(texto, opciones = {}) {
|
|
|
166
191
|
} catch {
|
|
167
192
|
// ya cerrado
|
|
168
193
|
}
|
|
194
|
+
// Reintenta solo si el fallo fue del transporte antes de llegar audio: un
|
|
195
|
+
// corte a mitad de stream seria un audio truncado, y el servicio que
|
|
196
|
+
// rechaza un formato lo va a volver a rechazar.
|
|
197
|
+
if (!abrio && chunks.length === 0 && !signal?.aborted) error.reintento = true;
|
|
169
198
|
reject(error);
|
|
170
199
|
};
|
|
171
200
|
|
|
@@ -180,6 +209,7 @@ export function sintetizar(texto, opciones = {}) {
|
|
|
180
209
|
vigilar();
|
|
181
210
|
|
|
182
211
|
ws.addEventListener('open', () => {
|
|
212
|
+
abrio = true;
|
|
183
213
|
ws.send(
|
|
184
214
|
`X-Timestamp:${marcaDeTiempo()}\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n` +
|
|
185
215
|
JSON.stringify({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import { bridgeStatePath } from './config/home.js';
|
|
4
4
|
import { pidAlive, killPidTree } from './pid.js';
|
|
5
5
|
|
|
@@ -18,9 +18,10 @@ function killTree(child) {
|
|
|
18
18
|
* Estado del bridge (escrito por `tamnora bridge`), si el supervisor sigue
|
|
19
19
|
* vivo. Lo consulta el server para saber que hay un tunel publico activo y
|
|
20
20
|
* ajustar el modelo de confianza (guards locales, cookie Secure).
|
|
21
|
-
* Cache de 1 s: se lee en cada request
|
|
21
|
+
* Cache de 1 s + invalidacion por mtime del archivo: se lee en cada request
|
|
22
|
+
* sensible, pero un bridge.json nuevo (o borrado) se ve al instante.
|
|
22
23
|
*/
|
|
23
|
-
let tunnelCache = { key: null, at: 0, state: null };
|
|
24
|
+
let tunnelCache = { key: null, mtime: null, at: 0, state: null };
|
|
24
25
|
export function tunnelState() {
|
|
25
26
|
let statePath;
|
|
26
27
|
try {
|
|
@@ -29,7 +30,16 @@ export function tunnelState() {
|
|
|
29
30
|
return null;
|
|
30
31
|
}
|
|
31
32
|
const now = Date.now();
|
|
32
|
-
|
|
33
|
+
let mtime = null;
|
|
34
|
+
try {
|
|
35
|
+
mtime = statSync(statePath).mtimeMs;
|
|
36
|
+
} catch {
|
|
37
|
+
tunnelCache = { key: statePath, mtime: null, at: now, state: null };
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (tunnelCache.key === statePath && tunnelCache.mtime === mtime && now - tunnelCache.at < 1000) {
|
|
41
|
+
return tunnelCache.state;
|
|
42
|
+
}
|
|
33
43
|
let state = null;
|
|
34
44
|
try {
|
|
35
45
|
const parsed = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
@@ -39,7 +49,7 @@ export function tunnelState() {
|
|
|
39
49
|
} catch {
|
|
40
50
|
/* sin bridge */
|
|
41
51
|
}
|
|
42
|
-
tunnelCache = { key: statePath, at: now, state };
|
|
52
|
+
tunnelCache = { key: statePath, mtime, at: now, state };
|
|
43
53
|
return state;
|
|
44
54
|
}
|
|
45
55
|
|
|
@@ -376,6 +376,24 @@ test('runAgent propaga maxOutputTokens al proveedor', async () => {
|
|
|
376
376
|
assert.deepEqual(seen, [1234]);
|
|
377
377
|
});
|
|
378
378
|
|
|
379
|
+
test('runAgent propaga promptCaching al proveedor', async () => {
|
|
380
|
+
const seen = [];
|
|
381
|
+
const provider = {
|
|
382
|
+
async streamChat(_config, params) {
|
|
383
|
+
seen.push(params.promptCaching);
|
|
384
|
+
return { content: 'ok', reasoning: '', toolCalls: [] };
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
await runAgent({
|
|
388
|
+
provider,
|
|
389
|
+
config: {},
|
|
390
|
+
model: 'm',
|
|
391
|
+
messages: [{ role: 'user', content: 'x' }],
|
|
392
|
+
promptCaching: true,
|
|
393
|
+
});
|
|
394
|
+
assert.deepEqual(seen, [true]);
|
|
395
|
+
});
|
|
396
|
+
|
|
379
397
|
test('runAgent emite el ciclo de vida (run.start/end, tool.before/after)', async () => {
|
|
380
398
|
const life = [];
|
|
381
399
|
const provider = {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Canary de los servicios no oficiales (TTS de Edge y STT de Google). Son los
|
|
2
|
+
// unicos que dependen de un servicio externo sin contrato: si cambian el
|
|
3
|
+
// protocolo o la clave, el unico sintoma real es un 403. Los tests con mocks
|
|
4
|
+
// no lo ven, asi que estos prueban el camino real, pero solo con TAMNORA_LIVE=1
|
|
5
|
+
// (piden red y a servicios que pueden estar caidos o barrerte).
|
|
6
|
+
//
|
|
7
|
+
// TAMNORA_LIVE=1 node --test harness/packages/core/test/servicios-vivos.test.js
|
|
8
|
+
//
|
|
9
|
+
// El servicio de Edge solo devuelve MP3 (los formatos riff-/raw- cierran la
|
|
10
|
+
// conexion antes de mandar audio), asi que el round trip se hace con ffmpeg en
|
|
11
|
+
// el medio para convertir el MP3 a WAV.
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { generarSecMsGec, listarVoces, reconocerGoogle, sintetizar, VOZ_POR_DEFECTO } from '../src/index.js';
|
|
16
|
+
|
|
17
|
+
const LIVE = process.env.TAMNORA_LIVE === '1';
|
|
18
|
+
const liveOpts = LIVE ? {} : { skip: 'red real: correr con TAMNORA_LIVE=1' };
|
|
19
|
+
|
|
20
|
+
/** MP3 -> WAV 16 kHz mono con ffmpeg. `null` si no hay ffmpeg. */
|
|
21
|
+
async function mp3AWav(mp3) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const hijo = spawn(
|
|
24
|
+
'ffmpeg',
|
|
25
|
+
['-hide_banner', '-loglevel', 'error', '-i', 'pipe:0', '-f', 'wav', '-ar', '16000', '-ac', '1', 'pipe:1'],
|
|
26
|
+
{ stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true },
|
|
27
|
+
);
|
|
28
|
+
const salida = [];
|
|
29
|
+
hijo.stdout.on('data', (b) => salida.push(b));
|
|
30
|
+
hijo.on('error', () => resolve(null));
|
|
31
|
+
hijo.on('close', (code) => resolve(code === 0 && salida.length ? Buffer.concat(salida) : null));
|
|
32
|
+
hijo.stdin.on('error', () => {});
|
|
33
|
+
hijo.stdin.end(mp3);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('sintetizar entrega un MP3 real (canary)', liveOpts, async () => {
|
|
38
|
+
const mp3 = await sintetizar('hola', { voice: VOZ_POR_DEFECTO });
|
|
39
|
+
assert.ok(Buffer.isBuffer(mp3), 'deberia devolver un Buffer');
|
|
40
|
+
assert.ok(mp3.length > 1000, `MP3 demasiado chico: ${mp3.length} bytes`);
|
|
41
|
+
assert.equal(mp3[0], 0xff, 'sin frame de MP3: el servicio cambio de formato');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('el catalogo de voces responde con la voz por defecto (canary)', liveOpts, async () => {
|
|
45
|
+
await esperar(3000);
|
|
46
|
+
const voces = await listarVoces('es-');
|
|
47
|
+
assert.ok(Array.isArray(voces) && voces.length > 0, 'el catalogo vino vacio');
|
|
48
|
+
const nombres = voces.map((v) => v.ShortName ?? v.name ?? v);
|
|
49
|
+
assert.ok(nombres.includes(VOZ_POR_DEFECTO), `falta ${VOZ_POR_DEFECTO} en ${JSON.stringify(nombres.slice(0, 3))}`);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Espera ms antes de abrir otro socket: el servicio de Edge se queja (fallo de
|
|
53
|
+
* handshake) si se le abren varias conexiones seguidas. */
|
|
54
|
+
const esperar = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
55
|
+
|
|
56
|
+
test('ida y vuelta: el audio del TTS se entiende en el STT (canary)', liveOpts, async () => {
|
|
57
|
+
await esperar(3000);
|
|
58
|
+
const mp3 = await sintetizar('hola mundo de prueba', { voice: VOZ_POR_DEFECTO });
|
|
59
|
+
const wav = await mp3AWav(mp3);
|
|
60
|
+
if (!wav) {
|
|
61
|
+
// Sin ffmpeg no hay WAV con el que hacer el viaje: se probo arriba el camino
|
|
62
|
+
// del TTS, que es el que mas se rompe.
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const texto = await reconocerGoogle(wav, { lang: 'es-AR' });
|
|
66
|
+
assert.ok(/hola/.test(texto), `se entiende otra cosa: "${texto}"`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('el anti-abuso de Edge sigue generando tokens de 64 hex', () => {
|
|
70
|
+
assert.match(generarSecMsGec(), /^[0-9A-F]{64}$/);
|
|
71
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { mkdtemp, readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { mkdtemp, readFile, writeFile, mkdir, symlink } 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';
|
|
@@ -8,6 +8,7 @@ import { evaluatePermission, bashGuard } from '../src/tools/permissions.js';
|
|
|
8
8
|
import { createTools, createToolRegistry } from '../src/tools/index.js';
|
|
9
9
|
import {
|
|
10
10
|
fetchUrlTool,
|
|
11
|
+
gitDiffTool,
|
|
11
12
|
previewUrlTool,
|
|
12
13
|
screenshotTool,
|
|
13
14
|
consoleTool,
|
|
@@ -61,6 +62,48 @@ test('bashGuard marca comandos peligrosos (piso de seguridad)', () => {
|
|
|
61
62
|
assert.equal(evaluatePermission('bash', { command: 'rm -rf ./dist' }, { bash: 'allow' }), 'allow');
|
|
62
63
|
});
|
|
63
64
|
|
|
65
|
+
test('bashGuard tolera grafías alternativas de flags en rm', () => {
|
|
66
|
+
assert.equal(bashGuard('rm --recursive --force /'), 'deny');
|
|
67
|
+
assert.equal(bashGuard('rm -r -f /'), 'deny');
|
|
68
|
+
assert.equal(bashGuard('rm -fr /'), 'deny');
|
|
69
|
+
assert.equal(bashGuard('rm -r -f ~'), 'deny');
|
|
70
|
+
assert.equal(bashGuard('rm --recursive --force *'), 'ask');
|
|
71
|
+
assert.equal(bashGuard('rm -r -f .'), 'ask');
|
|
72
|
+
assert.equal(bashGuard('rm -rf ./build'), null);
|
|
73
|
+
assert.equal(bashGuard('rm archivo.txt'), null);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('el tier ask del piso de seguridad no se autoaprueba con yes', async () => {
|
|
77
|
+
const root = await makeWorkspace();
|
|
78
|
+
const auto = createToolRegistry({ root, permissions: { bash: 'allow' }, yes: true });
|
|
79
|
+
assert.equal(await auto.authorize('bash', { command: 'rm -rf *' }), 'deny', 'yes no saltea la guarda');
|
|
80
|
+
|
|
81
|
+
const interactive = createToolRegistry({ root, permissions: { bash: 'allow' }, onAsk: async () => true });
|
|
82
|
+
assert.equal(await interactive.authorize('bash', { command: 'rm -rf *' }), 'allow', 'onAsk si puede aprobar');
|
|
83
|
+
|
|
84
|
+
// Con `yes`, un ask normal (sin guarda) sigue autoaprobando.
|
|
85
|
+
assert.equal(await auto.authorize('write', { path: 'x.txt' }), 'allow');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('git_diff rechaza refs que parecen opciones', async () => {
|
|
89
|
+
const root = await makeWorkspace();
|
|
90
|
+
const result = await gitDiffTool(root, { ref: '--output=/tmp/inyectado' });
|
|
91
|
+
assert.match(result, /ref.*inválido/i);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('resolveInRoot no deja escapar por symlink', async () => {
|
|
95
|
+
const root = await makeWorkspace();
|
|
96
|
+
const outside = await mkdtemp(path.join(tmpdir(), 'tamnora-fuera-'));
|
|
97
|
+
await writeFile(path.join(outside, 'secreto.txt'), 'no tocar');
|
|
98
|
+
try {
|
|
99
|
+
await symlink(outside, path.join(root, 'link'), 'junction');
|
|
100
|
+
} catch {
|
|
101
|
+
return; // el fs no permite crear symlinks: nada que probar
|
|
102
|
+
}
|
|
103
|
+
assert.throws(() => resolveInRoot(root, 'link/secreto.txt'), /fuera del workspace/);
|
|
104
|
+
assert.equal(toRel(root, resolveInRoot(root, 'src/a.js')), 'src/a.js');
|
|
105
|
+
});
|
|
106
|
+
|
|
64
107
|
test('local tools read, write, list, glob, grep and edit', async () => {
|
|
65
108
|
const root = await makeWorkspace();
|
|
66
109
|
const reg = createToolRegistry({ root, permissions: { write: 'allow', edit: 'allow' } });
|
|
@@ -39,3 +39,84 @@ test('sintetizar recorta el texto al tope del servicio', () => {
|
|
|
39
39
|
const largo = 'x'.repeat(MAX_TEXTO + 100);
|
|
40
40
|
assert.throws(() => sintetizar(largo, { signal: controller.signal }), /cancel/);
|
|
41
41
|
});
|
|
42
|
+
|
|
43
|
+
/** WebSocket falso: `guion` dice que hace cada intento ('error' o 'audio'). */
|
|
44
|
+
function mockWebSocket(guion) {
|
|
45
|
+
const original = globalThis.WebSocket;
|
|
46
|
+
let intentos = 0;
|
|
47
|
+
globalThis.WebSocket = class {
|
|
48
|
+
constructor() {
|
|
49
|
+
this.listeners = new Map();
|
|
50
|
+
this.binaryType = 'arraybuffer';
|
|
51
|
+
const paso = guion[Math.min(intentos, guion.length - 1)];
|
|
52
|
+
intentos += 1;
|
|
53
|
+
setTimeout(() => {
|
|
54
|
+
// 'fallo' nunca llega a abrirse (fallo de transporte); 'error' abre la
|
|
55
|
+
// conexion y recien ahi falla (el servicio rechaza algo).
|
|
56
|
+
if (paso !== 'fallo') this.fire('open', {});
|
|
57
|
+
if (paso === 'error' || paso === 'fallo') {
|
|
58
|
+
this.fire('error', {});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
this.fire('message', new TextEncoder().encode('Path:audio\r\nFRAME').buffer);
|
|
62
|
+
this.fire('message', 'X-RequestId:1\r\nPath:turn.end\r\n');
|
|
63
|
+
}, 0);
|
|
64
|
+
}
|
|
65
|
+
addEventListener(type, fn) {
|
|
66
|
+
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
|
67
|
+
this.listeners.get(type).add(fn);
|
|
68
|
+
}
|
|
69
|
+
removeEventListener() {}
|
|
70
|
+
send() {}
|
|
71
|
+
close() {
|
|
72
|
+
this.fire('close', {});
|
|
73
|
+
}
|
|
74
|
+
fire(type, arg) {
|
|
75
|
+
for (const fn of this.listeners.get(type) ?? []) fn({ data: arg, message: 'fake' });
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
get intentos() {
|
|
80
|
+
return intentos;
|
|
81
|
+
},
|
|
82
|
+
restaurar: () => {
|
|
83
|
+
globalThis.WebSocket = original;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
test('sintetizar reintenta un fallo de conexion y devuelve el audio', async () => {
|
|
89
|
+
const mock = mockWebSocket(['fallo', 'audio']);
|
|
90
|
+
try {
|
|
91
|
+
const mp3 = await sintetizar('hola', { pausaReintentoMs: 5 });
|
|
92
|
+
assert.equal(mp3.toString(), 'FRAME');
|
|
93
|
+
assert.equal(mock.intentos, 2);
|
|
94
|
+
} finally {
|
|
95
|
+
mock.restaurar();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('sintetizar no reintenta cuando el fallo es del propio servicio', async () => {
|
|
100
|
+
// Con la conexion abierta el servicio ya decidio: volver a conectar sirve
|
|
101
|
+
// para un corte de red, no para un rechazo.
|
|
102
|
+
const mock = mockWebSocket(['error']);
|
|
103
|
+
try {
|
|
104
|
+
await assert.rejects(() => sintetizar('hola', { reintentos: 5, pausaReintentoMs: 5 }), /WebSocket/);
|
|
105
|
+
assert.equal(mock.intentos, 1);
|
|
106
|
+
} finally {
|
|
107
|
+
mock.restaurar();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('sintetizar no reintenta si el usuario cancela', async () => {
|
|
112
|
+
const mock = mockWebSocket(['fallo']);
|
|
113
|
+
const controller = new AbortController();
|
|
114
|
+
try {
|
|
115
|
+
const enCurso = sintetizar('hola', { reintentos: 5, pausaReintentoMs: 5, signal: controller.signal });
|
|
116
|
+
controller.abort();
|
|
117
|
+
await assert.rejects(() => enCurso, /cancel/);
|
|
118
|
+
assert.ok(mock.intentos <= 1, `intentos: ${mock.intentos}`);
|
|
119
|
+
} finally {
|
|
120
|
+
mock.restaurar();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
// node:sqlite sigue siendo "experimental" en Node 22, que avisa aunque ande. El
|
|
3
|
+
// aviso no le aporta nada al usuario, asi que se filtra aca (lo que no sea un
|
|
4
|
+
// warning de Node se imprime igual).
|
|
5
|
+
process.on('warning', (warning) => {
|
|
6
|
+
if (warning.name !== 'ExperimentalWarning') console.warn(warning.stack ?? warning);
|
|
7
|
+
});
|
|
8
|
+
const { serveCommand } = await import('../src/cli.js');
|
|
3
9
|
|
|
4
10
|
serveCommand(process.argv.slice(2))
|
|
5
11
|
.then((code) => {
|