@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
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ 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.0] - 2026-10-28
|
|
9
|
+
|
|
10
|
+
### Cambios
|
|
11
|
+
|
|
12
|
+
- feat(ajustes): expone maxOutputTokens y promptCaching en la pestana Limites
|
|
13
|
+
- feat(consola): el logo acompana el esquema del tema, y el browser puede emular color y correr un pre-script
|
|
14
|
+
- refactor(consola): extrae las tarjetas de chat.js a cards.js
|
|
15
|
+
- test(consola): valida el grafo de modulos y la carga real en navegador headless
|
|
16
|
+
- feat: canary de servicios vivos en pnpm y en cron de GitHub
|
|
17
|
+
- feat(doctor): --voz prueba el servicio de voz y distingue voz rota de servicio caido
|
|
18
|
+
- fix(voz): reintenta un fallo de conexion de la sintesis, sin reintentar rechazos
|
|
19
|
+
- fix(test): el arranque de procs espera la condicion, no un tiempo fijo
|
|
20
|
+
- refactor(consola): extrae el formato puro de chat.js a chatFormat.js, con tests
|
|
21
|
+
- refactor(telegram): parte el canal en api, text, permissions, media, projects, run y commands
|
|
22
|
+
- fix(voz): EDGE_VERSION acompana al endpoint nuevo y agrega un canary de servicios vivos
|
|
23
|
+
- chore: quita document.md desactualizado (decia 0.11.3) y normaliza los fin de linea
|
|
24
|
+
- fix: node:sqlite necesita Node >= 22.13 y el warning experimental se filtra en los bins
|
|
25
|
+
- refactor(consola): unifica la navegacion en el menu mas y devuelve el foco al cerrarlo
|
|
26
|
+
|
|
27
|
+
## [0.22.0] - 2026-10-22
|
|
28
|
+
|
|
29
|
+
### Cambios
|
|
30
|
+
|
|
31
|
+
- docs: AGENTS.md al dia con la auditoria, los modulos de la consola y el preview opt-in
|
|
32
|
+
- refactor(test): parte server.test.js en archivos por dominio
|
|
33
|
+
- refactor(consola): parte app.js en modulos y pule la UX
|
|
34
|
+
- fix: cierra los hallazgos de seguridad de la auditoria y agrega regresiones
|
|
35
|
+
|
|
8
36
|
## [0.21.2] - 2026-10-20
|
|
9
37
|
|
|
10
38
|
### 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,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 { runCli } = await import('../src/cli.js');
|
|
3
9
|
|
|
4
10
|
runCli(process.argv.slice(2))
|
|
5
11
|
.then((code) => {
|
|
@@ -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),
|
|
@@ -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,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { withRetry, isRetryableError } from '../providers/resilience.js';
|
|
2
|
+
import { redactSecrets, redactDeep } from '../redact.js';
|
|
2
3
|
import { trimContext, estimateTokens } from './context.js';
|
|
3
4
|
|
|
4
5
|
// 0 = sin limite de rondas: el loop sigue hasta que el modelo deja de pedir
|
|
@@ -66,6 +67,7 @@ export async function runAgent({
|
|
|
66
67
|
retryDelayMs = 500,
|
|
67
68
|
contextTokens = 0,
|
|
68
69
|
onLifecycle,
|
|
70
|
+
redactExtra = [],
|
|
69
71
|
}) {
|
|
70
72
|
const working = [...messages];
|
|
71
73
|
// Mensajes efimeros (p. ej. reglas por path inyectadas en runtime): entran al
|
|
@@ -234,7 +236,7 @@ export async function runAgent({
|
|
|
234
236
|
}
|
|
235
237
|
}
|
|
236
238
|
|
|
237
|
-
const normalized = normalizeToolOutput(output);
|
|
239
|
+
const normalized = normalizeToolOutput(output, { extra: redactExtra });
|
|
238
240
|
onEvent?.({
|
|
239
241
|
type: 'tool_result',
|
|
240
242
|
id: call.id,
|
|
@@ -357,14 +359,16 @@ function createStallGuard(onEvent, stallWarnMs) {
|
|
|
357
359
|
* Normaliza el resultado de un tool a `{ content, meta }`. Un executor puede
|
|
358
360
|
* devolver un string (lo normal) o `{ content, meta }` cuando necesita mandar
|
|
359
361
|
* metadata al cliente sin ensuciar lo que ve el modelo (p. ej. una captura).
|
|
362
|
+
* El contenido va redactado (`redactSecrets`): un tool result que pegue una
|
|
363
|
+
* API key no puede llegar al historial, la UI ni los canales.
|
|
360
364
|
*/
|
|
361
|
-
export function normalizeToolOutput(output) {
|
|
362
|
-
if (typeof output === 'string') return { content: output };
|
|
365
|
+
export function normalizeToolOutput(output, opts = {}) {
|
|
366
|
+
if (typeof output === 'string') return { content: redactSecrets(output, opts) };
|
|
363
367
|
if (output && typeof output === 'object' && typeof output.content === 'string') {
|
|
364
|
-
return { content: output.content, meta: output.meta };
|
|
368
|
+
return { content: redactSecrets(output.content, opts), meta: redactDeep(output.meta, opts) };
|
|
365
369
|
}
|
|
366
370
|
if (output == null) return { content: '' };
|
|
367
|
-
return { content: JSON.stringify(output) };
|
|
371
|
+
return { content: redactSecrets(JSON.stringify(output), opts) };
|
|
368
372
|
}
|
|
369
373
|
|
|
370
374
|
async function defaultExecute(name) {
|
|
@@ -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 };
|
|
@@ -26,6 +26,20 @@ function shouldUseShell(server, command) {
|
|
|
26
26
|
return true;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Con `shell: true` Node concatena los args sin escapar: un arg con
|
|
30
|
+
// metacaracteres (`&`, `|`, `<`, `>`, `^`, `%`, `!`) inyectaria comandos. Misma
|
|
31
|
+
// regla que `lib/procs.js` del server.
|
|
32
|
+
const SAFE_SHELL_ARG = /^[^&|<>^%!"\r\n]+$/;
|
|
33
|
+
|
|
34
|
+
function assertSafeShellArgs(server, command, args = []) {
|
|
35
|
+
if (!shouldUseShell(server, command)) return;
|
|
36
|
+
for (const arg of args) {
|
|
37
|
+
if (!SAFE_SHELL_ARG.test(String(arg))) {
|
|
38
|
+
throw new Error(`MCP stdio: argumento no permitido con shell en Windows: "${arg}".`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
29
43
|
// Variables minimas para que un proceso hijo comun (node, npx, uvx) arranque.
|
|
30
44
|
const BASE_ENV_KEYS = [
|
|
31
45
|
'PATH',
|
|
@@ -73,6 +87,7 @@ export function childEnv(server = {}) {
|
|
|
73
87
|
export async function connectMcpStdioServer(server, { signal, timeout = 30000 } = {}) {
|
|
74
88
|
const command = server.command;
|
|
75
89
|
if (!command) throw new Error(`MCP stdio: falta "command" en el servidor ${server.id ?? ''}`);
|
|
90
|
+
assertSafeShellArgs(server, command, server.args ?? []);
|
|
76
91
|
|
|
77
92
|
const child = spawn(command, server.args ?? [], {
|
|
78
93
|
cwd: server.cwd,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// `fetch_url` (tambien en cada redirect) y el navegador headless.
|
|
4
4
|
|
|
5
5
|
import net from 'node:net';
|
|
6
|
+
import { lookup } from 'node:dns/promises';
|
|
6
7
|
|
|
7
8
|
const BLOCKED_SUFFIXES = ['.localhost', '.local', '.internal', '.intranet', '.lan', '.home.arpa'];
|
|
8
9
|
|
|
@@ -109,3 +110,72 @@ export function checkHttpUrl(value) {
|
|
|
109
110
|
}
|
|
110
111
|
return { url: parsed.toString(), parsed };
|
|
111
112
|
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Validacion para `preview_url`: la vista previa embebe un dev server, que casi
|
|
116
|
+
* siempre es local (127.0.0.1, LAN), asi que NO aplica la guarda de hosts
|
|
117
|
+
* privados de `checkHttpUrl`. Se bloquean igual los hosts de metadatos de la
|
|
118
|
+
* nube y las direcciones link-local/multicast.
|
|
119
|
+
*/
|
|
120
|
+
export function checkPreviewUrl(value) {
|
|
121
|
+
const raw = String(value ?? '').trim();
|
|
122
|
+
if (!raw) return { error: 'falta "url".' };
|
|
123
|
+
let parsed;
|
|
124
|
+
try {
|
|
125
|
+
parsed = new URL(raw);
|
|
126
|
+
} catch {
|
|
127
|
+
return { error: `URL inválida: "${raw}".` };
|
|
128
|
+
}
|
|
129
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
130
|
+
return { error: 'Solo se admite http(s).' };
|
|
131
|
+
}
|
|
132
|
+
const host = normalizeHost(parsed.hostname);
|
|
133
|
+
if (!host) return { error: 'URL inválida (sin host).' };
|
|
134
|
+
if (host === 'metadata' || host.endsWith('.internal')) {
|
|
135
|
+
return { error: `host bloqueado (${host}).` };
|
|
136
|
+
}
|
|
137
|
+
if (net.isIP(host) && isLinkLocalOrReserved(host)) {
|
|
138
|
+
return { error: `host bloqueado (${host}).` };
|
|
139
|
+
}
|
|
140
|
+
return { url: parsed.toString(), parsed };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isLinkLocalOrReserved(address) {
|
|
144
|
+
const value = normalizeHost(address);
|
|
145
|
+
const family = net.isIP(value);
|
|
146
|
+
if (family === 4) {
|
|
147
|
+
const [a, b] = parseV4(value) ?? [];
|
|
148
|
+
return a === 0 || (a === 169 && b === 254) || a >= 224;
|
|
149
|
+
}
|
|
150
|
+
if (family === 6) {
|
|
151
|
+
const groups = parseV6(value) ?? [];
|
|
152
|
+
const [g0] = groups;
|
|
153
|
+
return groups.every((g) => g === 0) || (g0 & 0xffc0) === 0xfe80 || (g0 & 0xff00) === 0xff00;
|
|
154
|
+
}
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Como `checkHttpUrl`, pero ademas resuelve el hostname y valida las IPs: un
|
|
160
|
+
* dominio publico que resuelva a una IP interna (DNS rebinding) no pasa. Vale
|
|
161
|
+
* para la URL inicial y para cada redirect.
|
|
162
|
+
*/
|
|
163
|
+
export async function checkHttpUrlResolved(value) {
|
|
164
|
+
const checked = checkHttpUrl(value);
|
|
165
|
+
if (checked.error) return checked;
|
|
166
|
+
const host = normalizeHost(checked.parsed.hostname);
|
|
167
|
+
if (net.isIP(host)) return checked;
|
|
168
|
+
let addresses;
|
|
169
|
+
try {
|
|
170
|
+
addresses = await lookup(host, { all: true });
|
|
171
|
+
} catch {
|
|
172
|
+
return { error: `No pude resolver el host "${host}".` };
|
|
173
|
+
}
|
|
174
|
+
if (!addresses.length) return { error: `No pude resolver el host "${host}".` };
|
|
175
|
+
for (const { address } of addresses) {
|
|
176
|
+
if (isBlockedAddress(address)) {
|
|
177
|
+
return { error: `host bloqueado (${host} resuelve a ${normalizeHost(address)}).` };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return checked;
|
|
181
|
+
}
|
|
@@ -17,6 +17,8 @@ const SECRET_PATTERNS = [
|
|
|
17
17
|
/AIza[0-9A-Za-z_-]{30,}/g,
|
|
18
18
|
/xox[baprs]-[A-Za-z0-9-]{10,}/g,
|
|
19
19
|
/ya29\.[A-Za-z0-9_-]{20,}/g,
|
|
20
|
+
/gsk_[A-Za-z0-9]{20,}/g, // Groq
|
|
21
|
+
/\b\d{8,10}:[A-Za-z0-9_-]{30,}\b/g, // token de bot de Telegram
|
|
20
22
|
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
21
23
|
];
|
|
22
24
|
|
|
@@ -7,7 +7,7 @@ import { diffLines } from './diff.js';
|
|
|
7
7
|
import { captureScreenshot, readConsole } from '../browser/index.js';
|
|
8
8
|
import { screenshotsDir } from '../config/home.js';
|
|
9
9
|
import { scrubEnv } from '../redact.js';
|
|
10
|
-
import {
|
|
10
|
+
import { checkHttpUrlResolved, checkPreviewUrl } from '../net/hosts.js';
|
|
11
11
|
|
|
12
12
|
const MAX_READ_LINES = 2000;
|
|
13
13
|
const MAX_LIST = 500;
|
|
@@ -98,7 +98,7 @@ function formatBody(head, text, limit) {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
export async function fetchUrlTool(_root, { url, maxLength, raw } = {}) {
|
|
101
|
-
const checked =
|
|
101
|
+
const checked = await checkHttpUrlResolved(url);
|
|
102
102
|
if (checked.error) return `Error: ${checked.error}`;
|
|
103
103
|
let target = checked.parsed;
|
|
104
104
|
|
|
@@ -129,9 +129,8 @@ export async function fetchUrlTool(_root, { url, maxLength, raw } = {}) {
|
|
|
129
129
|
if (next.protocol !== 'http:' && next.protocol !== 'https:') {
|
|
130
130
|
return `Error: redirección a un protocolo no permitido (${next.protocol}).`;
|
|
131
131
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
132
|
+
const recheck = await checkHttpUrlResolved(next.toString());
|
|
133
|
+
if (recheck.error) return `Error: ${recheck.error}`;
|
|
135
134
|
try {
|
|
136
135
|
await res.body?.cancel();
|
|
137
136
|
} catch {
|
|
@@ -331,7 +330,8 @@ export function bashTool(root, { command, timeout } = {}, { signal } = {}) {
|
|
|
331
330
|
let settled = false;
|
|
332
331
|
let timer = null;
|
|
333
332
|
const append = (chunk) => {
|
|
334
|
-
|
|
333
|
+
const room = MAX_BASH_OUTPUT - output.length;
|
|
334
|
+
if (room > 0) output += chunk.toString().slice(0, room);
|
|
335
335
|
};
|
|
336
336
|
|
|
337
337
|
const finish = (text) => {
|
|
@@ -374,7 +374,8 @@ function runGit(root, args) {
|
|
|
374
374
|
let output = '';
|
|
375
375
|
let settled = false;
|
|
376
376
|
const append = (chunk) => {
|
|
377
|
-
|
|
377
|
+
const room = MAX_BASH_OUTPUT - output.length;
|
|
378
|
+
if (room > 0) output += chunk.toString().slice(0, room);
|
|
378
379
|
};
|
|
379
380
|
child.stdout?.on('data', append);
|
|
380
381
|
child.stderr?.on('data', append);
|
|
@@ -399,7 +400,14 @@ export function gitStatusTool(root) {
|
|
|
399
400
|
export function gitDiffTool(root, { path: p, staged, ref } = {}) {
|
|
400
401
|
const args = ['diff', '--no-color'];
|
|
401
402
|
if (staged) args.push('--staged');
|
|
402
|
-
if (ref)
|
|
403
|
+
if (ref) {
|
|
404
|
+
// Un "ref" que empieza con '-' no es un ref: git lo tomaria como opcion
|
|
405
|
+
// (`--output=`, `--ext-diff`) y escribiria/ejecutaria cosas, siendo
|
|
406
|
+
// `git_diff` una tool de solo lectura con permiso `allow`.
|
|
407
|
+
const value = String(ref);
|
|
408
|
+
if (value.startsWith('-')) return 'Error: "ref" inválido (no puede empezar con "-").';
|
|
409
|
+
args.push(value);
|
|
410
|
+
}
|
|
403
411
|
if (p) args.push('--', String(p));
|
|
404
412
|
return runGit(root, args);
|
|
405
413
|
}
|
|
@@ -436,19 +444,6 @@ export function speakTool(_root, { texto, voice, rate, pitch } = {}) {
|
|
|
436
444
|
return `Audio pedido${opciones ? ` (${opciones})` : ''}: "${recortado}". El cliente lo reproduce o lo envía como nota de voz.`;
|
|
437
445
|
}
|
|
438
446
|
|
|
439
|
-
function normalizeHttpUrl(value) {
|
|
440
|
-
const raw = String(value ?? '').trim();
|
|
441
|
-
if (!raw) return { error: 'falta "url".' };
|
|
442
|
-
let parsed;
|
|
443
|
-
try {
|
|
444
|
-
parsed = new URL(raw);
|
|
445
|
-
} catch {
|
|
446
|
-
return { error: `URL inválida: "${raw}".` };
|
|
447
|
-
}
|
|
448
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return { error: 'Solo se admite http(s).' };
|
|
449
|
-
return { url: parsed.toString() };
|
|
450
|
-
}
|
|
451
|
-
|
|
452
447
|
function clampInt(value, fallback, min, max) {
|
|
453
448
|
const n = Math.round(Number(value));
|
|
454
449
|
if (!Number.isFinite(n)) return fallback;
|
|
@@ -460,7 +455,7 @@ function clampInt(value, fallback, min, max) {
|
|
|
460
455
|
* del cliente (igual que `speak`): aca solo se valida la URL.
|
|
461
456
|
*/
|
|
462
457
|
export function previewUrlTool(_root, { url } = {}) {
|
|
463
|
-
const parsed =
|
|
458
|
+
const parsed = checkPreviewUrl(url);
|
|
464
459
|
if (parsed.error) return `Error: ${parsed.error}`;
|
|
465
460
|
return `Vista previa pedida: ${parsed.url}. La consola la abre en el panel de vista previa.`;
|
|
466
461
|
}
|
|
@@ -558,7 +553,7 @@ export function showPlanTool(_root, { title, steps } = {}) {
|
|
|
558
553
|
* muestre y Telegram la mande como foto.
|
|
559
554
|
*/
|
|
560
555
|
export async function screenshotTool(_root, { url, fullPage, width, height } = {}) {
|
|
561
|
-
const parsed =
|
|
556
|
+
const parsed = await checkHttpUrlResolved(url);
|
|
562
557
|
if (parsed.error) return `Error: ${parsed.error}`;
|
|
563
558
|
const size = {
|
|
564
559
|
width: clampInt(width, 1280, 320, 3840),
|
|
@@ -595,7 +590,7 @@ export async function screenshotTool(_root, { url, fullPage, width, height } = {
|
|
|
595
590
|
|
|
596
591
|
/** Errores y mensajes de consola de una pagina (ejecutando su JS). */
|
|
597
592
|
export async function consoleTool(_root, { url, waitMs } = {}) {
|
|
598
|
-
const parsed =
|
|
593
|
+
const parsed = await checkHttpUrlResolved(url);
|
|
599
594
|
if (parsed.error) return `Error: ${parsed.error}`;
|
|
600
595
|
|
|
601
596
|
let result;
|
|
@@ -4,7 +4,7 @@ import { MCP_ROUTER_NAME, planMcpTools } from './mcp.js';
|
|
|
4
4
|
export { createToolRegistry } from './registry.js';
|
|
5
5
|
export { TOOL_DEFINITIONS, TOOL_NAMES } from './schema.js';
|
|
6
6
|
export { DEFAULT_PERMISSIONS, evaluatePermission, bashGuard } from './permissions.js';
|
|
7
|
-
export { resolveInRoot, toRel } from './paths.js';
|
|
7
|
+
export { resolveInRoot, toRel, realpathDeep } from './paths.js';
|
|
8
8
|
export { globToRegExp, walkFiles, DEFAULT_IGNORES } from './glob.js';
|
|
9
9
|
export { MCP_ROUTER_NAME, MCP_AUTO_THRESHOLD, useLazyMcp, buildMcpIndex, mcpRouterDefinition, planMcpTools } from './mcp.js';
|
|
10
10
|
export * as executors from './executors.js';
|