@danieltmn/tamnora 0.22.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +3 -1
- package/SECURITY.md +1 -1
- package/harness/packages/cli/bin/tamnora.mjs +9 -1
- package/harness/packages/cli/src/commands/bridge.js +3 -3
- 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/commands/update.js +13 -2
- package/harness/packages/cli/src/lib/runtime.js +2 -0
- package/harness/packages/cli/src/tui/app.js +2 -0
- package/harness/packages/cli/test/bin-warning.test.js +27 -0
- package/harness/packages/cli/test/tunnel.test.js +49 -1
- package/harness/packages/core/src/browser/index.js +27 -13
- package/harness/packages/core/src/index.js +1 -1
- package/harness/packages/core/src/tts/edge.js +34 -4
- package/harness/packages/core/src/tunnel.js +32 -9
- 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/tts.test.js +81 -0
- package/harness/packages/server/bin/tamnora-server.mjs +9 -1
- package/harness/packages/server/public/app.js +4 -3
- package/harness/packages/server/public/cards.js +470 -0
- package/harness/packages/server/public/chat.js +1343 -1893
- package/harness/packages/server/public/chatFormat.js +156 -0
- package/harness/packages/server/public/chrome.js +5 -4
- 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 +22 -23
- package/harness/packages/server/public/settings.js +4 -0
- package/harness/packages/server/public/styles.css +20 -50
- 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 -933
- package/harness/packages/server/src/lib/procs.js +4 -1
- package/harness/packages/server/src/lib/scheduler.js +1 -0
- package/harness/packages/server/src/lib/tasks.js +1 -0
- package/harness/packages/server/src/routes/chat.js +1 -1
- package/harness/packages/server/src/routes/providers.js +2 -0
- package/harness/packages/server/src/server.js +6 -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 +15 -1
- package/harness/packages/server/test/ops.test.js +9 -5
- package/harness/packages/server/test/procs-manager.test.js +17 -0
- package/harness/packages/server/test/settings.test.js +18 -1
- package/harness/scripts/canary.mjs +24 -0
- package/harness/scripts/self-update.mjs +4 -1
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,31 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
|
|
|
5
5
|
El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
|
|
6
6
|
y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
|
|
7
7
|
|
|
8
|
+
## [0.23.1] - 2026-10-28
|
|
9
|
+
|
|
10
|
+
### Cambios
|
|
11
|
+
|
|
12
|
+
- fix(spawn): evita el warning DEP0190 y hace puntual el stop del bridge
|
|
13
|
+
|
|
14
|
+
## [0.23.0] - 2026-10-28
|
|
15
|
+
|
|
16
|
+
### Cambios
|
|
17
|
+
|
|
18
|
+
- feat(ajustes): expone maxOutputTokens y promptCaching en la pestana Limites
|
|
19
|
+
- feat(consola): el logo acompana el esquema del tema, y el browser puede emular color y correr un pre-script
|
|
20
|
+
- refactor(consola): extrae las tarjetas de chat.js a cards.js
|
|
21
|
+
- test(consola): valida el grafo de modulos y la carga real en navegador headless
|
|
22
|
+
- feat: canary de servicios vivos en pnpm y en cron de GitHub
|
|
23
|
+
- feat(doctor): --voz prueba el servicio de voz y distingue voz rota de servicio caido
|
|
24
|
+
- fix(voz): reintenta un fallo de conexion de la sintesis, sin reintentar rechazos
|
|
25
|
+
- fix(test): el arranque de procs espera la condicion, no un tiempo fijo
|
|
26
|
+
- refactor(consola): extrae el formato puro de chat.js a chatFormat.js, con tests
|
|
27
|
+
- refactor(telegram): parte el canal en api, text, permissions, media, projects, run y commands
|
|
28
|
+
- fix(voz): EDGE_VERSION acompana al endpoint nuevo y agrega un canary de servicios vivos
|
|
29
|
+
- chore: quita document.md desactualizado (decia 0.11.3) y normaliza los fin de linea
|
|
30
|
+
- fix: node:sqlite necesita Node >= 22.13 y el warning experimental se filtra en los bins
|
|
31
|
+
- refactor(consola): unifica la navegacion en el menu mas y devuelve el foco al cerrarlo
|
|
32
|
+
|
|
8
33
|
## [0.22.0] - 2026-10-22
|
|
9
34
|
|
|
10
35
|
### Cambios
|
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ npx @danieltmn/tamnora --help
|
|
|
40
40
|
npm i -g @danieltmn/tamnora
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
Requiere **Node.js >= 22.
|
|
43
|
+
Requiere **Node.js >= 22.13** (por `node:sqlite`).
|
|
44
44
|
|
|
45
45
|
## Uso rapido
|
|
46
46
|
|
|
@@ -184,6 +184,8 @@ Los tres se renderizan en vivo y se reconstruyen al recargar la consola.
|
|
|
184
184
|
server resume lo viejo y sigue con el resumen + lo reciente, sin borrar nada.
|
|
185
185
|
- **`max_tokens` por ronda** (`maxOutputTokens`) y **prompt caching** opt-in
|
|
186
186
|
(`promptCaching`, `cache_control` en Anthropic y `prompt_cache_key` en OpenAI).
|
|
187
|
+
Ambos se ajustan en la pestaña **Límites** de la consola o con `PUT /api/settings`
|
|
188
|
+
(no solo por `tamnora.json`).
|
|
187
189
|
|
|
188
190
|
### Hooks y plugins
|
|
189
191
|
|
package/SECURITY.md
CHANGED
|
@@ -59,7 +59,7 @@ asi que se lo debe tratar como codigo con acceso a tu usuario.
|
|
|
59
59
|
|
|
60
60
|
## Buenas practicas
|
|
61
61
|
|
|
62
|
-
- Mantene Node actualizado (`node:sqlite` requiere Node >= 22.
|
|
62
|
+
- Mantene Node actualizado (`node:sqlite` requiere Node >= 22.13).
|
|
63
63
|
- No commitees `.env`, `tamnora.json`, bases `.db` ni `.npmrc`; estan en
|
|
64
64
|
`.gitignore`.
|
|
65
65
|
- Rota cualquier credencial que se haya compartido en texto plano.
|
|
@@ -1,5 +1,13 @@
|
|
|
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. Se quita el
|
|
4
|
+
// impresor default de Node para que lo que si se imprime (lo que no sea
|
|
5
|
+
// ExperimentalWarning) salga una sola vez, no dos.
|
|
6
|
+
process.removeAllListeners('warning');
|
|
7
|
+
process.on('warning', (warning) => {
|
|
8
|
+
if (warning.name !== 'ExperimentalWarning') console.warn(warning.stack ?? warning);
|
|
9
|
+
});
|
|
10
|
+
const { runCli } = await import('../src/cli.js');
|
|
3
11
|
|
|
4
12
|
runCli(process.argv.slice(2))
|
|
5
13
|
.then((code) => {
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
tailLines,
|
|
12
12
|
bridgeLogPath,
|
|
13
13
|
} from '../lib/daemon.js';
|
|
14
|
-
import { startTunnel, probeTailscale,
|
|
14
|
+
import { startTunnel, probeTailscale, stopTailscaleServe } from '../lib/tunnel.js';
|
|
15
15
|
import { qrTerminal } from '../lib/qr.js';
|
|
16
16
|
|
|
17
17
|
const OPTIONS = {
|
|
@@ -193,10 +193,10 @@ async function cmdStop() {
|
|
|
193
193
|
return 0;
|
|
194
194
|
}
|
|
195
195
|
try {
|
|
196
|
-
await
|
|
196
|
+
await stopTailscaleServe();
|
|
197
197
|
stopped.push('tailscale serve');
|
|
198
198
|
} catch (error) {
|
|
199
|
-
console.log(`No pude
|
|
199
|
+
console.log(`No pude detener tailscale serve: ${error.message}`);
|
|
200
200
|
}
|
|
201
201
|
// Mata el supervisor si quedo vivo.
|
|
202
202
|
if (state.pid && pidAlive(state.pid)) {
|
|
@@ -10,23 +10,28 @@ import {
|
|
|
10
10
|
readPackageVersion,
|
|
11
11
|
compareVersions,
|
|
12
12
|
fetchLatestVersion,
|
|
13
|
+
sintetizar,
|
|
13
14
|
} from '../../../core/src/index.js';
|
|
14
15
|
import { parse } from '../lib/util.js';
|
|
15
16
|
import { pidAlive, readRuntime } from '../lib/daemon.js';
|
|
16
17
|
|
|
17
|
-
const OPTIONS = {
|
|
18
|
+
const OPTIONS = {
|
|
19
|
+
home: { type: 'string' },
|
|
20
|
+
voz: { type: 'boolean' },
|
|
21
|
+
help: { type: 'boolean', short: 'h' },
|
|
22
|
+
};
|
|
18
23
|
|
|
19
24
|
export async function doctorCommand(argv) {
|
|
20
25
|
const { values } = parse(argv, OPTIONS);
|
|
21
26
|
if (values.help) {
|
|
22
|
-
console.log('Uso: tamnora doctor [--home <path>]\n\nVerifica Node, la casa, el puerto y la base.');
|
|
27
|
+
console.log('Uso: tamnora doctor [--home <path>] [--voz]\n\nVerifica Node, la casa, el puerto y la base. Con --voz prueba tambien el servicio de voz.');
|
|
23
28
|
return 0;
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
const [maj, min] = process.versions.node.split('.').map((n) => parseInt(n, 10));
|
|
27
|
-
const nodeOk = maj > 22 || (maj === 22 && min >=
|
|
32
|
+
const nodeOk = maj > 22 || (maj === 22 && min >= 13);
|
|
28
33
|
const checks = [
|
|
29
|
-
[`Node ${process.versions.node}`, nodeOk, nodeOk ? '' : 'requiere Node >= 22.
|
|
34
|
+
[`Node ${process.versions.node}`, nodeOk, nodeOk ? '' : 'requiere Node >= 22.13 (node:sqlite)'],
|
|
30
35
|
];
|
|
31
36
|
|
|
32
37
|
const cfgOk = homeExists();
|
|
@@ -69,6 +74,10 @@ export async function doctorCommand(argv) {
|
|
|
69
74
|
if (serverLive) checks.push([`server (pid ${rt.pid})`, true, rt.url || '']);
|
|
70
75
|
checks.push([`logs`, true, serverLogPath()]);
|
|
71
76
|
|
|
77
|
+
// El servicio de voz es un servicio externo sin contrato: se prueba solo si se
|
|
78
|
+
// pide, porque toca la red y tarda un par de segundos.
|
|
79
|
+
if (values.voz) checks.push(await checkVoz());
|
|
80
|
+
|
|
72
81
|
let ok = true;
|
|
73
82
|
for (const [name, pass, hint] of checks) {
|
|
74
83
|
console.log(`${pass ? ' OK ' : ' FALTA'} ${name}${hint ? ` -> ${hint}` : ''}`);
|
|
@@ -85,3 +94,32 @@ function isPortFree(port, host) {
|
|
|
85
94
|
server.listen(port, host);
|
|
86
95
|
});
|
|
87
96
|
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Prueba el servicio de voz sintetizando una frase. Si la voz configurada no
|
|
100
|
+
* responde, se prueba la voz por defecto: asi se distingue "el servicio esta
|
|
101
|
+
* caido" de "esta voz ya no existe".
|
|
102
|
+
*
|
|
103
|
+
* Entre intentos hay que esperar: el servicio se queja (fallo de handshake) si
|
|
104
|
+
* se le abren varias conexiones seguidas, y sin la pausa un fallo transitorio
|
|
105
|
+
* hace parecer que la voz no existe.
|
|
106
|
+
*/
|
|
107
|
+
async function checkVoz() {
|
|
108
|
+
const voz = readApp()?.tts?.voice || '';
|
|
109
|
+
const etiqueta = voz ? `voz (${voz})` : 'voz';
|
|
110
|
+
const esperar = () => new Promise((resolve) => setTimeout(resolve, 3000));
|
|
111
|
+
try {
|
|
112
|
+
const mp3 = await sintetizar('prueba de voz', voz ? { voice: voz } : {});
|
|
113
|
+
return [etiqueta, true, `${mp3.length} bytes`];
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const detalle = error?.message ?? error;
|
|
116
|
+
if (!voz) return [etiqueta, false, `el servicio no responde: ${detalle}`];
|
|
117
|
+
await esperar();
|
|
118
|
+
try {
|
|
119
|
+
await sintetizar('prueba de voz');
|
|
120
|
+
return [etiqueta, false, `esa voz no responde (${detalle}); la voz por defecto si: cambia la voz en Ajustes`];
|
|
121
|
+
} catch {
|
|
122
|
+
return [etiqueta, false, `el servicio no responde: ${detalle}`];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -166,6 +166,7 @@ export async function replCommand(argv) {
|
|
|
166
166
|
maxTokens: config.maxTokens,
|
|
167
167
|
maxDurationMs: config.maxDurationMs,
|
|
168
168
|
maxOutputTokens: config.maxOutputTokens,
|
|
169
|
+
promptCaching: config.promptCaching === true,
|
|
169
170
|
tools: toolDefs,
|
|
170
171
|
execute,
|
|
171
172
|
onEvent: printer.onEvent,
|
|
@@ -119,6 +119,7 @@ export async function runCommand(argv) {
|
|
|
119
119
|
maxTokens: config.maxTokens,
|
|
120
120
|
maxDurationMs: config.maxDurationMs,
|
|
121
121
|
maxOutputTokens: config.maxOutputTokens,
|
|
122
|
+
promptCaching: config.promptCaching === true,
|
|
122
123
|
signal: controller.signal,
|
|
123
124
|
tools: toolDefs,
|
|
124
125
|
execute,
|
|
@@ -101,6 +101,7 @@ export async function tuiCommand(argv) {
|
|
|
101
101
|
maxTokens: config.maxTokens,
|
|
102
102
|
maxDurationMs: config.maxDurationMs,
|
|
103
103
|
maxOutputTokens: config.maxOutputTokens,
|
|
104
|
+
promptCaching: config.promptCaching === true,
|
|
104
105
|
toolOptions: {
|
|
105
106
|
root: cwd,
|
|
106
107
|
enabled: enabledFor(mode),
|
|
@@ -16,8 +16,15 @@ const OPTIONS = {
|
|
|
16
16
|
help: { type: 'boolean', short: 'h' },
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
// Con `shell: true` (npm.cmd en Windows) Node concatena los args sin escapar
|
|
20
|
+
// (warning DEP0190): se manda un unico comando con args internos ya validados.
|
|
21
|
+
function npmSpawn(args, extra = {}) {
|
|
22
|
+
const [cmd, cmdArgs] = IS_WIN ? [[NPM, ...args].join(' '), []] : [NPM, args];
|
|
23
|
+
return spawnSync(cmd, cmdArgs, { windowsHide: true, shell: IS_WIN, timeout: 180000, ...extra });
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
function npm(args) {
|
|
20
|
-
return
|
|
27
|
+
return npmSpawn(args, { encoding: 'utf8' });
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
/** Version del paquete instalado globalmente (la que resuelve `npm ls -g`). */
|
|
@@ -78,6 +85,10 @@ export async function updateCommand(argv) {
|
|
|
78
85
|
}
|
|
79
86
|
|
|
80
87
|
const tag = typeof values.tag === 'string' && values.tag ? values.tag : 'latest';
|
|
88
|
+
if (!/^[\w.-]+$/.test(tag)) {
|
|
89
|
+
console.error(`Tag invalido: ${tag}`);
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
81
92
|
const spec = `${PACKAGE}@${tag}`;
|
|
82
93
|
const version = readPackageVersion();
|
|
83
94
|
console.log(`Version actual: ${version}`);
|
|
@@ -115,7 +126,7 @@ export async function updateCommand(argv) {
|
|
|
115
126
|
}
|
|
116
127
|
|
|
117
128
|
console.log(`Actualizando a ${spec}...`);
|
|
118
|
-
const result =
|
|
129
|
+
const result = npmSpawn(['i', '-g', spec], { stdio: 'inherit' });
|
|
119
130
|
if (result.status !== 0) {
|
|
120
131
|
console.error(`La actualizacion fallo. Proba a mano: npm i -g ${spec}`);
|
|
121
132
|
if (wasRunning) restartServer(wasRunning);
|
|
@@ -77,6 +77,7 @@ export async function runTurn({
|
|
|
77
77
|
maxTokens,
|
|
78
78
|
maxDurationMs,
|
|
79
79
|
maxOutputTokens,
|
|
80
|
+
promptCaching,
|
|
80
81
|
stallWarnMs,
|
|
81
82
|
signal,
|
|
82
83
|
execute,
|
|
@@ -104,6 +105,7 @@ export async function runTurn({
|
|
|
104
105
|
maxTokens,
|
|
105
106
|
maxDurationMs,
|
|
106
107
|
maxOutputTokens,
|
|
108
|
+
promptCaching,
|
|
107
109
|
stallWarnMs,
|
|
108
110
|
signal,
|
|
109
111
|
onEvent,
|
|
@@ -24,6 +24,7 @@ export function runTui({
|
|
|
24
24
|
maxTokens,
|
|
25
25
|
maxDurationMs,
|
|
26
26
|
maxOutputTokens,
|
|
27
|
+
promptCaching,
|
|
27
28
|
toolOptions,
|
|
28
29
|
store,
|
|
29
30
|
sessionId,
|
|
@@ -207,6 +208,7 @@ export function runTui({
|
|
|
207
208
|
maxTokens,
|
|
208
209
|
maxDurationMs,
|
|
209
210
|
maxOutputTokens,
|
|
211
|
+
promptCaching,
|
|
210
212
|
signal: controller.signal,
|
|
211
213
|
tools: toolset.definitions,
|
|
212
214
|
execute: toolset.execute,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const BIN = fileURLToPath(new URL('../bin/tamnora.mjs', import.meta.url));
|
|
10
|
+
|
|
11
|
+
// El bin filtra el ExperimentalWarning de node:sqlite, pero no debe duplicar el
|
|
12
|
+
// resto: sin quitar el impresor default de Node, cada warning salia dos veces.
|
|
13
|
+
// El probe difiere el warning para que salte con el handler del bin ya instalado.
|
|
14
|
+
test('el bin imprime cada warning una sola vez', async () => {
|
|
15
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-bin-'));
|
|
16
|
+
const probe = path.join(dir, 'probe.cjs');
|
|
17
|
+
await writeFile(probe, `setTimeout(() => process.emitWarning('aviso de prueba', 'CustomWarning'), 100);`);
|
|
18
|
+
const res = spawnSync(process.execPath, ['--require', probe, BIN, '--help'], {
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
windowsHide: true,
|
|
21
|
+
timeout: 30000,
|
|
22
|
+
});
|
|
23
|
+
const out = `${res.stdout}\n${res.stderr}`;
|
|
24
|
+
const count = out.split('aviso de prueba').length - 1;
|
|
25
|
+
assert.equal(count, 1, `se esperaba 1 impresion del warning, hubo ${count}`);
|
|
26
|
+
assert.equal(res.status, 0);
|
|
27
|
+
});
|
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import {
|
|
5
|
+
parseTunnelmoleUrls,
|
|
6
|
+
parseTailscaleStatus,
|
|
7
|
+
startTunnel,
|
|
8
|
+
startTunnelmole,
|
|
9
|
+
stopTailscaleServe,
|
|
10
|
+
tailscalePreviewUrl,
|
|
11
|
+
} from '../src/lib/tunnel.js';
|
|
4
12
|
import { qrTerminal } from '../src/lib/qr.js';
|
|
5
13
|
|
|
14
|
+
function fakeChild(exitCode = 0) {
|
|
15
|
+
const child = new EventEmitter();
|
|
16
|
+
child.stdout = new EventEmitter();
|
|
17
|
+
child.stderr = new EventEmitter();
|
|
18
|
+
child.pid = 0;
|
|
19
|
+
child.killed = false;
|
|
20
|
+
setImmediate(() => child.emit('exit', exitCode));
|
|
21
|
+
return child;
|
|
22
|
+
}
|
|
23
|
+
|
|
6
24
|
test('parseTunnelmoleUrls extracts the https URL', () => {
|
|
7
25
|
const out = parseTunnelmoleUrls('Listening at https://abc-123.tunnelmole.net (forwarding to 4517)');
|
|
8
26
|
assert.equal(out.https, 'https://abc-123.tunnelmole.net');
|
|
@@ -38,6 +56,36 @@ test('startTunnel con provider none no abre nada', async () => {
|
|
|
38
56
|
assert.equal(tun.url, '');
|
|
39
57
|
});
|
|
40
58
|
|
|
59
|
+
test('stopTailscaleServe apaga solo el handler 443 y lanza tailscale sin shell', async () => {
|
|
60
|
+
const calls = [];
|
|
61
|
+
const spawnImpl = (cmd, args, options) => {
|
|
62
|
+
calls.push({ cmd, args, options });
|
|
63
|
+
return fakeChild();
|
|
64
|
+
};
|
|
65
|
+
await stopTailscaleServe({ spawnImpl });
|
|
66
|
+
assert.equal(calls.length, 1);
|
|
67
|
+
assert.equal(calls[0].cmd, 'tailscale');
|
|
68
|
+
assert.deepEqual(calls[0].args, ['serve', '--https=443', 'off']);
|
|
69
|
+
assert.equal(calls[0].options.shell, undefined);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test(
|
|
73
|
+
'startTunnelmole con shell manda un unico comando (Node no concatena args)',
|
|
74
|
+
{ skip: process.platform !== 'win32' },
|
|
75
|
+
async () => {
|
|
76
|
+
const calls = [];
|
|
77
|
+
const spawnImpl = (cmd, args, options) => {
|
|
78
|
+
calls.push({ cmd, args, options });
|
|
79
|
+
return fakeChild(1);
|
|
80
|
+
};
|
|
81
|
+
await assert.rejects(() => startTunnelmole(4517, { spawnImpl }), /termino/);
|
|
82
|
+
assert.equal(calls.length, 1);
|
|
83
|
+
assert.equal(calls[0].cmd, 'npx --yes tunnelmole 4517');
|
|
84
|
+
assert.deepEqual(calls[0].args, []);
|
|
85
|
+
assert.equal(calls[0].options.shell, true);
|
|
86
|
+
}
|
|
87
|
+
);
|
|
88
|
+
|
|
41
89
|
test('tailscalePreviewUrl usa el 8443 y omite el 443', () => {
|
|
42
90
|
assert.equal(tailscalePreviewUrl('pc.tailnet.ts.net'), 'https://pc.tailnet.ts.net:8443');
|
|
43
91
|
assert.equal(tailscalePreviewUrl('pc.tailnet.ts.net', 9000), 'https://pc.tailnet.ts.net:9000');
|
|
@@ -53,7 +53,7 @@ function delay(ms) {
|
|
|
53
53
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
function assertAllowedUrl(raw) {
|
|
56
|
+
function assertAllowedUrl(raw, allowPrivate = false) {
|
|
57
57
|
let parsed;
|
|
58
58
|
try {
|
|
59
59
|
parsed = new URL(String(raw));
|
|
@@ -63,16 +63,18 @@ function assertAllowedUrl(raw) {
|
|
|
63
63
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
64
64
|
throw new Error(`Solo se admite http(s): ${raw}`);
|
|
65
65
|
}
|
|
66
|
-
|
|
66
|
+
// `allowPrivate` es para abrir nuestro **propio** server local (el smoke test
|
|
67
|
+
// de la consola). Las tools no lo usan: ahi la guarda anti-SSRF es total.
|
|
68
|
+
if (!allowPrivate && isBlockedHost(parsed.hostname)) {
|
|
67
69
|
throw new Error(`host bloqueado (${normalizeHost(parsed.hostname)}).`);
|
|
68
70
|
}
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
function isBlockedNavigation(raw) {
|
|
73
|
+
function isBlockedNavigation(raw, allowPrivate = false) {
|
|
72
74
|
try {
|
|
73
75
|
const parsed = new URL(String(raw));
|
|
74
76
|
if (!parsed.hostname) return parsed.protocol === 'file:';
|
|
75
|
-
return isBlockedHost(parsed.hostname);
|
|
77
|
+
return !allowPrivate && isBlockedHost(parsed.hostname);
|
|
76
78
|
} catch {
|
|
77
79
|
return false;
|
|
78
80
|
}
|
|
@@ -196,7 +198,7 @@ function describeArg(arg) {
|
|
|
196
198
|
return String(arg.type ?? '');
|
|
197
199
|
}
|
|
198
200
|
|
|
199
|
-
async function openPage(wsUrl, url, { timeoutMs = 20000, collectMessages = true } = {}) {
|
|
201
|
+
async function openPage(wsUrl, url, { timeoutMs = 20000, collectMessages = true, allowPrivate = false } = {}) {
|
|
200
202
|
const cdp = new CdpConnection(new WebSocket(wsUrl));
|
|
201
203
|
await cdp.open();
|
|
202
204
|
const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
|
|
@@ -240,7 +242,7 @@ async function openPage(wsUrl, url, { timeoutMs = 20000, collectMessages = true
|
|
|
240
242
|
if (message.method !== 'Page.frameNavigated' || message.sessionId !== sessionId) return;
|
|
241
243
|
const frame = message.params?.frame;
|
|
242
244
|
if (!frame || frame.parentId || !frame.url || frame.url === 'about:blank') return;
|
|
243
|
-
if (isBlockedNavigation(frame.url)) blockedUrl = frame.url;
|
|
245
|
+
if (isBlockedNavigation(frame.url, allowPrivate)) blockedUrl = frame.url;
|
|
244
246
|
});
|
|
245
247
|
try {
|
|
246
248
|
await cdp.send('Page.navigate', { url }, sessionId);
|
|
@@ -256,7 +258,7 @@ async function openPage(wsUrl, url, { timeoutMs = 20000, collectMessages = true
|
|
|
256
258
|
sessionId
|
|
257
259
|
);
|
|
258
260
|
const finalUrl = String(result?.value ?? '');
|
|
259
|
-
if (finalUrl && isBlockedNavigation(finalUrl)) blockedUrl = finalUrl;
|
|
261
|
+
if (finalUrl && isBlockedNavigation(finalUrl, allowPrivate)) blockedUrl = finalUrl;
|
|
260
262
|
} catch {
|
|
261
263
|
/* sin runtime disponible: nos quedamos con los eventos de navegacion */
|
|
262
264
|
}
|
|
@@ -287,7 +289,7 @@ function killBrowser(child) {
|
|
|
287
289
|
}
|
|
288
290
|
|
|
289
291
|
async function withBrowser(url, options, run) {
|
|
290
|
-
assertAllowedUrl(url);
|
|
292
|
+
assertAllowedUrl(url, options.allowPrivate === true);
|
|
291
293
|
const bin = findBrowserPath();
|
|
292
294
|
if (!bin) {
|
|
293
295
|
throw new Error('no encontré Chrome/Edge/Chromium; definí TAMNORA_BROWSER con la ruta al ejecutable');
|
|
@@ -315,11 +317,23 @@ async function withBrowser(url, options, run) {
|
|
|
315
317
|
/**
|
|
316
318
|
* Captura PNG de una URL. Devuelve `{ buffer, width, height, consoleErrors }`.
|
|
317
319
|
*/
|
|
318
|
-
export async function captureScreenshot(url, { fullPage = false, width = 1280, height = 720, waitMs = 800, timeoutMs } = {}) {
|
|
319
|
-
return withBrowser(url, { timeoutMs, collectMessages: true }, async ({ cdp, sessionId, messages }) => {
|
|
320
|
+
export async function captureScreenshot(url, { fullPage = false, width = 1280, height = 720, waitMs = 800, timeoutMs, allowPrivate = false, preScript = '', colorScheme = '' } = {}) {
|
|
321
|
+
return withBrowser(url, { timeoutMs, collectMessages: true, allowPrivate }, async ({ cdp, sessionId, messages }) => {
|
|
320
322
|
await cdp
|
|
321
323
|
.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }, sessionId)
|
|
322
324
|
.catch(() => {});
|
|
325
|
+
// Para paginas que se tematizan con `prefers-color-scheme` (el sitio, no la
|
|
326
|
+
// consola): emula el esquema del sistema.
|
|
327
|
+
if (colorScheme) {
|
|
328
|
+
await cdp.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: colorScheme }] }, sessionId).catch(() => {});
|
|
329
|
+
}
|
|
330
|
+
let preScriptResult = null;
|
|
331
|
+
if (preScript) {
|
|
332
|
+
const evaluado = await cdp
|
|
333
|
+
.send('Runtime.evaluate', { expression: preScript, returnByValue: true, awaitPromise: true }, sessionId)
|
|
334
|
+
.catch(() => null);
|
|
335
|
+
preScriptResult = evaluado?.result?.value ?? evaluado?.value ?? null;
|
|
336
|
+
}
|
|
323
337
|
if (waitMs > 0) await delay(waitMs);
|
|
324
338
|
const shot = await cdp.send(
|
|
325
339
|
'Page.captureScreenshot',
|
|
@@ -327,15 +341,15 @@ export async function captureScreenshot(url, { fullPage = false, width = 1280, h
|
|
|
327
341
|
sessionId
|
|
328
342
|
);
|
|
329
343
|
const errors = messages.filter((m) => m.level === 'error');
|
|
330
|
-
return { buffer: Buffer.from(shot.data, 'base64'), width, height, consoleErrors: errors };
|
|
344
|
+
return { buffer: Buffer.from(shot.data, 'base64'), width, height, consoleErrors: errors, preScriptResult };
|
|
331
345
|
});
|
|
332
346
|
}
|
|
333
347
|
|
|
334
348
|
/**
|
|
335
349
|
* Abre una URL y devuelve los mensajes de consola. `{ messages, errors }`.
|
|
336
350
|
*/
|
|
337
|
-
export async function readConsole(url, { waitMs = 1500, timeoutMs } = {}) {
|
|
338
|
-
return withBrowser(url, { timeoutMs, collectMessages: true }, async ({ messages }) => {
|
|
351
|
+
export async function readConsole(url, { waitMs = 1500, timeoutMs, allowPrivate = false } = {}) {
|
|
352
|
+
return withBrowser(url, { timeoutMs, collectMessages: true, allowPrivate }, async ({ messages }) => {
|
|
339
353
|
if (waitMs > 0) await delay(waitMs);
|
|
340
354
|
const errors = messages.filter((m) => m.level === 'error');
|
|
341
355
|
return { messages, errors };
|
|
@@ -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({
|
|
@@ -71,14 +71,19 @@ function spawnTunnel(bin, args, opts = {}) {
|
|
|
71
71
|
const parse = opts.parse || (() => ({ https: '', http: '' }));
|
|
72
72
|
const timeoutMs = opts.timeoutMs || 90000;
|
|
73
73
|
const label = opts.label || bin;
|
|
74
|
+
const useShell = process.platform === 'win32';
|
|
74
75
|
return new Promise((resolve, reject) => {
|
|
75
76
|
let child;
|
|
76
77
|
try {
|
|
77
|
-
|
|
78
|
+
// En Windows `npx` es un shim .cmd y exige shell; ahi se manda un unico
|
|
79
|
+
// comando (ver shellCommandLine): con array de args Node concatena sin
|
|
80
|
+
// escapar y emite el warning DEP0190.
|
|
81
|
+
const [cmd, cmdArgs] = useShell ? [shellCommandLine(bin, args), []] : [bin, args];
|
|
82
|
+
child = (opts.spawnImpl || spawn)(cmd, cmdArgs, {
|
|
78
83
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
79
84
|
windowsHide: true,
|
|
80
|
-
shell:
|
|
81
|
-
detached:
|
|
85
|
+
shell: useShell,
|
|
86
|
+
detached: !useShell,
|
|
82
87
|
});
|
|
83
88
|
} catch (err) {
|
|
84
89
|
reject(new Error(`no se pudo iniciar ${label}: ${err.message}`));
|
|
@@ -138,15 +143,29 @@ export function startTunnelmole(port, opts = {}) {
|
|
|
138
143
|
* tailnet reenviando a loopback, asi que no hay exposicion publica y las
|
|
139
144
|
* peticiones siguen siendo "locales" para el server. Proveedor recomendado.
|
|
140
145
|
*/
|
|
146
|
+
// Con `shell: true` Node concatena los args sin escapar (warning DEP0190): se
|
|
147
|
+
// arma un unico comando con args validados contra metacaracteres del shell.
|
|
148
|
+
const SAFE_SHELL_ARG = /^[^&|<>^%!"\r\n]+$/;
|
|
149
|
+
|
|
150
|
+
function shellCommandLine(bin, args) {
|
|
151
|
+
for (const arg of args) {
|
|
152
|
+
if (!SAFE_SHELL_ARG.test(String(arg))) {
|
|
153
|
+
throw new Error(`argumento no permitido para ${bin}: "${arg}"`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return [bin, ...args].join(' ');
|
|
157
|
+
}
|
|
158
|
+
|
|
141
159
|
function runTailscale(args, opts = {}) {
|
|
142
160
|
const timeoutMs = opts.timeoutMs || 15000;
|
|
143
161
|
return new Promise((resolve, reject) => {
|
|
144
162
|
let child;
|
|
145
163
|
try {
|
|
146
|
-
|
|
164
|
+
// `tailscale` es un ejecutable real (.exe en Windows): resuelve por PATH
|
|
165
|
+
// sin shell, que ademas evita el warning DEP0190 de Node.
|
|
166
|
+
child = (opts.spawnImpl || spawn)('tailscale', args, {
|
|
147
167
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
148
168
|
windowsHide: true,
|
|
149
|
-
shell: process.platform === 'win32',
|
|
150
169
|
});
|
|
151
170
|
} catch (err) {
|
|
152
171
|
reject(err);
|
|
@@ -241,7 +260,7 @@ export async function startTailscaleServe(port, opts = {}) {
|
|
|
241
260
|
pid: 0,
|
|
242
261
|
child: null,
|
|
243
262
|
probeUrl: `${url}/api/health`,
|
|
244
|
-
stop: () =>
|
|
263
|
+
stop: () => stopTailscaleServe().catch(() => {}),
|
|
245
264
|
};
|
|
246
265
|
}
|
|
247
266
|
|
|
@@ -279,9 +298,13 @@ export async function probeTailscale() {
|
|
|
279
298
|
return parseTailscaleStatus(stdout);
|
|
280
299
|
}
|
|
281
300
|
|
|
282
|
-
/**
|
|
283
|
-
|
|
284
|
-
|
|
301
|
+
/**
|
|
302
|
+
* Apaga el handler `serve` de la consola (https 443) de forma puntual: un
|
|
303
|
+
* `serve reset` borraria tambien los otros handlers (p.ej. el preview en 8443).
|
|
304
|
+
*/
|
|
305
|
+
export function stopTailscaleServe(opts = {}) {
|
|
306
|
+
const httpsPort = Number(opts.httpsPort) || 443;
|
|
307
|
+
return runTailscale(['serve', `--https=${httpsPort}`, 'off'], { timeoutMs: 30000, spawnImpl: opts.spawnImpl });
|
|
285
308
|
}
|
|
286
309
|
|
|
287
310
|
/**
|