@danieltmn/tamnora 0.25.1 → 0.26.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.
Files changed (99) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +13 -25
  3. package/SECURITY.md +3 -4
  4. package/harness/packages/cli/src/cli.js +0 -3
  5. package/harness/packages/cli/src/commands/bridge.js +0 -1
  6. package/harness/packages/cli/src/commands/repl.js +3 -3
  7. package/harness/packages/cli/src/commands/run.js +1 -1
  8. package/harness/packages/cli/src/commands/server.js +1 -1
  9. package/harness/packages/cli/src/commands/tui.js +1 -1
  10. package/harness/packages/cli/src/lib/util.js +2 -2
  11. package/harness/packages/cli/src/tui/app.js +2 -2
  12. package/harness/packages/cli/test/tunnel.test.js +5 -32
  13. package/harness/packages/core/src/agents/index.js +13 -41
  14. package/harness/packages/core/src/config/app.js +0 -4
  15. package/harness/packages/core/src/config/home.js +1 -20
  16. package/harness/packages/core/src/config/index.js +5 -8
  17. package/harness/packages/core/src/extensions/load.js +22 -0
  18. package/harness/packages/core/src/frontmatter.js +32 -0
  19. package/harness/packages/core/src/hooks/index.js +2 -16
  20. package/harness/packages/core/src/index.js +2 -14
  21. package/harness/packages/core/src/plugins/index.js +2 -16
  22. package/harness/packages/core/src/procs/project.js +68 -0
  23. package/harness/packages/core/src/procs/static.js +17 -0
  24. package/harness/packages/core/src/rules/index.js +8 -27
  25. package/harness/packages/core/src/skills/index.js +8 -19
  26. package/harness/packages/core/src/storage/sqlite.js +595 -690
  27. package/harness/packages/core/src/tunnel.js +9 -147
  28. package/harness/packages/core/test/home.test.js +2 -35
  29. package/harness/packages/core/test/procs.test.js +3 -64
  30. package/harness/packages/core/test/storage.test.js +1 -12
  31. package/harness/packages/server/public/app.js +2 -19
  32. package/harness/packages/server/public/appearance.js +2 -56
  33. package/harness/packages/server/public/boot.js +2 -2
  34. package/harness/packages/server/public/cards.js +2 -21
  35. package/harness/packages/server/public/chat.js +0 -16
  36. package/harness/packages/server/public/chatFormat.js +0 -11
  37. package/harness/packages/server/public/chrome.js +2 -5
  38. package/harness/packages/server/public/commands.js +1 -2
  39. package/harness/packages/server/public/explorer.js +7 -10
  40. package/harness/packages/server/public/icons/icon-128x128.png +0 -0
  41. package/harness/packages/server/public/icons/icon-144x144.png +0 -0
  42. package/harness/packages/server/public/icons/icon-152x152.png +0 -0
  43. package/harness/packages/server/public/icons/icon-192x192.png +0 -0
  44. package/harness/packages/server/public/icons/icon-384x384.png +0 -0
  45. package/harness/packages/server/public/icons/icon-512x512.png +0 -0
  46. package/harness/packages/server/public/icons/icon-72x72.png +0 -0
  47. package/harness/packages/server/public/icons/icon-96x96.png +0 -0
  48. package/harness/packages/server/public/icons/logo.png +0 -0
  49. package/harness/packages/server/public/icons/logo_light.png +0 -0
  50. package/harness/packages/server/public/index.html +1228 -1326
  51. package/harness/packages/server/public/landing/boot.js +2 -3
  52. package/harness/packages/server/public/landing/index.html +3 -367
  53. package/harness/packages/server/public/logo.png +0 -0
  54. package/harness/packages/server/public/settings.js +1255 -1322
  55. package/harness/packages/server/public/settingsNav.js +0 -1
  56. package/harness/packages/server/public/styles.css +2341 -2867
  57. package/harness/packages/server/public/system.js +356 -495
  58. package/harness/packages/server/public/voice.js +6 -22
  59. package/harness/packages/server/src/auth.js +4 -15
  60. package/harness/packages/server/src/cli.js +1 -1
  61. package/harness/packages/server/src/lib/agents.js +13 -27
  62. package/harness/packages/server/src/lib/appearance.js +3 -22
  63. package/harness/packages/server/src/lib/channels/telegram/commands.js +1 -1
  64. package/harness/packages/server/src/lib/definitions.js +40 -0
  65. package/harness/packages/server/src/lib/skills.js +14 -21
  66. package/harness/packages/server/src/lib/ttsSettings.js +2 -2
  67. package/harness/packages/server/src/lib/workspace.js +9 -11
  68. package/harness/packages/server/src/routes/chat.js +0 -43
  69. package/harness/packages/server/src/routes/preview.js +5 -22
  70. package/harness/packages/server/src/routes/procs.js +2 -4
  71. package/harness/packages/server/src/routes/tts.js +1 -1
  72. package/harness/packages/server/src/server.js +1 -5
  73. package/harness/packages/server/test/appearance-route.test.js +3 -3
  74. package/harness/packages/server/test/appearance.test.js +12 -38
  75. package/harness/packages/server/test/chatFormat.test.js +0 -8
  76. package/harness/packages/server/test/consoleTabs.test.js +4 -4
  77. package/harness/packages/server/test/ops.test.js +8 -56
  78. package/harness/packages/server/test/preview.test.js +2 -42
  79. package/harness/packages/server/test/security.test.js +194 -370
  80. package/harness/packages/server/test/settingsNav.test.js +2 -2
  81. package/harness/packages/server/test/tts.test.js +4 -4
  82. package/harness/tamnora.example.json +1 -1
  83. package/package.json +2 -2
  84. package/harness/packages/cli/src/commands/eval.js +0 -186
  85. package/harness/packages/core/src/eval/dataset.js +0 -47
  86. package/harness/packages/core/src/eval/judge.js +0 -39
  87. package/harness/packages/core/src/eval/report.js +0 -48
  88. package/harness/packages/core/src/eval/runTask.js +0 -62
  89. package/harness/packages/core/src/eval/runner.js +0 -153
  90. package/harness/packages/core/src/eval/scorers.js +0 -217
  91. package/harness/packages/core/src/eval/task.js +0 -59
  92. package/harness/packages/core/src/procs/detect.js +0 -149
  93. package/harness/packages/core/test/eval.test.js +0 -144
  94. package/harness/packages/server/public/icons (1).zip +0 -0
  95. package/harness/packages/server/src/lib/advisor.js +0 -198
  96. package/harness/packages/server/src/routes/advisor.js +0 -79
  97. package/harness/packages/server/src/routes/eval.js +0 -100
  98. package/harness/packages/server/test/advisor.test.js +0 -249
  99. package/harness/scripts/example-dataset.jsonl +0 -4
@@ -1,370 +1,194 @@
1
- import { test } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import http from 'node:http';
4
- import { mkdtemp, writeFile, unlink, mkdir } from 'node:fs/promises';
5
- import { tmpdir } from 'node:os';
6
- import path from 'node:path';
7
- import { createTamnoraServer } from '../src/index.js';
8
- import { setHome, ensureApp, writeApp, makeUser, bridgeStatePath } from '../../core/src/index.js';
9
- import { isLoopback, localOk } from '../src/lib/workspace.js';
10
- import { loginClear } from '../src/auth.js';
11
-
12
- const fakeProvider = {
13
- id: 'fake',
14
- async streamChat(_config, { onEvent }) {
15
- onEvent?.({ type: 'text', delta: 'hola', full: 'hola' });
16
- return { content: 'hola', reasoning: '', toolCalls: [] };
17
- },
18
- };
19
-
20
- async function startServer(overrides = {}) {
21
- const cwd = overrides.cwd ?? (await mkdtemp(path.join(tmpdir(), 'tamnora-sec-')));
22
- setHome(cwd);
23
- const instance = await createTamnoraServer({
24
- cwd,
25
- dbPath: path.join(cwd, 'tamnora.db'),
26
- loadMcp: false,
27
- resolveProvider: () => ({ provider: fakeProvider, config: {} }),
28
- ...overrides,
29
- });
30
- const info = await instance.listen({ port: 0 });
31
- return { instance, base: info.url, port: info.port, cwd, home: cwd };
32
- }
33
-
34
- /** Deja un bridge.json apuntando a un supervisor vivo (este proceso). */
35
- async function fakeTunnel() {
36
- await writeFile(bridgeStatePath(), `${JSON.stringify({ pid: process.pid, url: 'https://fake.tunnelmole.net', provider: 'tunnelmole', port: 4517 })}\n`);
37
- }
38
-
39
- async function clearTunnel() {
40
- try {
41
- await unlink(bridgeStatePath());
42
- } catch {
43
- /* nada */
44
- }
45
- }
46
-
47
- test('isLoopback solo acepta direcciones de loopback', () => {
48
- for (const good of ['127.0.0.1', '127.9.9.9', '::1', '0:0:0:0:0:0:0:1', '::ffff:127.0.0.1']) {
49
- assert.ok(isLoopback(good), good);
50
- }
51
- for (const bad of ['192.168.1.5', '::ffff:192.168.1.5', '', null, 'fe80::1']) {
52
- assert.ok(!isLoopback(bad), String(bad));
53
- }
54
- });
55
-
56
- test('localOk exige loopback en remoteAddress y sin tunel activo', async () => {
57
- const fake = (remote) => ({ socket: { remoteAddress: remote } });
58
- assert.ok(localOk(fake('127.0.0.1')));
59
- assert.ok(!localOk(fake('192.168.1.5')));
60
- // localAddress (la direccion de bind) ya no engana.
61
- assert.ok(!localOk({ socket: { remoteAddress: '10.0.0.8', localAddress: '127.0.0.1' } }));
62
-
63
- const { instance, home } = await startServer();
64
- try {
65
- // Con tunel activo, hasta una conexion loopback deja de ser local.
66
- await fakeTunnel();
67
- assert.ok(!localOk({ socket: { remoteAddress: '127.0.0.1' } }));
68
- assert.equal(home, path.dirname(bridgeStatePath()));
69
- } finally {
70
- await clearTunnel();
71
- await instance.close();
72
- }
73
- });
74
-
75
- test('un bridge de tailscale no degrada localOk (red privada por loopback)', async () => {
76
- const { instance } = await startServer();
77
- try {
78
- await writeFile(
79
- bridgeStatePath(),
80
- `${JSON.stringify({ pid: process.pid, url: 'https://pc.tailnet.ts.net', provider: 'tailscale', port: 4517 })}\n`
81
- );
82
- assert.ok(localOk({ socket: { remoteAddress: '127.0.0.1' } }), 'loopback sigue siendo local con tailscale');
83
- } finally {
84
- await clearTunnel();
85
- await instance.close();
86
- }
87
- });
88
-
89
- test('POST /api/shell responde 403 mientras el tunel esta activo', async () => {
90
- const { instance, base } = await startServer();
91
- try {
92
- const shell = (extraHeaders = {}) =>
93
- fetch(`${base}/api/shell`, {
94
- method: 'POST',
95
- headers: { 'Content-Type': 'application/json', ...extraHeaders },
96
- body: JSON.stringify({ command: 'echo tamnora-ok' }),
97
- });
98
-
99
- const ok = await shell();
100
- assert.equal(ok.status, 200);
101
- const body = await ok.json();
102
- assert.match(String(body.output), /tamnora-ok/);
103
-
104
- await fakeTunnel();
105
- const blocked = await shell();
106
- assert.equal(blocked.status, 403);
107
-
108
- // Con header falsificado tampoco pasa: remoteAddress manda.
109
- const spoofed = await shell({ 'x-forwarded-for': '203.0.113.9' });
110
- assert.equal(spoofed.status, 403);
111
- } finally {
112
- await clearTunnel();
113
- await instance.close();
114
- }
115
- });
116
-
117
- test('GET /api/files: sin tunel acepta ?root; con tunel y sin auth responde 403', async () => {
118
- const { instance, base } = await startServer();
119
- try {
120
- // Sin tunel y desde loopback el request es local: cualquier raiz va.
121
- const outside = await mkdtemp(path.join(tmpdir(), 'tamnora-fuera-'));
122
- mkdir(path.join(outside, 'sub'));
123
- const free = await (await fetch(`${base}/api/files?root=${encodeURIComponent(outside)}`)).json();
124
- assert.equal(free.root, outside, 'conexion local puede explorar otra raiz');
125
-
126
- const escape = await fetch(`${base}/api/files?path=..%2F..&root=${encodeURIComponent(outside)}`);
127
- assert.equal(escape.status, 400);
128
-
129
- await fakeTunnel();
130
- const viaTunnel = await fetch(`${base}/api/files`);
131
- assert.equal(viaTunnel.status, 403);
132
- } finally {
133
- await clearTunnel();
134
- await instance.close();
135
- }
136
- });
137
-
138
- test('con auth, git/files quedan usables por el tunel y confinados al workspace', async () => {
139
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-sec-auth-'));
140
- setHome(cwd);
141
- const app = ensureApp();
142
- app.user = makeUser('admin', 'secreto123');
143
- writeApp(app);
144
-
145
- const instance = await createTamnoraServer({
146
- cwd,
147
- dbPath: path.join(cwd, 'tamnora.db'),
148
- loadMcp: false,
149
- resolveProvider: () => ({ provider: fakeProvider, config: {} }),
150
- });
151
- const info = await instance.listen({ port: 0 });
152
- const base = info.url;
153
- try {
154
- const login = await fetch(`${base}/api/auth/login`, {
155
- method: 'POST',
156
- headers: { 'Content-Type': 'application/json' },
157
- body: JSON.stringify({ username: 'admin', password: 'secreto123' }),
158
- });
159
- assert.equal(login.status, 200);
160
- const cookie = login.headers.get('set-cookie');
161
- const { csrf } = await login.json();
162
-
163
- await fakeTunnel();
164
-
165
- const files = await fetch(`${base}/api/files`, { headers: { cookie } });
166
- assert.equal(files.status, 200);
167
- const listing = await files.json();
168
- assert.equal(listing.root, cwd, 'por el tunel la raiz es el workspace');
169
-
170
- const shell = await fetch(`${base}/api/shell`, {
171
- method: 'POST',
172
- headers: { 'Content-Type': 'application/json', cookie, 'x-csrf': csrf },
173
- body: JSON.stringify({ command: 'echo nope' }),
174
- });
175
- assert.equal(shell.status, 403, 'el shell directo sigue cerrado por el tunel');
176
- } finally {
177
- await clearTunnel();
178
- await instance.close();
179
- }
180
- });
181
-
182
- test('el lockout de login no se esquiva falsificando X-Forwarded-For', async () => {
183
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-sec-lock-'));
184
- setHome(cwd);
185
- const app = ensureApp();
186
- app.user = makeUser('locktest', 'secreto123');
187
- writeApp(app);
188
-
189
- const instance = await createTamnoraServer({
190
- cwd,
191
- dbPath: path.join(cwd, 'tamnora.db'),
192
- loadMcp: false,
193
- resolveProvider: () => ({ provider: fakeProvider, config: {} }),
194
- });
195
- const info = await instance.listen({ port: 0 });
196
- const base = info.url;
197
- try {
198
- const attempt = (xff) =>
199
- fetch(`${base}/api/auth/login`, {
200
- method: 'POST',
201
- headers: { 'Content-Type': 'application/json', ...(xff ? { 'x-forwarded-for': xff } : {}) },
202
- body: JSON.stringify({ username: 'locktest', password: 'mala' }),
203
- });
204
-
205
- for (let i = 0; i < 5; i++) {
206
- await attempt(i % 2 ? '203.0.113.1' : '203.0.113.2');
207
- }
208
- const lockedPlain = await attempt();
209
- assert.equal(lockedPlain.status, 429);
210
-
211
- const lockedSpoof = await attempt('198.51.100.7');
212
- assert.equal(lockedSpoof.status, 429, 'XFF falsificado no resetea el contador');
213
- } finally {
214
- loginClear({ socket: { remoteAddress: '127.0.0.1' } });
215
- await instance.close();
216
- }
217
- });
218
-
219
- test('la cookie va con Secure cuando hay tunel y el Host es publico', async () => {
220
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-sec-cookie-'));
221
- setHome(cwd);
222
- const app = ensureApp();
223
- app.user = makeUser('admin', 'secreto123');
224
- writeApp(app);
225
-
226
- const instance = await createTamnoraServer({
227
- cwd,
228
- dbPath: path.join(cwd, 'tamnora.db'),
229
- loadMcp: false,
230
- resolveProvider: () => ({ provider: fakeProvider, config: {} }),
231
- });
232
- const info = await instance.listen({ port: 0 });
233
- try {
234
- await fakeTunnel();
235
-
236
- const rawLogin = (host) =>
237
- new Promise((resolve, reject) => {
238
- const body = JSON.stringify({ username: 'admin', password: 'secreto123' });
239
- const req = http.request(
240
- { host: '127.0.0.1', port: info.port, path: '/api/auth/login', method: 'POST', headers: { Host: host, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } },
241
- (res) => {
242
- let out = '';
243
- res.on('data', (c) => (out += c));
244
- res.on('end', () => resolve({ status: res.statusCode, setCookie: res.headers['set-cookie']?.[0] ?? '' }));
245
- }
246
- );
247
- req.on('error', reject);
248
- req.end(body);
249
- });
250
-
251
- const viaTunnel = await rawLogin('algo.tunnelmole.net');
252
- assert.match(String(viaTunnel.setCookie), /Secure/, 'por el tunel la cookie es Secure');
253
-
254
- const local = await rawLogin('localhost:4517');
255
- assert.ok(!/Secure/i.test(String(local.setCookie)), 'local http no fuerza Secure');
256
- } finally {
257
- await clearTunnel();
258
- await instance.close();
259
- }
260
- });
261
-
262
- /** Request crudo para poder fijar headers que `fetch` no deja (Host, Origin). */
263
- function rawRequest({ port, path: reqPath, method = 'GET', headers = {}, body = '' }) {
264
- return new Promise((resolve, reject) => {
265
- const req = http.request(
266
- {
267
- host: '127.0.0.1',
268
- port,
269
- path: reqPath,
270
- method,
271
- headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...headers },
272
- },
273
- (res) => {
274
- let out = '';
275
- res.on('data', (c) => (out += c));
276
- res.on('end', () => resolve({ status: res.statusCode, body: out }));
277
- }
278
- );
279
- req.on('error', reject);
280
- req.end(body);
281
- });
282
- }
283
-
284
- test('las mutaciones rechazan un Origin ajeno, con o sin auth (CSRF drive-by)', async () => {
285
- const { instance, port } = await startServer();
286
- try {
287
- const shell = (headers) =>
288
- rawRequest({
289
- port,
290
- path: '/api/shell',
291
- method: 'POST',
292
- headers,
293
- body: JSON.stringify({ command: 'echo pwned' }),
294
- });
295
-
296
- // Una web maliciosa: simple request con Origin ajeno no ejecuta nada.
297
- const driveBy = await shell({ Origin: 'https://evil.example' });
298
- assert.equal(driveBy.status, 403, 'Origin ajeno bloqueado');
299
-
300
- const nullOrigin = await shell({ Origin: 'null' });
301
- assert.equal(nullOrigin.status, 403, 'Origin: null bloqueado');
302
-
303
- // Mismo origen (la consola) y clientes sin navegador (CLI) siguen pasando.
304
- const sameOrigin = await shell({ Origin: `http://127.0.0.1:${port}` });
305
- assert.equal(sameOrigin.status, 200, 'mismo origen permitido');
306
- assert.match(sameOrigin.body, /pwned/);
307
-
308
- const noOrigin = await shell({});
309
- assert.equal(noOrigin.status, 200, 'cliente sin Origin (no navegador) permitido');
310
- } finally {
311
- await instance.close();
312
- }
313
- });
314
-
315
- test('el Host de un dominio ajeno se rechaza (DNS rebinding)', async () => {
316
- const { instance, port } = await startServer();
317
- try {
318
- const health = (host) => rawRequest({ port, path: '/api/health', headers: { Host: host } });
319
-
320
- const rebinding = await health('evil.example:8080');
321
- assert.equal(rebinding.status, 403, 'Host de un dominio ajeno bloqueado');
322
-
323
- const local = await health('127.0.0.1');
324
- assert.equal(local.status, 200, 'IP literal permitida');
325
-
326
- const named = await health('mi-pc.tailnet.ts.net');
327
- assert.equal(named.status, 200, 'dominio de tailscale permitido');
328
-
329
- const viaTunnelHost = await health('abc.tunnelmole.net');
330
- assert.equal(viaTunnelHost.status, 200, 'dominio del tunel permitido');
331
- } finally {
332
- await instance.close();
333
- }
334
- });
335
-
336
- test('el lockout de login no se esquiva rotando el username', async () => {
337
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-sec-lock2-'));
338
- setHome(cwd);
339
- const app = ensureApp();
340
- app.user = makeUser('locktest2', 'secreto123');
341
- writeApp(app);
342
-
343
- const instance = await createTamnoraServer({
344
- cwd,
345
- dbPath: path.join(cwd, 'tamnora.db'),
346
- loadMcp: false,
347
- resolveProvider: () => ({ provider: fakeProvider, config: {} }),
348
- });
349
- const info = await instance.listen({ port: 0 });
350
- const base = info.url;
351
- try {
352
- const attempt = (username) =>
353
- fetch(`${base}/api/auth/login`, {
354
- method: 'POST',
355
- headers: { 'Content-Type': 'application/json' },
356
- body: JSON.stringify({ username, password: 'mala' }),
357
- });
358
-
359
- // Cada intento con un nombre distinto: el contador es por IP, no por usuario.
360
- for (let i = 0; i < 5; i++) {
361
- const res = await attempt(`intruso${i}`);
362
- assert.equal(res.status, 401, `intento ${i} sin bloquear todavia`);
363
- }
364
- const locked = await attempt('locktest2');
365
- assert.equal(locked.status, 429, 'tras 5 fallos el bloqueo aplica a toda la IP');
366
- } finally {
367
- loginClear({ socket: { remoteAddress: '127.0.0.1' } });
368
- await instance.close();
369
- }
370
- });
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import http from 'node:http';
4
+ import { mkdtemp } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import path from 'node:path';
7
+ import { createTamnoraServer } from '../src/index.js';
8
+ import { setHome, ensureApp, writeApp, makeUser } from '../../core/src/index.js';
9
+ import { isLoopback, localOk } from '../src/lib/workspace.js';
10
+ import { loginClear } from '../src/auth.js';
11
+
12
+ const fakeProvider = {
13
+ id: 'fake',
14
+ async streamChat(_config, { onEvent }) {
15
+ onEvent?.({ type: 'text', delta: 'hola', full: 'hola' });
16
+ return { content: 'hola', reasoning: '', toolCalls: [] };
17
+ },
18
+ };
19
+
20
+ async function startServer(overrides = {}) {
21
+ const cwd = overrides.cwd ?? (await mkdtemp(path.join(tmpdir(), 'tamnora-sec-')));
22
+ setHome(cwd);
23
+ const instance = await createTamnoraServer({
24
+ cwd,
25
+ dbPath: path.join(cwd, 'tamnora.db'),
26
+ loadMcp: false,
27
+ resolveProvider: () => ({ provider: fakeProvider, config: {} }),
28
+ ...overrides,
29
+ });
30
+ const info = await instance.listen({ port: 0 });
31
+ return { instance, base: info.url, port: info.port, cwd, home: cwd };
32
+ }
33
+
34
+ test('isLoopback solo acepta direcciones de loopback', () => {
35
+ for (const good of ['127.0.0.1', '127.9.9.9', '::1', '0:0:0:0:0:0:0:1', '::ffff:127.0.0.1']) {
36
+ assert.ok(isLoopback(good), good);
37
+ }
38
+ for (const bad of ['192.168.1.5', '::ffff:192.168.1.5', '', null, 'fe80::1']) {
39
+ assert.ok(!isLoopback(bad), String(bad));
40
+ }
41
+ });
42
+
43
+ test('localOk exige loopback en remoteAddress', () => {
44
+ const fake = (remote) => ({ socket: { remoteAddress: remote } });
45
+ assert.ok(localOk(fake('127.0.0.1')));
46
+ assert.ok(!localOk(fake('192.168.1.5')));
47
+ // localAddress (la direccion de bind) ya no engana.
48
+ assert.ok(!localOk({ socket: { remoteAddress: '10.0.0.8', localAddress: '127.0.0.1' } }));
49
+ });
50
+
51
+
52
+ test('el lockout de login no se esquiva falsificando X-Forwarded-For', async () => {
53
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-sec-lock-'));
54
+ setHome(cwd);
55
+ const app = ensureApp();
56
+ app.user = makeUser('locktest', 'secreto123');
57
+ writeApp(app);
58
+
59
+ const instance = await createTamnoraServer({
60
+ cwd,
61
+ dbPath: path.join(cwd, 'tamnora.db'),
62
+ loadMcp: false,
63
+ resolveProvider: () => ({ provider: fakeProvider, config: {} }),
64
+ });
65
+ const info = await instance.listen({ port: 0 });
66
+ const base = info.url;
67
+ try {
68
+ const attempt = (xff) =>
69
+ fetch(`${base}/api/auth/login`, {
70
+ method: 'POST',
71
+ headers: { 'Content-Type': 'application/json', ...(xff ? { 'x-forwarded-for': xff } : {}) },
72
+ body: JSON.stringify({ username: 'locktest', password: 'mala' }),
73
+ });
74
+
75
+ for (let i = 0; i < 5; i++) {
76
+ await attempt(i % 2 ? '203.0.113.1' : '203.0.113.2');
77
+ }
78
+ const lockedPlain = await attempt();
79
+ assert.equal(lockedPlain.status, 429);
80
+
81
+ const lockedSpoof = await attempt('198.51.100.7');
82
+ assert.equal(lockedSpoof.status, 429, 'XFF falsificado no resetea el contador');
83
+ } finally {
84
+ loginClear({ socket: { remoteAddress: '127.0.0.1' } });
85
+ await instance.close();
86
+ }
87
+ });
88
+
89
+ /** Request crudo para poder fijar headers que `fetch` no deja (Host, Origin). */
90
+ function rawRequest({ port, path: reqPath, method = 'GET', headers = {}, body = '' }) {
91
+ return new Promise((resolve, reject) => {
92
+ const req = http.request(
93
+ {
94
+ host: '127.0.0.1',
95
+ port,
96
+ path: reqPath,
97
+ method,
98
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...headers },
99
+ },
100
+ (res) => {
101
+ let out = '';
102
+ res.on('data', (c) => (out += c));
103
+ res.on('end', () => resolve({ status: res.statusCode, body: out }));
104
+ }
105
+ );
106
+ req.on('error', reject);
107
+ req.end(body);
108
+ });
109
+ }
110
+
111
+ test('las mutaciones rechazan un Origin ajeno, con o sin auth (CSRF drive-by)', async () => {
112
+ const { instance, port } = await startServer();
113
+ try {
114
+ const shell = (headers) =>
115
+ rawRequest({
116
+ port,
117
+ path: '/api/shell',
118
+ method: 'POST',
119
+ headers,
120
+ body: JSON.stringify({ command: 'echo pwned' }),
121
+ });
122
+
123
+ // Una web maliciosa: simple request con Origin ajeno no ejecuta nada.
124
+ const driveBy = await shell({ Origin: 'https://evil.example' });
125
+ assert.equal(driveBy.status, 403, 'Origin ajeno bloqueado');
126
+
127
+ const nullOrigin = await shell({ Origin: 'null' });
128
+ assert.equal(nullOrigin.status, 403, 'Origin: null bloqueado');
129
+
130
+ // Mismo origen (la consola) y clientes sin navegador (CLI) siguen pasando.
131
+ const sameOrigin = await shell({ Origin: `http://127.0.0.1:${port}` });
132
+ assert.equal(sameOrigin.status, 200, 'mismo origen permitido');
133
+ assert.match(sameOrigin.body, /pwned/);
134
+
135
+ const noOrigin = await shell({});
136
+ assert.equal(noOrigin.status, 200, 'cliente sin Origin (no navegador) permitido');
137
+ } finally {
138
+ await instance.close();
139
+ }
140
+ });
141
+
142
+ test('el Host de un dominio ajeno se rechaza (DNS rebinding)', async () => {
143
+ const { instance, port } = await startServer();
144
+ try {
145
+ const health = (host) => rawRequest({ port, path: '/api/health', headers: { Host: host } });
146
+
147
+ const rebinding = await health('evil.example:8080');
148
+ assert.equal(rebinding.status, 403, 'Host de un dominio ajeno bloqueado');
149
+
150
+ const local = await health('127.0.0.1');
151
+ assert.equal(local.status, 200, 'IP literal permitida');
152
+
153
+ const named = await health('mi-pc.tailnet.ts.net');
154
+ assert.equal(named.status, 200, 'dominio de tailscale permitido');
155
+ } finally {
156
+ await instance.close();
157
+ }
158
+ });
159
+
160
+ test('el lockout de login no se esquiva rotando el username', async () => {
161
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-sec-lock2-'));
162
+ setHome(cwd);
163
+ const app = ensureApp();
164
+ app.user = makeUser('locktest2', 'secreto123');
165
+ writeApp(app);
166
+
167
+ const instance = await createTamnoraServer({
168
+ cwd,
169
+ dbPath: path.join(cwd, 'tamnora.db'),
170
+ loadMcp: false,
171
+ resolveProvider: () => ({ provider: fakeProvider, config: {} }),
172
+ });
173
+ const info = await instance.listen({ port: 0 });
174
+ const base = info.url;
175
+ try {
176
+ const attempt = (username) =>
177
+ fetch(`${base}/api/auth/login`, {
178
+ method: 'POST',
179
+ headers: { 'Content-Type': 'application/json' },
180
+ body: JSON.stringify({ username, password: 'mala' }),
181
+ });
182
+
183
+ // Cada intento con un nombre distinto: el contador es por IP, no por usuario.
184
+ for (let i = 0; i < 5; i++) {
185
+ const res = await attempt(`intruso${i}`);
186
+ assert.equal(res.status, 401, `intento ${i} sin bloquear todavia`);
187
+ }
188
+ const locked = await attempt('locktest2');
189
+ assert.equal(locked.status, 429, 'tras 5 fallos el bloqueo aplica a toda la IP');
190
+ } finally {
191
+ loginClear({ socket: { remoteAddress: '127.0.0.1' } });
192
+ await instance.close();
193
+ }
194
+ });
@@ -16,7 +16,7 @@ test('foldText ignora mayusculas y acentos', () => {
16
16
  test('sin consulta devuelve todas las secciones, en el orden de los grupos', () => {
17
17
  assert.deepEqual(ids(filterSettings('')), ids(SETTINGS_SECTIONS));
18
18
  assert.deepEqual(ids(filterSettings(' ')), ids(SETTINGS_SECTIONS));
19
- assert.equal(SETTINGS_SECTIONS.length, 14);
19
+ assert.equal(SETTINGS_SECTIONS.length, 13);
20
20
  });
21
21
 
22
22
  test('busca por nombre sin importar acentos ni mayusculas', () => {
@@ -27,7 +27,7 @@ test('busca por nombre sin importar acentos ni mayusculas', () => {
27
27
 
28
28
  test('el nombre del grupo no tapa a la seccion: "voz" da Voz, no todo "Voz y canales"', () => {
29
29
  assert.deepEqual(ids(filterSettings('voz')), ['voz']);
30
- assert.deepEqual(ids(filterSettings('canales')), ['voz', 'transcripcion', 'asesor', 'telegram']);
30
+ assert.deepEqual(ids(filterSettings('canales')), ['voz', 'transcripcion', 'telegram']);
31
31
  });
32
32
 
33
33
  test('busca por palabra clave y por grupo', () => {
@@ -80,25 +80,25 @@ test('los ajustes de voz exponen defaults y sanitizan lo guardado', async () =>
80
80
  assert.equal(initial.announce.start, true);
81
81
  assert.equal(initial.announce.tools, true);
82
82
  assert.equal(initial.announce.end, true);
83
- assert.equal(initial.announce.summary, false);
83
+ assert.equal(initial.announce.answer, false);
84
84
 
85
85
  const put = await fetch(`${base}/api/tts/settings`, {
86
86
  method: 'PUT',
87
87
  headers: { 'Content-Type': 'application/json' },
88
- body: JSON.stringify({ enabled: true, voice: '<script>', rate: '150%', pitch: '999Hz', announce: { summary: true } }),
88
+ body: JSON.stringify({ enabled: true, voice: '<script>', rate: '150%', pitch: '999Hz', announce: { answer: true } }),
89
89
  });
90
90
  const saved = await put.json();
91
91
  assert.equal(saved.enabled, true);
92
92
  assert.equal(saved.voice, 'es-AR-ElenaNeural');
93
93
  assert.equal(saved.rate, '+100%');
94
94
  assert.equal(saved.pitch, '+50Hz');
95
- assert.equal(saved.announce.summary, true);
95
+ assert.equal(saved.announce.answer, true);
96
96
 
97
97
  const reread = await (await fetch(`${base}/api/tts/settings`)).json();
98
98
  assert.equal(reread.enabled, true);
99
99
  assert.equal(reread.rate, '+100%');
100
100
  assert.equal(reread.pitch, '+50Hz');
101
- assert.equal(reread.announce.summary, true);
101
+ assert.equal(reread.announce.answer, true);
102
102
  } finally {
103
103
  global.WebSocket = original;
104
104
  await instance.close();
@@ -54,7 +54,7 @@
54
54
  "enabled": true,
55
55
  "allow": ["npm", "node", "npx", "pnpm", "yarn", "bun", "php", "python", "composer"]
56
56
  },
57
- "preview": { "provider": "tailscale", "tailscalePort": 8443, "fallback": false },
57
+ "preview": { "provider": "tailscale", "tailscalePort": 8443 },
58
58
  "telegram": { "token": "", "allowedChatIds": [], "transcribeModel": "whisper-1" },
59
59
  "agents": { "maxRounds": 6 }
60
60
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.25.1",
4
- "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
3
+ "version": "0.26.1",
4
+ "description": "Harness local para IAs: agente CLI-first con TUI y consola web. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",
7
7
  "author": "tamnora",