@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.
- package/CHANGELOG.md +12 -0
- package/README.md +10 -6
- package/SECURITY.md +13 -0
- package/harness/packages/cli/src/cli.js +3 -0
- package/harness/packages/cli/src/commands/config.js +141 -0
- package/harness/packages/core/src/browser/index.js +56 -2
- package/harness/packages/core/src/config/portable.js +138 -0
- package/harness/packages/core/src/hooks/index.js +9 -5
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/net/hosts.js +111 -0
- package/harness/packages/core/src/plugins/index.js +6 -4
- package/harness/packages/core/src/tools/executors.js +42 -36
- package/harness/packages/core/test/hooks.test.js +6 -3
- package/harness/packages/core/test/hosts.test.js +50 -0
- package/harness/packages/core/test/plugins.test.js +5 -3
- package/harness/packages/core/test/portable.test.js +150 -0
- package/harness/packages/core/test/tools.test.js +54 -1
- package/harness/packages/server/public/app.js +33 -8
- package/harness/packages/server/public/index.html +1 -1
- package/harness/packages/server/public/styles.css +4 -0
- package/harness/packages/server/src/lib/procs.js +30 -3
- package/harness/packages/server/src/routes/procs.js +2 -6
- package/harness/packages/server/src/server.js +9 -2
- package/harness/packages/server/test/procs-manager.test.js +43 -0
- package/harness/packages/server/test/server.test.js +8 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
1895
|
+
function addCompactCard(container, { text, label, running = false } = {}) {
|
|
1896
1896
|
if (!container) return null;
|
|
1897
|
-
const card = el('div',
|
|
1898
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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:
|
|
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 (!
|
|
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 (!
|
|
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
|
|
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 (
|
|
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, {
|