@danieltmn/tamnora 0.21.0 → 0.21.2

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.
@@ -7,6 +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 { checkHttpUrl, isBlockedHost, normalizeHost } from '../net/hosts.js';
10
11
 
11
12
  const MAX_READ_LINES = 2000;
12
13
  const MAX_LIST = 500;
@@ -16,6 +17,8 @@ const MAX_GREP_FILE_BYTES = 2_000_000;
16
17
  const MAX_BASH_OUTPUT = 100_000;
17
18
  const FETCH_TIMEOUT_MS = 20000;
18
19
  const MAX_FETCH_CHARS = 200_000;
20
+ const MAX_REDIRECTS = 5;
21
+ const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
19
22
 
20
23
  const NAMED_ENTITIES = {
21
24
  amp: '&',
@@ -35,23 +38,6 @@ const NAMED_ENTITIES = {
35
38
  trade: '\u2122',
36
39
  };
37
40
 
38
- function isBlockedHost(hostname) {
39
- const h = String(hostname ?? '')
40
- .toLowerCase()
41
- .replace(/^\[|\]$/g, '');
42
- if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local')) return true;
43
- if (h === '::1' || h === '0.0.0.0') return true;
44
- const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
45
- if (!m) return false;
46
- const a = Number(m[1]);
47
- const b = Number(m[2]);
48
- if (a === 127 || a === 10 || a === 0) return true;
49
- if (a === 169 && b === 254) return true;
50
- if (a === 192 && b === 168) return true;
51
- if (a === 172 && b >= 16 && b <= 31) return true;
52
- return false;
53
- }
54
-
55
41
  function isTextualType(type) {
56
42
  const t = String(type || '').toLowerCase();
57
43
  if (!t) return true;
@@ -112,27 +98,47 @@ function formatBody(head, text, limit) {
112
98
  }
113
99
 
114
100
  export async function fetchUrlTool(_root, { url, maxLength, raw } = {}) {
115
- let target;
116
- try {
117
- target = new URL(String(url ?? ''));
118
- } catch {
119
- return 'Error: URL inválida.';
120
- }
121
- if (!/^https?:$/.test(target.protocol)) return 'Error: solo se permite http/https.';
122
- if (isBlockedHost(target.hostname)) return `Error: host bloqueado (${target.hostname}).`;
101
+ const checked = checkHttpUrl(url);
102
+ if (checked.error) return `Error: ${checked.error}`;
103
+ let target = checked.parsed;
123
104
 
124
105
  const controller = new AbortController();
125
106
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
126
107
  try {
127
- const res = await fetch(target.toString(), {
128
- redirect: 'follow',
129
- signal: controller.signal,
130
- headers: {
131
- 'User-Agent': 'Mozilla/5.0 (compatible; tamnora-harness; +https://github.com/tamnora/tamnora-harness)',
132
- Accept: 'text/html,application/xhtml+xml,application/json,text/plain;q=0.9,*/*;q=0.5',
133
- 'Accept-Language': 'es,en;q=0.8',
134
- },
135
- });
108
+ let res;
109
+ for (let hop = 0; ; hop++) {
110
+ res = await fetch(target.toString(), {
111
+ redirect: 'manual',
112
+ signal: controller.signal,
113
+ headers: {
114
+ 'User-Agent': 'Mozilla/5.0 (compatible; tamnora-harness; +https://github.com/tamnora/tamnora-harness)',
115
+ Accept: 'text/html,application/xhtml+xml,application/json,text/plain;q=0.9,*/*;q=0.5',
116
+ 'Accept-Language': 'es,en;q=0.8',
117
+ },
118
+ });
119
+ if (!REDIRECT_STATUS.has(res.status)) break;
120
+ const location = res.headers.get('location');
121
+ if (!location) break;
122
+ if (hop >= MAX_REDIRECTS) return `Error: demasiadas redirecciones (${MAX_REDIRECTS}).`;
123
+ let next;
124
+ try {
125
+ next = new URL(location, target);
126
+ } catch {
127
+ return `Error: redirección inválida (${location}).`;
128
+ }
129
+ if (next.protocol !== 'http:' && next.protocol !== 'https:') {
130
+ return `Error: redirección a un protocolo no permitido (${next.protocol}).`;
131
+ }
132
+ if (isBlockedHost(next.hostname)) {
133
+ return `Error: host bloqueado (${normalizeHost(next.hostname)}).`;
134
+ }
135
+ try {
136
+ await res.body?.cancel();
137
+ } catch {
138
+ /* el cuerpo del redirect no importa */
139
+ }
140
+ target = next;
141
+ }
136
142
  const type = res.headers.get('content-type') || '';
137
143
  const limit = Math.min(Math.max(Number(maxLength) || 20000, 500), MAX_FETCH_CHARS);
138
144
  const full = await res.text();
@@ -552,7 +558,7 @@ export function showPlanTool(_root, { title, steps } = {}) {
552
558
  * muestre y Telegram la mande como foto.
553
559
  */
554
560
  export async function screenshotTool(_root, { url, fullPage, width, height } = {}) {
555
- const parsed = normalizeHttpUrl(url);
561
+ const parsed = checkHttpUrl(url);
556
562
  if (parsed.error) return `Error: ${parsed.error}`;
557
563
  const size = {
558
564
  width: clampInt(width, 1280, 320, 3840),
@@ -589,7 +595,7 @@ export async function screenshotTool(_root, { url, fullPage, width, height } = {
589
595
 
590
596
  /** Errores y mensajes de consola de una pagina (ejecutando su JS). */
591
597
  export async function consoleTool(_root, { url, waitMs } = {}) {
592
- const parsed = normalizeHttpUrl(url);
598
+ const parsed = checkHttpUrl(url);
593
599
  if (parsed.error) return `Error: ${parsed.error}`;
594
600
 
595
601
  let result;
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
- import { normalizeHookModule, loadHooks } from '../src/hooks/index.js';
6
+ import { normalizeHookModule, loadHooks, DEFAULT_HOOK_PATTERNS } from '../src/hooks/index.js';
7
7
 
8
8
  test('normalizeHookModule acepta objeto events, funcion y onEvent', () => {
9
9
  const byEvents = normalizeHookModule({ default: { name: 'a', events: { 'run.end': () => {} } } }, 'x');
@@ -14,7 +14,7 @@ test('normalizeHookModule acepta objeto events, funcion y onEvent', () => {
14
14
  assert.equal(normalizeHookModule({ default: {} }, 'n'), null);
15
15
  });
16
16
 
17
- test('loadHooks carga .tamnora/hooks/*.mjs e ignora lo roto', async () => {
17
+ test('loadHooks no ejecuta nada por presencia: exige un patron explicito', async () => {
18
18
  const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-hooks-'));
19
19
  await mkdir(path.join(cwd, '.tamnora', 'hooks'), { recursive: true });
20
20
  await writeFile(
@@ -22,7 +22,10 @@ test('loadHooks carga .tamnora/hooks/*.mjs e ignora lo roto', async () => {
22
22
  "export default { name: 'demo', events: { 'run.end': () => {} } };\n"
23
23
  );
24
24
  await writeFile(path.join(cwd, '.tamnora', 'hooks', 'roto.mjs'), 'this is not valid javascript {{{\n');
25
- const hooks = await loadHooks(cwd, []);
25
+
26
+ assert.deepEqual(await loadHooks(cwd, []), [], 'sin patrones no se importa nada');
27
+
28
+ const hooks = await loadHooks(cwd, DEFAULT_HOOK_PATTERNS);
26
29
  assert.equal(hooks.length, 1);
27
30
  assert.equal(hooks[0].id, 'demo');
28
31
  assert.deepEqual(Object.keys(hooks[0].events), ['run.end']);
@@ -0,0 +1,50 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { checkHttpUrl, isBlockedAddress, isBlockedHost } from '../src/net/hosts.js';
4
+
5
+ test('isBlockedHost bloquea loopback, redes privadas y sufijos internos', () => {
6
+ for (const host of [
7
+ 'localhost',
8
+ 'foo.localhost',
9
+ 'app.local',
10
+ 'intranet',
11
+ 'metadata.internal',
12
+ '127.0.0.1',
13
+ '10.1.2.3',
14
+ '172.16.0.1',
15
+ '192.168.1.10',
16
+ '169.254.169.254',
17
+ '100.64.0.1',
18
+ '0.0.0.0',
19
+ '224.0.0.1',
20
+ ]) {
21
+ assert.equal(isBlockedHost(host), true, `deberia bloquear ${host}`);
22
+ }
23
+ for (const host of ['example.com', 'api.openai.com', '8.8.8.8', '2606:4700:4700::1111']) {
24
+ assert.equal(isBlockedHost(host), false, `no deberia bloquear ${host}`);
25
+ }
26
+ });
27
+
28
+ test('isBlockedHost cubre IPv6-mapped y rangos IPv6 internos', () => {
29
+ for (const host of ['[::1]', '::1', '[::ffff:127.0.0.1]', '[::ffff:7f00:1]', '[fd00::1]', '[fe80::1]', '[::]']) {
30
+ assert.equal(isBlockedHost(host), true, `deberia bloquear ${host}`);
31
+ }
32
+ assert.equal(isBlockedHost('[2001:db8::1]'), false);
33
+ });
34
+
35
+ test('isBlockedAddress valida el rango de una IP', () => {
36
+ assert.equal(isBlockedAddress('127.0.0.1'), true);
37
+ assert.equal(isBlockedAddress('::ffff:10.0.0.1'), true);
38
+ assert.equal(isBlockedAddress('100.64.1.1'), true);
39
+ assert.equal(isBlockedAddress('8.8.4.4'), false);
40
+ assert.equal(isBlockedAddress('nope'), true);
41
+ });
42
+
43
+ test('checkHttpUrl valida protocolo y host', () => {
44
+ assert.match(checkHttpUrl('').error, /falta "url"/);
45
+ assert.match(checkHttpUrl('nope').error, /URL inválida/);
46
+ assert.match(checkHttpUrl('ftp://example.com').error, /http\(s\)/);
47
+ assert.match(checkHttpUrl('http://127.0.0.1:4517/').error, /host bloqueado/);
48
+ assert.match(checkHttpUrl('http://[::ffff:127.0.0.1]/').error, /host bloqueado/);
49
+ assert.equal(checkHttpUrl('https://example.com/x').url, 'https://example.com/x');
50
+ });
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
3
3
  import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
- import { normalizePlugin, loadPlugins } from '../src/plugins/index.js';
6
+ import { normalizePlugin, loadPlugins, DEFAULT_PLUGIN_PATTERNS } from '../src/plugins/index.js';
7
7
 
8
8
  test('normalizePlugin filtra tools invalidas', () => {
9
9
  const plugin = normalizePlugin({ default: { name: 'demo', tools: [{ name: 'x', handler: () => {} }, { name: 'bad' }] } }, 'z');
@@ -12,14 +12,16 @@ test('normalizePlugin filtra tools invalidas', () => {
12
12
  assert.equal(normalizePlugin({ default: {} }, 'n'), null);
13
13
  });
14
14
 
15
- test('loadPlugins carga .tamnora/plugins/*.mjs', async () => {
15
+ test('loadPlugins exige un patron explicito (no ejecuta por presencia)', async () => {
16
16
  const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-plugins-'));
17
17
  await mkdir(path.join(cwd, '.tamnora', 'plugins'), { recursive: true });
18
18
  await writeFile(
19
19
  path.join(cwd, '.tamnora', 'plugins', 'demo.mjs'),
20
20
  "export default { name: 'demo', tools: [{ name: 'saludar', handler: () => 'hi' }] };\n"
21
21
  );
22
- const plugins = await loadPlugins(cwd, []);
22
+ assert.deepEqual(await loadPlugins(cwd, []), []);
23
+
24
+ const plugins = await loadPlugins(cwd, DEFAULT_PLUGIN_PATTERNS);
23
25
  assert.equal(plugins.length, 1);
24
26
  assert.equal(plugins[0].tools[0].name, 'saludar');
25
27
  });
@@ -0,0 +1,150 @@
1
+ import { test, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import {
7
+ setHome,
8
+ databasePath,
9
+ Store,
10
+ ensureApp,
11
+ readApp,
12
+ writeApp,
13
+ exportConfig,
14
+ importConfig,
15
+ MASKED,
16
+ } from '../src/index.js';
17
+
18
+ const prevHome = process.env.TAMNORA_HOME;
19
+ const prevDb = process.env.TAMNORA_DB;
20
+
21
+ afterEach(() => {
22
+ setHome(null);
23
+ if (prevHome === undefined) delete process.env.TAMNORA_HOME;
24
+ else process.env.TAMNORA_HOME = prevHome;
25
+ if (prevDb === undefined) delete process.env.TAMNORA_DB;
26
+ else process.env.TAMNORA_DB = prevDb;
27
+ });
28
+
29
+ async function freshHome() {
30
+ const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-portable-'));
31
+ setHome(dir);
32
+ process.env.TAMNORA_DB = path.join(dir, 'tamnora.db');
33
+ return dir;
34
+ }
35
+
36
+ test('exportConfig enmascara los secretos y respeta las referencias env:', async () => {
37
+ await freshHome();
38
+ ensureApp();
39
+ const app = readApp();
40
+ app.user = { name: 'daniel', password: { algo: 'scrypt', salt: 'x', hash: 'y' } };
41
+ app.telegram = { token: '123:abc', allowedChatIds: ['7'] };
42
+ writeApp(app);
43
+
44
+ const store = new Store(databasePath());
45
+ store.saveProvider({ id: 'deepseek', apiKey: 'sk-real', baseUrl: 'https://deepseek', enabled: true });
46
+ store.saveProvider({ id: 'env', apiKey: 'env:OPENAI_API_KEY' });
47
+ store.setSetting('activeProvider', 'deepseek');
48
+ store.setSetting('telegram:chat:7', 'sess-1');
49
+ const data = exportConfig({ store, now: 0 });
50
+ store.close();
51
+
52
+ assert.equal(data.kind, 'tamnora-config');
53
+ assert.equal(data.version, 1);
54
+ assert.equal(data.secrets, false);
55
+ assert.equal(data.app.authSecret, MASKED);
56
+ assert.equal(data.app.user, MASKED);
57
+ assert.equal(data.app.telegram.token, MASKED);
58
+ assert.deepEqual(data.app.telegram.allowedChatIds, ['7']);
59
+ assert.equal(data.providers.find((p) => p.id === 'deepseek').apiKey, MASKED);
60
+ assert.equal(data.providers.find((p) => p.id === 'env').apiKey, 'env:OPENAI_API_KEY');
61
+ assert.equal(data.settings.activeProvider, 'deepseek');
62
+ assert.equal('telegram:chat:7' in data.settings, false);
63
+ });
64
+
65
+ test('exportConfig con withSecrets incluye las claves reales', async () => {
66
+ await freshHome();
67
+ ensureApp();
68
+ const app = readApp();
69
+ app.authSecret = 'super-secret';
70
+ writeApp(app);
71
+
72
+ const store = new Store(databasePath());
73
+ store.saveProvider({ id: 'deepseek', apiKey: 'sk-real' });
74
+ const data = exportConfig({ store, withSecrets: true });
75
+ store.close();
76
+
77
+ assert.equal(data.secrets, true);
78
+ assert.equal(data.app.authSecret, 'super-secret');
79
+ assert.equal(data.providers[0].apiKey, 'sk-real');
80
+ });
81
+
82
+ test('importConfig aplica proveedores y ajustes y conserva los secretos enmascarados', async () => {
83
+ await freshHome();
84
+ ensureApp();
85
+ const current = readApp();
86
+ current.authSecret = 'keep-me';
87
+ writeApp(current);
88
+
89
+ const store = new Store(databasePath());
90
+ store.saveProvider({ id: 'deepseek', apiKey: 'old-key' });
91
+ store.close();
92
+
93
+ const payload = {
94
+ kind: 'tamnora-config',
95
+ version: 1,
96
+ app: { port: 4599, authSecret: MASKED, user: MASKED, profile: { name: 'Daniel' } },
97
+ providers: [
98
+ { id: 'deepseek', baseUrl: 'https://api.deepseek.com', apiKey: MASKED, models: ['chat'], enabled: true },
99
+ { id: 'groq', baseUrl: 'https://groq', apiKey: 'gsk-new', enabled: true },
100
+ ],
101
+ settings: { activeProvider: 'groq', strictScope: true, 'telegram:chat:7': 'ignored' },
102
+ };
103
+
104
+ const store2 = new Store(databasePath());
105
+ const summary = importConfig(store2, payload);
106
+ const deepseek = store2.getProvider('deepseek');
107
+ const groq = store2.getProvider('groq');
108
+ const strict = store2.getSetting('strictScope');
109
+ store2.close();
110
+
111
+ assert.equal(summary.providers, 2);
112
+ assert.equal(summary.settings, 2);
113
+ assert.equal(readApp().port, 4599);
114
+ assert.equal(readApp().authSecret, 'keep-me');
115
+ assert.equal(readApp().profile.name, 'Daniel');
116
+ assert.equal(deepseek.apiKey, 'old-key');
117
+ assert.equal(deepseek.baseUrl, 'https://api.deepseek.com');
118
+ assert.equal(groq.apiKey, 'gsk-new');
119
+ assert.equal(strict, true);
120
+ });
121
+
122
+ test('importConfig avisa cuando una clave enmascarada no tiene existente y no escribe con dryRun', async () => {
123
+ await freshHome();
124
+ const store = new Store(databasePath());
125
+ const payload = {
126
+ kind: 'tamnora-config',
127
+ version: 1,
128
+ providers: [{ id: 'nuevo', apiKey: MASKED }],
129
+ settings: { activeModel: 'x' },
130
+ };
131
+
132
+ const dry = importConfig(store, payload, { dryRun: true });
133
+ assert.deepEqual(dry.missingKeys, ['nuevo']);
134
+ assert.equal(store.getProvider('nuevo'), null);
135
+ assert.equal(store.getSetting('activeModel'), null);
136
+
137
+ const real = importConfig(store, payload);
138
+ assert.deepEqual(real.missingKeys, ['nuevo']);
139
+ assert.ok(store.getProvider('nuevo'));
140
+ assert.equal(store.getSetting('activeModel'), 'x');
141
+ store.close();
142
+ });
143
+
144
+ test('importConfig rechaza archivos que no son un export de Tamnora', async () => {
145
+ await freshHome();
146
+ const store = new Store(databasePath());
147
+ assert.throws(() => importConfig(store, { hello: 'world' }), /no parece un export/i);
148
+ assert.throws(() => importConfig(store, { kind: 'tamnora-config', version: 99 }), /no soportada/i);
149
+ store.close();
150
+ });
@@ -133,7 +133,7 @@ test('permission "ask" is denied by default and allowed with yes', async () => {
133
133
  test('fetchUrlTool rejects invalid URLs and private hosts', async () => {
134
134
  const root = await makeWorkspace();
135
135
  assert.match(await fetchUrlTool(root, { url: 'not-a-url' }), /URL inválida/);
136
- assert.match(await fetchUrlTool(root, { url: 'ftp://example.com' }), /http\/https/);
136
+ assert.match(await fetchUrlTool(root, { url: 'ftp://example.com' }), /http\(s\)/);
137
137
  assert.match(await fetchUrlTool(root, { url: 'http://127.0.0.1:9/' }), /host bloqueado/);
138
138
  assert.match(await fetchUrlTool(root, { url: 'http://192.168.1.10/' }), /host bloqueado/);
139
139
  assert.match(await fetchUrlTool(root, { url: 'http://169.254.169.254/' }), /host bloqueado/);
@@ -192,6 +192,55 @@ test('fetchUrlTool raw returns the original body', async () => {
192
192
  assert.match(out, /<p>hola<\/p>/);
193
193
  });
194
194
 
195
+ test('fetchUrlTool revalida el host en cada redirect', async () => {
196
+ let calls = 0;
197
+ const out = await withFetch(
198
+ async () => {
199
+ calls++;
200
+ return new Response(null, { status: 302, headers: { location: 'http://127.0.0.1:9/admin' } });
201
+ },
202
+ () => fetchUrlTool(null, { url: 'https://example.com/' })
203
+ );
204
+ assert.match(out, /host bloqueado/);
205
+ assert.equal(calls, 1, 'no debe pedir el destino bloqueado');
206
+ });
207
+
208
+ test('fetchUrlTool bloquea redirects a IPv6 internos y protocolos no http', async () => {
209
+ const mapped = await withFetch(
210
+ async () => new Response(null, { status: 301, headers: { location: 'http://[::ffff:127.0.0.1]/' } }),
211
+ () => fetchUrlTool(null, { url: 'https://example.com/' })
212
+ );
213
+ assert.match(mapped, /host bloqueado/);
214
+
215
+ const file = await withFetch(
216
+ async () => new Response(null, { status: 302, headers: { location: 'file:///etc/passwd' } }),
217
+ () => fetchUrlTool(null, { url: 'https://example.com/' })
218
+ );
219
+ assert.match(file, /protocolo no permitido/);
220
+ });
221
+
222
+ test('fetchUrlTool sigue redirects a hosts publicos', async () => {
223
+ const out = await withFetch(
224
+ async (url) => {
225
+ if (String(url) === 'https://example.com/') {
226
+ return new Response(null, { status: 301, headers: { location: 'https://example.org/final' } });
227
+ }
228
+ return new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } });
229
+ },
230
+ () => fetchUrlTool(null, { url: 'https://example.com/' })
231
+ );
232
+ assert.match(out, /200 .*example\.org\/final/);
233
+ assert.match(out, /ok/);
234
+ });
235
+
236
+ test('fetchUrlTool corta cadenas de redirects demasiado largas', async () => {
237
+ const out = await withFetch(
238
+ async () => new Response(null, { status: 302, headers: { location: 'https://example.com/loop' } }),
239
+ () => fetchUrlTool(null, { url: 'https://example.com/' })
240
+ );
241
+ assert.match(out, /demasiadas redirecciones/);
242
+ });
243
+
195
244
  test('createTools combines local and MCP tool routing', async () => {
196
245
  const root = await makeWorkspace();
197
246
  const mcp = {
@@ -265,10 +314,14 @@ test('la tool speak valida el texto y la recorta', async () => {
265
314
 
266
315
  test('screenshot, console_errors y preview_url validan la URL sin lanzar el navegador', async () => {
267
316
  assert.match(previewUrlTool(null, { url: 'https://example.com/x' }), /example\.com/);
317
+ assert.match(previewUrlTool(null, { url: 'http://127.0.0.1:5173/' }), /127\.0\.0\.1/, 'preview permite loopback');
268
318
  assert.match(previewUrlTool(null, {}), /falta "url"/);
269
319
  assert.match(await screenshotTool(null, { url: 'ftp://x' }), /Solo se admite http/);
270
320
  assert.match(await screenshotTool(null, {}), /falta "url"/);
321
+ assert.match(await screenshotTool(null, { url: 'http://127.0.0.1:4517/' }), /host bloqueado/);
322
+ assert.match(await screenshotTool(null, { url: 'http://[fd00::1]/' }), /host bloqueado/);
271
323
  assert.match(await consoleTool(null, { url: 'no-url' }), /URL inválida/);
324
+ assert.match(await consoleTool(null, { url: 'http://localhost:4517/' }), /host bloqueado/);
272
325
  assert.equal(evaluatePermission('screenshot', {}, {}), 'allow');
273
326
  assert.equal(evaluatePermission('console_errors', {}, {}), 'allow');
274
327
  assert.equal(evaluatePermission('preview_url', {}, {}), 'allow');
@@ -1892,18 +1892,39 @@ function addAdvisorCard(container, { text, kind } = {}) {
1892
1892
  * el resumen que pasa a ser el contexto del modelo. Lo de arriba sigue visible
1893
1893
  * como histórico; al modelo solo le llega lo de abajo (más los mensajes nuevos).
1894
1894
  */
1895
- function addCompactCard(container, { text, label = 'contexto compactado' } = {}) {
1895
+ function addCompactCard(container, { text, label, running = false } = {}) {
1896
1896
  if (!container) return null;
1897
- const card = el('div', 'compact-card');
1898
- card.append(el('div', 'compact-rule', label));
1897
+ const card = el('div', `compact-card${running ? ' running' : ''}`);
1898
+ const rule = el('div', 'compact-rule', label ?? (running ? 'compactando…' : 'contexto compactado'));
1899
+ card.append(rule);
1900
+ if (running) {
1901
+ // La compactación puede tardar (el modelo resume el historial): mostramos el
1902
+ // corte con un loader animado hasta que el resumen llegue.
1903
+ rule.append(el('span', 'loader loader-thinking'));
1904
+ } else {
1905
+ const clean = String(text ?? '').trim();
1906
+ if (clean) {
1907
+ const body = el('div', 'compact-text text');
1908
+ body.innerHTML = renderMarkdown(clean);
1909
+ card.append(body);
1910
+ }
1911
+ }
1912
+ container.appendChild(card);
1913
+ return card;
1914
+ }
1915
+
1916
+ /** Completa la tarjeta de compactación en curso con el resumen del modelo. */
1917
+ function finishCompactCard(card, text) {
1918
+ if (!card) return;
1919
+ card.classList.remove('running');
1920
+ const rule = card.querySelector('.compact-rule');
1921
+ if (rule) rule.textContent = 'contexto compactado';
1899
1922
  const clean = String(text ?? '').trim();
1900
- if (clean) {
1923
+ if (clean && !card.querySelector('.compact-text')) {
1901
1924
  const body = el('div', 'compact-text text');
1902
1925
  body.innerHTML = renderMarkdown(clean);
1903
1926
  card.append(body);
1904
1927
  }
1905
- container.appendChild(card);
1906
- return card;
1907
1928
  }
1908
1929
 
1909
1930
  function renderStored(message, pane) {
@@ -3853,17 +3874,21 @@ async function runCommand(name, arg) {
3853
3874
  }
3854
3875
  case 'compact': {
3855
3876
  if (!state.sessionId) return needSession();
3856
- toast('Compactando la conversación…');
3877
+ // Animación en el pane: el corte queda a la vista con un loader mientras
3878
+ // el modelo resume (puede tardar), y se completa con el resumen.
3879
+ const card = addCompactCard(activePane, { running: true });
3880
+ scrollMessages();
3857
3881
  const out = await api(`/api/sessions/${state.sessionId}/compact`, { method: 'POST' }).catch((error) => ({
3858
3882
  error: error.message,
3859
3883
  }));
3860
3884
  if (out.error) {
3885
+ card?.remove();
3861
3886
  notify(`No se pudo compactar: ${out.error}`, 'notice err');
3862
3887
  return { handled: true };
3863
3888
  }
3864
3889
  // No borramos el pane: el historial queda a la vista y marcamos el corte
3865
3890
  // con la línea + el resumen que ahora ve el modelo.
3866
- addCompactCard(activePane, { text: out.summary });
3891
+ finishCompactCard(card, out.summary);
3867
3892
  scrollMessages();
3868
3893
  refreshSessions();
3869
3894
  return { handled: true };
@@ -75,7 +75,7 @@
75
75
  </head>
76
76
  <body>
77
77
  <header class="topbar">
78
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.21.0</span></div>
78
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.21.2</span></div>
79
79
  <nav class="tabs">
80
80
  <button class="tab active" data-tab="chat">chat</button>
81
81
  <button class="tab" data-tab="providers">proveedores</button>
@@ -2505,6 +2505,10 @@ dialog.modal::backdrop { background: rgba(24, 22, 16, 0.55); backdrop-filter: bl
2505
2505
  }
2506
2506
  .compact-rule::before,
2507
2507
  .compact-rule::after { content: ''; flex: 1; height: 1px; background: var(--border); }
2508
+ .compact-card.running .compact-rule { color: var(--accent); }
2509
+ .compact-card.running .compact-rule::before,
2510
+ .compact-card.running .compact-rule::after { background: var(--accent-soft); }
2511
+ .compact-rule .loader-thinking { width: 22px; height: 6px; }
2508
2512
  .compact-text {
2509
2513
  margin-top: 10px;
2510
2514
  padding-left: 12px;
@@ -4,6 +4,25 @@ import path from 'node:path';
4
4
  import { DEFAULT_PROCESS_ALLOW, killPidTree } from './core.js';
5
5
 
6
6
  const MAX_LOG_LINES = 400;
7
+ const MAX_ARGS = 32;
8
+ const MAX_ARG_LENGTH = 1000;
9
+ // Con `shell: true` (Windows y comando sin ruta) Node pega los args sin
10
+ // escapar: cualquier metacaracter del shell permitiria inyectar comandos.
11
+ const SAFE_SHELL_ARG = /^[^&|<>^%!"\r\n]+$/;
12
+
13
+ function normalizeArgs(args) {
14
+ if (args === undefined || args === null) return [];
15
+ if (!Array.isArray(args)) throw new Error('"args" debe ser una lista de strings.');
16
+ if (args.length > MAX_ARGS) throw new Error(`Demasiados argumentos (máximo ${MAX_ARGS}).`);
17
+ return args.map((arg) => {
18
+ const value = String(arg);
19
+ if (value.length > MAX_ARG_LENGTH) {
20
+ throw new Error(`Un argumento supera los ${MAX_ARG_LENGTH} caracteres.`);
21
+ }
22
+ if (/[\0\r\n]/.test(value)) throw new Error('Argumento inválido.');
23
+ return value;
24
+ });
25
+ }
7
26
 
8
27
  function killTree(child) {
9
28
  if (!child || child.killed) return;
@@ -49,18 +68,26 @@ export function createProcManager({ allow } = {}) {
49
68
  if (!allows(bin)) {
50
69
  throw new Error(`Comando no permitido: ${bin}. Agregalo a processes.allow en tamnora.json.`);
51
70
  }
52
- const child = spawn(bin, args.map(String), {
71
+ const list = normalizeArgs(args);
72
+ const shell = process.platform === 'win32' && !path.isAbsolute(bin);
73
+ if (shell) {
74
+ const bad = list.find((arg) => !SAFE_SHELL_ARG.test(arg));
75
+ if (bad !== undefined) {
76
+ throw new Error(`Argumento no permitido con shell en Windows: "${bad}".`);
77
+ }
78
+ }
79
+ const child = spawn(bin, list, {
53
80
  cwd,
54
81
  stdio: ['ignore', 'pipe', 'pipe'],
55
82
  windowsHide: true,
56
- shell: process.platform === 'win32' && !path.isAbsolute(bin),
83
+ shell,
57
84
  detached: process.platform !== 'win32',
58
85
  });
59
86
  const id = randomUUID().slice(0, 8);
60
87
  const record = {
61
88
  id,
62
89
  command: bin,
63
- args: args.map(String),
90
+ args: list,
64
91
  cwd,
65
92
  port: port ? Number(port) : null,
66
93
  pid: child.pid,
@@ -16,9 +16,7 @@ export function registerProcs(route, ctx) {
16
16
  });
17
17
 
18
18
  route('POST', '/api/procs/start', async ({ req, res }) => {
19
- if (!ctx.authEnabled && !requireLocal(req, res, 'Iniciar procesos solo está disponible en conexiones locales.')) {
20
- return;
21
- }
19
+ if (!requireLocal(req, res, 'Iniciar procesos solo está disponible en conexiones locales.')) return;
22
20
  if (!ctx.procsEnabled) return sendError(res, 403, 'Los procesos están deshabilitados (processes.enabled = false).');
23
21
  const body = await readJson(req);
24
22
  const dir = resolveDirOr400(res, body.cwd, ctx.cwd);
@@ -38,9 +36,7 @@ export function registerProcs(route, ctx) {
38
36
  });
39
37
 
40
38
  route('POST', '/api/procs/:id/stop', ({ req, res, params }) => {
41
- if (!ctx.authEnabled && !requireLocal(req, res, 'Detener procesos solo está disponible en conexiones locales.')) {
42
- return;
43
- }
39
+ if (!requireLocal(req, res, 'Detener procesos solo está disponible en conexiones locales.')) return;
44
40
  if (!ctx.procs.stop(params.id)) return sendError(res, 404, 'Proceso no encontrado.');
45
41
  sendJson(res, 200, { ok: true });
46
42
  });
@@ -254,11 +254,18 @@ export async function createTamnoraServer({
254
254
  ctx.systemPrompt = (await ctx.systemPromptFor(cwd)) || DEFAULT_SYSTEM_PROMPT;
255
255
  ctx.agentTools = createAgentTools(ctx);
256
256
 
257
- // Hooks locales (opt-in: solo si hay archivos). Observan el ciclo de vida.
257
+ // Hooks locales (opt-in explicito: solo los patrones de `config.hooks[]`).
258
+ // Observan el ciclo de vida.
258
259
  ctx.hooks = await createHookRunner(ctx, config.hooks ?? []);
260
+ if (ctx.hooks.count) {
261
+ process.stderr.write(`tamnora: ${ctx.hooks.count} hook(s) cargados desde config.hooks\n`);
262
+ }
259
263
 
260
- // Plugins locales: tools propias del usuario (`.tamnora/plugins/*.mjs`).
264
+ // Plugins locales: tools propias del usuario (solo `config.plugins[]`).
261
265
  ctx.plugins = await createPlugins(ctx, config.plugins ?? []);
266
+ if (ctx.plugins.count) {
267
+ process.stderr.write(`tamnora: ${ctx.plugins.count} tool(s) de plugin cargadas desde config.plugins\n`);
268
+ }
262
269
 
263
270
  // Canales de mensajeria (Telegram via long polling) si hay token.
264
271
  const channels = createChannelsManager(ctx, {