@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
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
|
|
|
5
5
|
El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
|
|
6
6
|
y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
|
|
7
7
|
|
|
8
|
+
## [0.21.2] - 2026-10-20
|
|
9
|
+
|
|
10
|
+
### Cambios
|
|
11
|
+
|
|
12
|
+
- fix: cierra los P0 de la auditoria y pule la compactacion
|
|
13
|
+
|
|
14
|
+
## [0.21.1] - 2026-10-16
|
|
15
|
+
|
|
16
|
+
### Cambios
|
|
17
|
+
|
|
18
|
+
- feat(config): export/import de la configuracion de la casa
|
|
19
|
+
|
|
8
20
|
## [0.21.0] - 2026-10-14
|
|
9
21
|
|
|
10
22
|
### Cambios
|
package/README.md
CHANGED
|
@@ -29,7 +29,8 @@ con TUI, consola web, tools locales, MCP y evaluacion de modelos.
|
|
|
29
29
|
- **UI interactiva**: el agente pide un **formulario** (`ask_user`), ofrece
|
|
30
30
|
**botones** (`offer_choices`) o muestra un **checklist** (`show_plan`); la
|
|
31
31
|
consola los renderiza.
|
|
32
|
-
- **Plataforma**: hooks y plugins locales
|
|
32
|
+
- **Plataforma**: hooks y plugins locales declarados en `tamnora.json`
|
|
33
|
+
(`.tamnora/hooks`, `.tamnora/plugins`; nunca se cargan por presencia).
|
|
33
34
|
|
|
34
35
|
## Instalacion
|
|
35
36
|
|
|
@@ -186,11 +187,14 @@ Los tres se renderizan en vivo y se reconstruyen al recargar la consola.
|
|
|
186
187
|
|
|
187
188
|
### Hooks y plugins
|
|
188
189
|
|
|
189
|
-
- **Hooks** (
|
|
190
|
-
(`run.start/end`, `tool.before/after`,
|
|
191
|
-
Corren aislados y con los datos
|
|
192
|
-
|
|
193
|
-
|
|
190
|
+
- **Hooks** (`hooks[]` en `tamnora.json`, p. ej. `[".tamnora/hooks/*.mjs"]`):
|
|
191
|
+
observan el ciclo de vida (`run.start/end`, `tool.before/after`,
|
|
192
|
+
`session.compacting/compacted`, `advice`). Corren aislados y con los datos
|
|
193
|
+
redactados. Es **opt-in explícito**: la presencia de archivos en el proyecto no
|
|
194
|
+
ejecuta nada.
|
|
195
|
+
- **Plugins** (`plugins[]`, p. ej. `[".tamnora/plugins/*.mjs"]`): agregan **tools
|
|
196
|
+
propias** al agente (expuestas como `plugin__<plugin>__<tool>`, con permisos).
|
|
197
|
+
Igual que los hooks, solo se cargan desde la config.
|
|
194
198
|
|
|
195
199
|
### Preview
|
|
196
200
|
|
package/SECURITY.md
CHANGED
|
@@ -35,6 +35,19 @@ asi que se lo debe tratar como codigo con acceso a tu usuario.
|
|
|
35
35
|
servidores de confianza. Un tool tipo `get-env` no puede leer tus secretos del
|
|
36
36
|
shell, y los resultados MCP se **redactan** (`redactSecrets`) por si devuelven un
|
|
37
37
|
token. No pases credenciales en los argumentos de las tools: van por `env`.
|
|
38
|
+
- **Hooks y plugins**: son **opt-in explicito**. Solo se ejecutan los modulos
|
|
39
|
+
`.mjs` cuyos patrones declares en `hooks[]`/`plugins[]` de `tamnora.json`; la
|
|
40
|
+
presencia de `.tamnora/hooks` o `.tamnora/plugins` en un proyecto **no** ejecuta
|
|
41
|
+
nada. Aun asi, un hook/plugin corre con tus privilegios: revisalo antes de
|
|
42
|
+
declararlo.
|
|
43
|
+
- **Salidas HTTP**: `fetch_url`, `screenshot` y `console_errors` bloquean loopback,
|
|
44
|
+
redes privadas, link-local, CGNAT y rangos IPv6 internos (incluido
|
|
45
|
+
IPv4-mapped), y **revalidan el host en cada redirect**. `preview_url` es la
|
|
46
|
+
excepcion a proposito: abre URLs locales en el panel de vista previa de la
|
|
47
|
+
consola.
|
|
48
|
+
- **Procesos de dev**: `POST /api/procs/start` solo acepta conexiones locales,
|
|
49
|
+
pasa por la allowlist `processes.allow` y valida los argumentos; en Windows no
|
|
50
|
+
se permiten metacaracteres del shell.
|
|
38
51
|
- **Tools locales y `bash`**: `bash` corre en tu maquina como tu usuario, asi que
|
|
39
52
|
hay un piso de seguridad que **no se puede saltear** ni con `bash: "allow"` ni
|
|
40
53
|
con `--yes`: los comandos catastroficos (`rm -rf /`/`~`, fork bomb, `mkfs`,
|
|
@@ -10,6 +10,7 @@ import { bridgeCommand } from './commands/bridge.js';
|
|
|
10
10
|
import { updateCommand } from './commands/update.js';
|
|
11
11
|
import { autostartCommand } from './commands/autostart.js';
|
|
12
12
|
import { doctorCommand } from './commands/doctor.js';
|
|
13
|
+
import { configCommand } from './commands/config.js';
|
|
13
14
|
import { tuiCommand } from './commands/tui.js';
|
|
14
15
|
import { ensureUtf8Console } from './lib/util.js';
|
|
15
16
|
import { readPackageVersion } from './lib/core.js';
|
|
@@ -29,6 +30,7 @@ const COMMANDS = {
|
|
|
29
30
|
update: updateCommand,
|
|
30
31
|
autostart: autostartCommand,
|
|
31
32
|
doctor: doctorCommand,
|
|
33
|
+
config: configCommand,
|
|
32
34
|
};
|
|
33
35
|
|
|
34
36
|
function readVersion() {
|
|
@@ -76,6 +78,7 @@ Uso:
|
|
|
76
78
|
tamnora autostart install|remove Arranque automatico del server
|
|
77
79
|
tamnora serve Consola web local (primer plano)
|
|
78
80
|
tamnora doctor Verificar Node, casa, puerto y base
|
|
81
|
+
tamnora config export|import Exportar/importar la configuracion (proveedores + ajustes)
|
|
79
82
|
tamnora update Buscar/instalar una version nueva
|
|
80
83
|
tamnora init Crear tamnora.json
|
|
81
84
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
appConfigPath,
|
|
5
|
+
databasePath,
|
|
6
|
+
exportConfig,
|
|
7
|
+
homeDir,
|
|
8
|
+
importConfig,
|
|
9
|
+
Store,
|
|
10
|
+
} from '../../../core/src/index.js';
|
|
11
|
+
import { parse } from '../lib/util.js';
|
|
12
|
+
import { applyHomeOverride, stopRunningServer } from '../lib/daemon.js';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_FILE = 'tamnora-config.json';
|
|
15
|
+
|
|
16
|
+
const OPTIONS = {
|
|
17
|
+
home: { type: 'string' },
|
|
18
|
+
out: { type: 'string', short: 'o' },
|
|
19
|
+
'with-secrets': { type: 'boolean' },
|
|
20
|
+
stdout: { type: 'boolean' },
|
|
21
|
+
'dry-run': { type: 'boolean' },
|
|
22
|
+
help: { type: 'boolean', short: 'h' },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export async function configCommand(argv) {
|
|
26
|
+
const { values, positionals } = parse(argv, OPTIONS);
|
|
27
|
+
if (values.help) {
|
|
28
|
+
printHelp();
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
applyHomeOverride(values);
|
|
32
|
+
|
|
33
|
+
const sub = (positionals[0] || 'path').toLowerCase();
|
|
34
|
+
const target = positionals[1] || values.out;
|
|
35
|
+
|
|
36
|
+
if (sub === 'export') return doExport(values, target);
|
|
37
|
+
if (sub === 'import') return doImport(values, target);
|
|
38
|
+
if (sub === 'path') {
|
|
39
|
+
printPaths();
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
printHelp();
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function withStore(fn) {
|
|
47
|
+
const store = new Store(databasePath());
|
|
48
|
+
try {
|
|
49
|
+
return fn(store);
|
|
50
|
+
} finally {
|
|
51
|
+
store.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function doExport(values, target) {
|
|
56
|
+
const data = withStore((store) => exportConfig({ store, withSecrets: Boolean(values['with-secrets']) }));
|
|
57
|
+
const json = `${JSON.stringify(data, null, 2)}\n`;
|
|
58
|
+
if (values.stdout || target === '-') {
|
|
59
|
+
process.stdout.write(json);
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
const file = path.resolve(target || DEFAULT_FILE);
|
|
63
|
+
writeFileSync(file, json);
|
|
64
|
+
console.log(`Configuracion exportada a ${file}`);
|
|
65
|
+
console.log(` proveedores: ${data.providers.length} · ajustes: ${Object.keys(data.settings).length}`);
|
|
66
|
+
console.log(` secretos: ${data.secrets ? 'incluidos' : 'enmascarados (usa --with-secrets para incluirlos)'}`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function doImport(values, target) {
|
|
71
|
+
if (!target) {
|
|
72
|
+
console.error('Falta el archivo: tamnora config import <archivo.json>');
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
const file = path.resolve(target);
|
|
76
|
+
let data;
|
|
77
|
+
try {
|
|
78
|
+
data = JSON.parse(readFileSync(file, 'utf8'));
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error(`No pude leer ${file}: ${error.message}`);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const dryRun = Boolean(values['dry-run']);
|
|
85
|
+
if (!dryRun && stopRunningServer()) {
|
|
86
|
+
console.log('Habia un server corriendo; lo detuve para aplicar los cambios.');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let summary;
|
|
90
|
+
try {
|
|
91
|
+
summary = withStore((store) => importConfig(store, data, { dryRun }));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error(error.message);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (dryRun) {
|
|
98
|
+
console.log('Simulacion (--dry-run): no se cambio nada.');
|
|
99
|
+
printSummary(summary);
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
printSummary(summary);
|
|
103
|
+
console.log('Volve a arrancar el server: tamnora server --start');
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function printSummary(summary) {
|
|
108
|
+
console.log(`Importado: ${summary.providers} proveedor(es), ${summary.settings} ajuste(s), app.json actualizado.`);
|
|
109
|
+
if (summary.missingKeys.length) {
|
|
110
|
+
console.log(` Ojo: ${summary.missingKeys.join(', ')} quedaron sin API key (venian enmascaradas); cargalas en la consola.`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function printPaths() {
|
|
115
|
+
console.log(`Casa de Tamnora : ${homeDir()}`);
|
|
116
|
+
console.log(` config app : ${appConfigPath()}`);
|
|
117
|
+
console.log(` base de datos : ${databasePath()}`);
|
|
118
|
+
console.log(' por proyecto : <cwd>/tamnora.json y <cwd>/.tamnora/');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function printHelp() {
|
|
122
|
+
console.log(`Uso: tamnora config <export|import|path> [archivo] [opciones]
|
|
123
|
+
|
|
124
|
+
Exporta o importa la configuracion de la casa (app.json + proveedores + ajustes).
|
|
125
|
+
No incluye sesiones, historial, MCP servers ni tareas programadas.
|
|
126
|
+
|
|
127
|
+
export [archivo] Vuelca la configuracion a JSON (default: ${DEFAULT_FILE})
|
|
128
|
+
import <archivo> Aplica el JSON sobre esta instalacion
|
|
129
|
+
path Muestra donde vive la configuracion
|
|
130
|
+
|
|
131
|
+
Opciones:
|
|
132
|
+
--with-secrets Incluye API keys y secretos reales (default: enmascarados)
|
|
133
|
+
-o, --out <archivo> Archivo de salida para export
|
|
134
|
+
--stdout Imprime el JSON por stdout (export)
|
|
135
|
+
--dry-run Muestra que haria el import sin escribir nada
|
|
136
|
+
--home <path> Casa de Tamnora (default: TAMNORA_HOME o datos del sistema)
|
|
137
|
+
|
|
138
|
+
Ejemplos:
|
|
139
|
+
tamnora config export tamnora-config.json --with-secrets
|
|
140
|
+
tamnora config import tamnora-config.json`);
|
|
141
|
+
}
|
|
@@ -8,6 +8,7 @@ import { spawn } from 'node:child_process';
|
|
|
8
8
|
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
9
9
|
import { tmpdir } from 'node:os';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import { isBlockedHost, normalizeHost } from '../net/hosts.js';
|
|
11
12
|
|
|
12
13
|
const LAUNCH_TIMEOUT_MS = 15000;
|
|
13
14
|
|
|
@@ -52,6 +53,31 @@ function delay(ms) {
|
|
|
52
53
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
function assertAllowedUrl(raw) {
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = new URL(String(raw));
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error(`URL inválida: ${raw}`);
|
|
62
|
+
}
|
|
63
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
64
|
+
throw new Error(`Solo se admite http(s): ${raw}`);
|
|
65
|
+
}
|
|
66
|
+
if (isBlockedHost(parsed.hostname)) {
|
|
67
|
+
throw new Error(`host bloqueado (${normalizeHost(parsed.hostname)}).`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isBlockedNavigation(raw) {
|
|
72
|
+
try {
|
|
73
|
+
const parsed = new URL(String(raw));
|
|
74
|
+
if (!parsed.hostname) return parsed.protocol === 'file:';
|
|
75
|
+
return isBlockedHost(parsed.hostname);
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
55
81
|
function launchBrowser(bin) {
|
|
56
82
|
return new Promise((resolve, reject) => {
|
|
57
83
|
const profile = mkdtempSync(path.join(tmpdir(), 'tamnora-browser-'));
|
|
@@ -209,8 +235,35 @@ async function openPage(wsUrl, url, { timeoutMs = 20000, collectMessages = true
|
|
|
209
235
|
}
|
|
210
236
|
});
|
|
211
237
|
});
|
|
212
|
-
|
|
213
|
-
|
|
238
|
+
let blockedUrl = '';
|
|
239
|
+
const offNav = cdp.on((message) => {
|
|
240
|
+
if (message.method !== 'Page.frameNavigated' || message.sessionId !== sessionId) return;
|
|
241
|
+
const frame = message.params?.frame;
|
|
242
|
+
if (!frame || frame.parentId || !frame.url || frame.url === 'about:blank') return;
|
|
243
|
+
if (isBlockedNavigation(frame.url)) blockedUrl = frame.url;
|
|
244
|
+
});
|
|
245
|
+
try {
|
|
246
|
+
await cdp.send('Page.navigate', { url }, sessionId);
|
|
247
|
+
await Promise.race([loaded, delay(timeoutMs)]);
|
|
248
|
+
} finally {
|
|
249
|
+
offNav();
|
|
250
|
+
}
|
|
251
|
+
if (!blockedUrl) {
|
|
252
|
+
try {
|
|
253
|
+
const { result } = await cdp.send(
|
|
254
|
+
'Runtime.evaluate',
|
|
255
|
+
{ expression: 'location.href', returnByValue: true },
|
|
256
|
+
sessionId
|
|
257
|
+
);
|
|
258
|
+
const finalUrl = String(result?.value ?? '');
|
|
259
|
+
if (finalUrl && isBlockedNavigation(finalUrl)) blockedUrl = finalUrl;
|
|
260
|
+
} catch {
|
|
261
|
+
/* sin runtime disponible: nos quedamos con los eventos de navegacion */
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (blockedUrl) {
|
|
265
|
+
throw new Error(`host bloqueado (${normalizeHost(new URL(blockedUrl).hostname)}).`);
|
|
266
|
+
}
|
|
214
267
|
|
|
215
268
|
return { cdp, sessionId, targetId, messages };
|
|
216
269
|
}
|
|
@@ -234,6 +287,7 @@ function killBrowser(child) {
|
|
|
234
287
|
}
|
|
235
288
|
|
|
236
289
|
async function withBrowser(url, options, run) {
|
|
290
|
+
assertAllowedUrl(url);
|
|
237
291
|
const bin = findBrowserPath();
|
|
238
292
|
if (!bin) {
|
|
239
293
|
throw new Error('no encontré Chrome/Edge/Chromium; definí TAMNORA_BROWSER con la ruta al ejecutable');
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { readApp, writeApp } from './app.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Export/import portable de la configuracion de la casa (app.json + tablas
|
|
5
|
+
* `providers` y `settings` de la base global). Los secretos (API keys, hash de
|
|
6
|
+
* usuario, token de Telegram, `authSecret`) se enmascaran salvo `withSecrets`;
|
|
7
|
+
* al importar, los valores enmascarados se saltan y se conserva lo existente.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const CONFIG_KIND = 'tamnora-config';
|
|
11
|
+
export const CONFIG_VERSION = 1;
|
|
12
|
+
export const MASKED = '<<tamnora:secreto>>';
|
|
13
|
+
|
|
14
|
+
const LINKAGE_PREFIXES = ['telegram:', 'scheduleSession:'];
|
|
15
|
+
|
|
16
|
+
function isSecretValue(value) {
|
|
17
|
+
return typeof value === 'string' && value !== '' && !value.startsWith('env:');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isLinkageKey(key) {
|
|
21
|
+
return LINKAGE_PREFIXES.some((prefix) => key.startsWith(prefix));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function maskApp(app) {
|
|
25
|
+
const out = structuredClone(app);
|
|
26
|
+
if (isSecretValue(out.authSecret)) out.authSecret = MASKED;
|
|
27
|
+
if (out.user) out.user = MASKED;
|
|
28
|
+
if (out.telegram && isSecretValue(out.telegram.token)) out.telegram.token = MASKED;
|
|
29
|
+
if (out.tunnel && isSecretValue(out.tunnel.token)) out.tunnel.token = MASKED;
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function providerToPortable(provider, withSecrets) {
|
|
34
|
+
const apiKey = withSecrets || !isSecretValue(provider.apiKey) ? provider.apiKey ?? '' : MASKED;
|
|
35
|
+
return {
|
|
36
|
+
id: provider.id,
|
|
37
|
+
label: provider.label,
|
|
38
|
+
baseUrl: provider.baseUrl ?? '',
|
|
39
|
+
apiKey,
|
|
40
|
+
models: provider.models ?? [],
|
|
41
|
+
requiresKey: provider.requiresKey,
|
|
42
|
+
custom: Boolean(provider.custom),
|
|
43
|
+
enabled: provider.enabled !== false,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function exportableSettings(all) {
|
|
48
|
+
const out = {};
|
|
49
|
+
for (const [key, value] of Object.entries(all)) {
|
|
50
|
+
if (isLinkageKey(key)) continue;
|
|
51
|
+
out[key] = value;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizePayload(data) {
|
|
57
|
+
if (!data || typeof data !== 'object') {
|
|
58
|
+
throw new Error('El archivo no es un objeto JSON valido.');
|
|
59
|
+
}
|
|
60
|
+
if (data.kind !== CONFIG_KIND) {
|
|
61
|
+
throw new Error(`No parece un export de Tamnora (kind: ${data.kind ?? 'desconocido'}).`);
|
|
62
|
+
}
|
|
63
|
+
if (Number(data.version) > CONFIG_VERSION) {
|
|
64
|
+
throw new Error(`Version de export no soportada: ${data.version} (max ${CONFIG_VERSION}).`);
|
|
65
|
+
}
|
|
66
|
+
return data;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function mergeMaskAware(target, source) {
|
|
70
|
+
for (const [key, value] of Object.entries(source)) {
|
|
71
|
+
if (value === MASKED) continue;
|
|
72
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
73
|
+
const base = target[key] && typeof target[key] === 'object' && !Array.isArray(target[key]) ? target[key] : {};
|
|
74
|
+
target[key] = mergeMaskAware({ ...base }, value);
|
|
75
|
+
} else {
|
|
76
|
+
target[key] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return target;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Arma el payload portable a partir del store y `app.json`. */
|
|
83
|
+
export function exportConfig({ store, withSecrets = false, now } = {}) {
|
|
84
|
+
const app = readApp();
|
|
85
|
+
return {
|
|
86
|
+
kind: CONFIG_KIND,
|
|
87
|
+
version: CONFIG_VERSION,
|
|
88
|
+
exportedAt: new Date(now ?? Date.now()).toISOString(),
|
|
89
|
+
secrets: withSecrets,
|
|
90
|
+
app: withSecrets ? app : maskApp(app),
|
|
91
|
+
providers: store.listProviders().map((provider) => providerToPortable(provider, withSecrets)),
|
|
92
|
+
settings: exportableSettings(store.allSettings()),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Aplica un payload portable sobre el store y `app.json`. Con `dryRun` no
|
|
98
|
+
* escribe nada. Devuelve un resumen (proveedores, ajustes y claves faltantes).
|
|
99
|
+
*/
|
|
100
|
+
export function importConfig(store, data, { dryRun = false } = {}) {
|
|
101
|
+
const payload = normalizePayload(data);
|
|
102
|
+
const summary = { app: true, providers: 0, settings: 0, missingKeys: [] };
|
|
103
|
+
|
|
104
|
+
const app = readApp();
|
|
105
|
+
mergeMaskAware(app, payload.app ?? {});
|
|
106
|
+
|
|
107
|
+
for (const provider of payload.providers ?? []) {
|
|
108
|
+
if (!provider?.id) continue;
|
|
109
|
+
const existing = store.getProvider(provider.id);
|
|
110
|
+
let apiKey = provider.apiKey ?? '';
|
|
111
|
+
if (apiKey === MASKED) {
|
|
112
|
+
apiKey = existing?.apiKey ?? '';
|
|
113
|
+
if (!apiKey) summary.missingKeys.push(provider.id);
|
|
114
|
+
}
|
|
115
|
+
if (!dryRun) {
|
|
116
|
+
store.saveProvider({
|
|
117
|
+
id: provider.id,
|
|
118
|
+
label: provider.label,
|
|
119
|
+
baseUrl: provider.baseUrl,
|
|
120
|
+
apiKey,
|
|
121
|
+
models: provider.models,
|
|
122
|
+
requiresKey: provider.requiresKey,
|
|
123
|
+
custom: provider.custom,
|
|
124
|
+
enabled: provider.enabled !== false,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
summary.providers += 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const [key, value] of Object.entries(payload.settings ?? {})) {
|
|
131
|
+
if (isLinkageKey(key)) continue;
|
|
132
|
+
if (!dryRun) store.setSetting(key, value);
|
|
133
|
+
summary.settings += 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!dryRun) writeApp(app);
|
|
137
|
+
return summary;
|
|
138
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Hooks locales: modulos `.mjs` que observan el ciclo de vida del agente
|
|
2
|
-
// (cargados con `import()` dinamico
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// (cargados con `import()` dinamico). Es **opt-in explicito**: solo se cargan
|
|
3
|
+
// los patrones declarados en `config.hooks[]`; la mera presencia de archivos en
|
|
4
|
+
// el cwd no ejecuta nada. Un hook exporta por default un objeto
|
|
5
|
+
// `{ name?, events: { '<tipo>': fn } }` o una funcion (se llama para todos los
|
|
6
|
+
// eventos).
|
|
5
7
|
|
|
6
8
|
import path from 'node:path';
|
|
7
9
|
import { pathToFileURL } from 'node:url';
|
|
@@ -23,11 +25,13 @@ export function normalizeHookModule(mod, id = '') {
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
|
-
* Carga los hooks de `patterns`
|
|
28
|
+
* Carga los hooks de los `patterns` explicitos (relativos a `cwd`). Sin
|
|
29
|
+
* patrones no se importa nada: abrir un proyecto no ejecuta codigo por si solo.
|
|
27
30
|
* Un modulo roto se ignora.
|
|
28
31
|
*/
|
|
29
32
|
export async function loadHooks(cwd, patterns = []) {
|
|
30
|
-
const list = [...new Set(
|
|
33
|
+
const list = [...new Set(patterns ?? [])];
|
|
34
|
+
if (!list.length) return [];
|
|
31
35
|
const hooks = [];
|
|
32
36
|
for (const rel of await globFiles(cwd, list)) {
|
|
33
37
|
try {
|
|
@@ -70,6 +70,7 @@ export {
|
|
|
70
70
|
migrateLegacyDb,
|
|
71
71
|
} from './config/home.js';
|
|
72
72
|
export { DEFAULT_APP, readApp, writeApp, ensureApp, randomToken } from './config/app.js';
|
|
73
|
+
export { exportConfig, importConfig, CONFIG_KIND, CONFIG_VERSION, MASKED } from './config/portable.js';
|
|
73
74
|
export { hashPassword, verifyPassword, userValidName, makeUser } from './config/identity.js';
|
|
74
75
|
export { composeProviderSettings } from './config/providers.js';
|
|
75
76
|
export {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Guarda de hosts para salidas del harness: bloquea loopback, redes privadas,
|
|
2
|
+
// link-local, CGNAT y rangos IPv6 internos (incluido IPv4-mapped). La usan
|
|
3
|
+
// `fetch_url` (tambien en cada redirect) y el navegador headless.
|
|
4
|
+
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
|
|
7
|
+
const BLOCKED_SUFFIXES = ['.localhost', '.local', '.internal', '.intranet', '.lan', '.home.arpa'];
|
|
8
|
+
|
|
9
|
+
export function normalizeHost(hostname) {
|
|
10
|
+
return String(hostname ?? '')
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/^\[|\]$/g, '')
|
|
13
|
+
.replace(/\.$/, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isBlockedV4(octets) {
|
|
17
|
+
const [a, b] = octets;
|
|
18
|
+
if (!Number.isInteger(a) || !Number.isInteger(b)) return true;
|
|
19
|
+
if (a === 0 || a === 10 || a === 127) return true;
|
|
20
|
+
if (a === 169 && b === 254) return true;
|
|
21
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
22
|
+
if (a === 192 && b === 168) return true;
|
|
23
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
24
|
+
if (a >= 224) return true;
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseV4(value) {
|
|
29
|
+
const parts = String(value).split('.').map(Number);
|
|
30
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
|
|
31
|
+
return parts;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseV6(value) {
|
|
35
|
+
let text = String(value).toLowerCase().split('%')[0];
|
|
36
|
+
const embedded = /(\d{1,3}(?:\.\d{1,3}){3})$/.exec(text);
|
|
37
|
+
if (embedded) {
|
|
38
|
+
const v4 = parseV4(embedded[1]);
|
|
39
|
+
if (!v4) return null;
|
|
40
|
+
const hex = `${((v4[0] << 8) | v4[1]).toString(16)}:${((v4[2] << 8) | v4[3]).toString(16)}`;
|
|
41
|
+
text = text.slice(0, text.length - embedded[1].length) + hex;
|
|
42
|
+
}
|
|
43
|
+
const halves = text.split('::');
|
|
44
|
+
if (halves.length > 2) return null;
|
|
45
|
+
const left = halves[0] ? halves[0].split(':') : [];
|
|
46
|
+
const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
|
|
47
|
+
const missing = 8 - left.length - right.length;
|
|
48
|
+
if (halves.length === 1 ? missing !== 0 : missing < 0) return null;
|
|
49
|
+
const groups = [...left, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...right];
|
|
50
|
+
if (groups.length !== 8) return null;
|
|
51
|
+
const parsed = groups.map((group) => (/^[0-9a-f]{1,4}$/.test(group) ? parseInt(group, 16) : NaN));
|
|
52
|
+
return parsed.some(Number.isNaN) ? null : parsed;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isBlockedV6(groups) {
|
|
56
|
+
if (!groups) return true;
|
|
57
|
+
const [g0, g1, g2, g3, g4, g5, g6, g7] = groups;
|
|
58
|
+
if (groups.every((g) => g === 0)) return true; // ::
|
|
59
|
+
if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0 && g6 === 0 && g7 === 1) {
|
|
60
|
+
return true; // ::1
|
|
61
|
+
}
|
|
62
|
+
if ((g0 & 0xfe00) === 0xfc00) return true; // fc00::/7
|
|
63
|
+
if ((g0 & 0xffc0) === 0xfe80) return true; // fe80::/10
|
|
64
|
+
if ((g0 & 0xff00) === 0xff00) return true; // multicast
|
|
65
|
+
if (g0 === 0x64 && g1 === 0xff9b && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0) {
|
|
66
|
+
return isBlockedV4([g6 >> 8, g6 & 0xff, g7 >> 8, g7 & 0xff]); // 64:ff9b::/96
|
|
67
|
+
}
|
|
68
|
+
if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && (g5 === 0 || g5 === 0xffff)) {
|
|
69
|
+
return isBlockedV4([g6 >> 8, g6 & 0xff, g7 >> 8, g7 & 0xff]); // IPv4-compatible / mapped
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `true` si la IP (v4 o v6) es loopback, privada, link-local o reservada. */
|
|
75
|
+
export function isBlockedAddress(address) {
|
|
76
|
+
const value = normalizeHost(address);
|
|
77
|
+
const family = net.isIP(value);
|
|
78
|
+
if (family === 4) return isBlockedV4(parseV4(value) ?? []);
|
|
79
|
+
if (family === 6) return isBlockedV6(parseV6(value));
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `true` si el hostname (o literal IP) no debe usarse para salir del harness. */
|
|
84
|
+
export function isBlockedHost(hostname) {
|
|
85
|
+
const host = normalizeHost(hostname);
|
|
86
|
+
if (!host) return true;
|
|
87
|
+
if (net.isIP(host)) return isBlockedAddress(host);
|
|
88
|
+
if (host === 'localhost' || host === 'localhost.localdomain') return true;
|
|
89
|
+
if (BLOCKED_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
|
|
90
|
+
if (!host.includes('.')) return true; // una sola etiqueta suele ser un host interno
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Valida una URL http(s) para una salida del harness. */
|
|
95
|
+
export function checkHttpUrl(value) {
|
|
96
|
+
const raw = String(value ?? '').trim();
|
|
97
|
+
if (!raw) return { error: 'falta "url".' };
|
|
98
|
+
let parsed;
|
|
99
|
+
try {
|
|
100
|
+
parsed = new URL(raw);
|
|
101
|
+
} catch {
|
|
102
|
+
return { error: `URL inválida: "${raw}".` };
|
|
103
|
+
}
|
|
104
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
105
|
+
return { error: 'Solo se admite http(s).' };
|
|
106
|
+
}
|
|
107
|
+
if (isBlockedHost(parsed.hostname)) {
|
|
108
|
+
return { error: `host bloqueado (${normalizeHost(parsed.hostname)}).` };
|
|
109
|
+
}
|
|
110
|
+
return { url: parsed.toString(), parsed };
|
|
111
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Plugins locales: modulos `.mjs` que exportan `{ name?, tools: [...] }` para
|
|
2
2
|
// agregar herramientas propias al agente. Se cargan con `import()` dinamico,
|
|
3
|
-
// opt-in
|
|
4
|
-
//
|
|
3
|
+
// **opt-in explicito**: solo los patrones declarados en `config.plugins[]`; la
|
|
4
|
+
// presencia de archivos en el cwd no ejecuta nada. Cada tool:
|
|
5
|
+
// `{ name, description, parameters?, required?, handler(args, ctx) }`.
|
|
5
6
|
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
import { pathToFileURL } from 'node:url';
|
|
@@ -19,9 +20,10 @@ export function normalizePlugin(mod, id = '') {
|
|
|
19
20
|
return { id: value.name || id, tools: valid };
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
/** Carga los plugins de `patterns`
|
|
23
|
+
/** Carga los plugins de los `patterns` explicitos (relativos a `cwd`). */
|
|
23
24
|
export async function loadPlugins(cwd, patterns = []) {
|
|
24
|
-
const list = [...new Set(
|
|
25
|
+
const list = [...new Set(patterns ?? [])];
|
|
26
|
+
if (!list.length) return [];
|
|
25
27
|
const plugins = [];
|
|
26
28
|
for (const rel of await globFiles(cwd, list)) {
|
|
27
29
|
try {
|