@danieltmn/tamnora 0.21.2 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/harness/packages/core/src/agent/runAgent.js +9 -5
  3. package/harness/packages/core/src/index.js +1 -0
  4. package/harness/packages/core/src/mcp/stdio.js +15 -0
  5. package/harness/packages/core/src/net/hosts.js +70 -0
  6. package/harness/packages/core/src/redact.js +2 -0
  7. package/harness/packages/core/src/tools/executors.js +19 -24
  8. package/harness/packages/core/src/tools/index.js +1 -1
  9. package/harness/packages/core/src/tools/paths.js +35 -5
  10. package/harness/packages/core/src/tools/permissions.js +41 -2
  11. package/harness/packages/core/src/tools/registry.js +8 -3
  12. package/harness/packages/core/src/tunnel.js +15 -5
  13. package/harness/packages/core/test/tools.test.js +44 -1
  14. package/harness/packages/server/public/app.js +343 -6754
  15. package/harness/packages/server/public/chat.js +1893 -0
  16. package/harness/packages/server/public/chrome.js +812 -0
  17. package/harness/packages/server/public/commands.js +403 -0
  18. package/harness/packages/server/public/core.js +209 -0
  19. package/harness/packages/server/public/explorer.js +625 -0
  20. package/harness/packages/server/public/index.html +19 -19
  21. package/harness/packages/server/public/sessions.js +274 -0
  22. package/harness/packages/server/public/settings.js +1076 -0
  23. package/harness/packages/server/public/styles.css +25 -2
  24. package/harness/packages/server/public/system.js +495 -0
  25. package/harness/packages/server/public/voice.js +1058 -0
  26. package/harness/packages/server/src/auth.js +12 -10
  27. package/harness/packages/server/src/lib/agentRun.js +6 -1
  28. package/harness/packages/server/src/lib/channels/telegram.js +1 -0
  29. package/harness/packages/server/src/lib/presence.js +12 -1
  30. package/harness/packages/server/src/lib/scheduler.js +11 -2
  31. package/harness/packages/server/src/lib/tasks.js +20 -3
  32. package/harness/packages/server/src/lib/workspace.js +90 -2
  33. package/harness/packages/server/src/routes/auth.js +8 -4
  34. package/harness/packages/server/src/routes/chat.js +15 -4
  35. package/harness/packages/server/src/routes/preview.js +10 -2
  36. package/harness/packages/server/src/routes/providers.js +12 -9
  37. package/harness/packages/server/src/server.js +19 -0
  38. package/harness/packages/server/test/chat.test.js +586 -0
  39. package/harness/packages/server/test/helpers.js +107 -0
  40. package/harness/packages/server/test/ops.test.js +511 -0
  41. package/harness/packages/server/test/runs.test.js +816 -0
  42. package/harness/packages/server/test/security.test.js +112 -9
  43. package/harness/packages/server/test/sessions.test.js +437 -0
  44. package/harness/packages/server/test/settings.test.js +465 -0
  45. package/harness/tamnora.example.json +1 -1
  46. package/package.json +1 -1
  47. package/harness/packages/server/test/server.test.js +0 -2885
@@ -125,17 +125,19 @@ export function requireCsrf(app, req, res) {
125
125
  // ---------------------------------------------------------------------------
126
126
  // Bloqueo por intentos fallidos (en memoria: el server es un proceso).
127
127
  // ---------------------------------------------------------------------------
128
- // La clave usa remoteAddress: X-Forwarded-For lo controla el cliente y con un
129
- // header falsificado se rotaba de IP y se esquivaba el bloqueo.
128
+ // La clave es solo la IP: con `ip|usuario` cada intento con un nombre distinto
129
+ // tenia su propio contador y el bloqueo se esquivaba rotando el username. Y el
130
+ // usuario se ignora a proposito: X-Forwarded-For lo controla el cliente y con
131
+ // un header falsificado se rotaba de IP y se esquivaba el bloqueo.
130
132
  const MAX_ATTEMPTS = 5;
131
133
  const WINDOW_MS = 15 * 60 * 1000;
132
134
  const LOCK_MS = 15 * 60 * 1000;
133
135
  const MAX_KEYS = 256;
134
136
  const attempts = new Map();
135
137
 
136
- function clientKey(req, user) {
138
+ function clientKey(req) {
137
139
  const ip = (req.socket && req.socket.remoteAddress) || 'desconocido';
138
- return `${ip}|${String(user || '').trim().toLowerCase()}`;
140
+ return String(ip);
139
141
  }
140
142
 
141
143
  function sweepStale(now) {
@@ -146,18 +148,18 @@ function sweepStale(now) {
146
148
  }
147
149
  }
148
150
 
149
- export function loginLockRemaining(req, user) {
151
+ export function loginLockRemaining(req) {
150
152
  const now = Date.now();
151
- const st = attempts.get(clientKey(req, user));
153
+ const st = attempts.get(clientKey(req));
152
154
  if (!st || !st.lockUntil) return 0;
153
155
  const left = st.lockUntil - now;
154
156
  return left > 0 ? Math.ceil(left / 1000) : 0;
155
157
  }
156
158
 
157
- export function loginRecordFailure(req, user) {
159
+ export function loginRecordFailure(req) {
158
160
  const now = Date.now();
159
161
  sweepStale(now);
160
- const key = clientKey(req, user);
162
+ const key = clientKey(req);
161
163
  let st = attempts.get(key);
162
164
  if (!st || (!st.lockUntil && now - st.first > WINDOW_MS)) st = { count: 0, first: now, lockUntil: 0 };
163
165
  st.count += 1;
@@ -165,6 +167,6 @@ export function loginRecordFailure(req, user) {
165
167
  attempts.set(key, st);
166
168
  }
167
169
 
168
- export function loginClear(req, user) {
169
- attempts.delete(clientKey(req, user));
170
+ export function loginClear(req) {
171
+ attempts.delete(clientKey(req));
170
172
  }
@@ -49,7 +49,12 @@ export function combineLifecycle(handlers = []) {
49
49
  for (const handler of active) {
50
50
  const out = await Promise.resolve()
51
51
  .then(() => handler(event))
52
- .catch(() => undefined);
52
+ .catch((error) => {
53
+ // El run no se corta por un handler, pero el fallo no puede ser
54
+ // invisible: sin esto un hook/regla roto desaparece sin rastro.
55
+ process.stderr.write(`tamnora: handler de ciclo de vida falló: ${error?.message ?? error}\n`);
56
+ return undefined;
57
+ });
53
58
  if (out && typeof out === 'object' && typeof out.inject === 'string' && out.inject.trim()) {
54
59
  injects.push(out.inject);
55
60
  }
@@ -520,6 +520,7 @@ export function startTelegramChannel(
520
520
  execute,
521
521
  toolContext: { cwd, sessionId },
522
522
  onLifecycle: createRulesInjector({ cwd, patterns: ctx.config.rules ?? [] }) ?? undefined,
523
+ redactExtra: ctx.secretExtra?.() ?? [],
523
524
  signal: controller.signal,
524
525
  maxRounds: ctx.effectiveMaxRounds?.() ?? ctx.config.maxRounds,
525
526
  maxTokens: ctx.effectiveMaxTokens?.() ?? 0,
@@ -6,6 +6,7 @@
6
6
  // cada entrada vence por TTL y el cliente la refresca con un latido.
7
7
 
8
8
  const DEFAULT_TTL_MS = 90000;
9
+ const MAX_IDS = 256;
9
10
 
10
11
  /** Tracker en memoria: sessionId -> último latido. */
11
12
  export function createPresence({ ttlMs = DEFAULT_TTL_MS, now = Date.now } = {}) {
@@ -13,11 +14,21 @@ export function createPresence({ ttlMs = DEFAULT_TTL_MS, now = Date.now } = {})
13
14
 
14
15
  const alive = (ts) => now() - ts <= ttlMs;
15
16
 
17
+ const prune = () => {
18
+ for (const [id, ts] of seen) if (!alive(ts)) seen.delete(id);
19
+ };
20
+
16
21
  return {
17
22
  /** Marca como abiertas (vistas recién) esas sesiones. */
18
23
  touch(ids = []) {
19
24
  const ts = now();
20
- for (const id of ids) if (id) seen.set(String(id), ts);
25
+ if (seen.size >= MAX_IDS) prune();
26
+ // Los ids vienen del body: se acotan y se limita el lote, que un cliente
27
+ // no pueda hacer crecer el Map sin techo.
28
+ for (const id of [].concat(ids).slice(0, MAX_IDS)) {
29
+ const key = String(id ?? '').slice(0, 64);
30
+ if (key) seen.set(key, ts);
31
+ }
21
32
  return this;
22
33
  },
23
34
  /** Marca como cerradas (quita la sesión sin esperar el TTL). */
@@ -41,7 +41,8 @@ async function runAgentSchedule(ctx, schedule) {
41
41
  let mcp = null;
42
42
  try {
43
43
  mcp = await ctx.getMcp();
44
- } catch {
44
+ } catch (error) {
45
+ process.stderr.write(`tamnora: MCP no disponible en la tarea programada: ${error?.message ?? error}\n`);
45
46
  mcp = null;
46
47
  }
47
48
 
@@ -72,6 +73,7 @@ async function runAgentSchedule(ctx, schedule) {
72
73
  cwd,
73
74
  providerId,
74
75
  model,
76
+ parentMode: mode,
75
77
  permissions: ctx.config.permissions,
76
78
  mcp,
77
79
  mcpMode: ctx.effectiveMcpMode(),
@@ -100,6 +102,7 @@ async function runAgentSchedule(ctx, schedule) {
100
102
  execute,
101
103
  toolContext: { cwd, sessionId },
102
104
  onLifecycle: createRulesInjector({ cwd, patterns: ctx.config.rules ?? [] }) ?? undefined,
105
+ redactExtra: ctx.secretExtra?.() ?? [],
103
106
  signal,
104
107
  maxRounds: ctx.effectiveMaxRounds?.() || MAX_TOOL_ROUNDS,
105
108
  maxTokens: ctx.effectiveMaxTokens?.() || 0,
@@ -189,7 +192,13 @@ export function createScheduler(ctx, { intervalMs = 30000, log = () => {} } = {}
189
192
 
190
193
  function start() {
191
194
  if (timer) return;
192
- timer = setInterval(() => tick().catch(() => {}), intervalMs);
195
+ timer = setInterval(
196
+ () =>
197
+ tick().catch((error) => {
198
+ process.stderr.write(`tamnora: tick del planificador falló: ${error?.message ?? error}\n`);
199
+ }),
200
+ intervalMs
201
+ );
193
202
  timer.unref?.();
194
203
  }
195
204
 
@@ -20,6 +20,20 @@ function definition(name, description, parameters, required = []) {
20
20
  };
21
21
  }
22
22
 
23
+ /**
24
+ * El modo del subagente nunca amplia el del padre: Plan solo puede delegar en
25
+ * solo-lectura y Voz hereda su restriccion de extensiones, aunque el modelo
26
+ * pida `mode: 'agent'`.
27
+ */
28
+ function clampMode(requested, parentMode) {
29
+ const parentTools = Array.isArray(parentMode?.tools) ? parentMode.tools : null;
30
+ const tools = parentTools
31
+ ? (requested.tools ?? [...parentTools]).filter((t) => parentTools.includes(t))
32
+ : (requested.tools ?? null);
33
+ const writeExtensions = parentMode?.writeExtensions ?? requested.writeExtensions;
34
+ return { id: requested.id, tools, writeExtensions };
35
+ }
36
+
23
37
  /**
24
38
  * Crea la tool `task` para una sesión/run y la función `execute`.
25
39
  * `emit(type, data, agentId)` persiste y difunde el evento (via RunManager).
@@ -37,6 +51,7 @@ export function createSubagentTool(ctx, options) {
37
51
  signal,
38
52
  exclude = [],
39
53
  emit,
54
+ parentMode = null,
40
55
  } = options;
41
56
 
42
57
  const definitions = [
@@ -62,13 +77,14 @@ export function createSubagentTool(ctx, options) {
62
77
  if (!prompt) return 'La subtarea necesita "prompt".';
63
78
 
64
79
  const agentId = ctx.store.createAgent({ sessionId, kind: 'task', title: description, model });
65
- const mode = resolveMode(args.mode ?? 'plan');
80
+ const mode = clampMode(resolveMode(args.mode ?? 'plan'), parentMode);
66
81
  emit('agent_start', { agentId, description, mode: mode.id, model }, agentId);
67
82
 
68
- const enabled =
83
+ const requested =
69
84
  Array.isArray(args.tools) && args.tools.length
70
85
  ? args.tools.filter((tool) => !exclude.includes(tool))
71
- : mode.tools ?? undefined;
86
+ : (mode.tools ?? undefined);
87
+ const enabled = mode.tools ? requested.filter((tool) => mode.tools.includes(tool)) : requested;
72
88
  const local = createTools({
73
89
  root: cwd,
74
90
  enabled,
@@ -98,6 +114,7 @@ export function createSubagentTool(ctx, options) {
98
114
  messages,
99
115
  tools: local.definitions,
100
116
  execute: local.execute,
117
+ redactExtra: ctx.secretExtra?.() ?? [],
101
118
  signal,
102
119
  maxRounds: ctx.config.agents?.maxRounds ?? 6,
103
120
  maxOutputTokens: ctx.config.agents?.maxOutputTokens ?? ctx.config.maxOutputTokens ?? 0,
@@ -1,7 +1,9 @@
1
1
  import fs from 'node:fs';
2
+ import net from 'node:net';
3
+ import os from 'node:os';
2
4
  import path from 'node:path';
3
5
  import { sendError } from './http.js';
4
- import { tunnelState } from './core.js';
6
+ import { tunnelState, realpathDeep } from './core.js';
5
7
 
6
8
  /** True for 127.x / ::1 / ::ffff:127.x addresses. */
7
9
  export function isLoopback(address) {
@@ -27,6 +29,84 @@ export function requireLocal(req, res, mensaje = 'Solo disponible en conexiones
27
29
  return false;
28
30
  }
29
31
 
32
+ // ---------------------------------------------------------------------------
33
+ // Guardas de request: DNS-rebinding (Host) y CSRF (Origin). Aplican SIEMPRE,
34
+ // con o sin autenticacion: una web maliciosa que abra el navegador de la
35
+ // victima contra 127.0.0.1 no puede ejecutar nada.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const TRUSTED_HOST_SUFFIXES = ['.ts.net', '.tunnelmole.net', '.localhost'];
39
+
40
+ /** Hostname (sin puerto, sin brackets de IPv6) del header `Host`. */
41
+ export function hostNameOf(hostHeader) {
42
+ const raw = String(hostHeader ?? '').trim().toLowerCase();
43
+ if (!raw) return '';
44
+ if (raw.startsWith('[')) {
45
+ const end = raw.indexOf(']');
46
+ return end > 0 ? raw.slice(1, end) : raw;
47
+ }
48
+ const colon = raw.lastIndexOf(':');
49
+ const maybePort = colon >= 0 ? raw.slice(colon + 1) : '';
50
+ return /^\d+$/.test(maybePort) ? raw.slice(0, colon) : raw;
51
+ }
52
+
53
+ function normalizeHost(host) {
54
+ return String(host ?? '')
55
+ .trim()
56
+ .toLowerCase()
57
+ .replace(/:80$/, '')
58
+ .replace(/:443$/, '');
59
+ }
60
+
61
+ /**
62
+ * Guarda DNS-rebinding: el `Host` debe ser un nombre que controlamos — una IP
63
+ * literal, localhost, el hostname de la maquina, un dominio de tailscale o del
64
+ * tunel, o una lista explicita (`config.server.allowedHosts`). Una pagina que
65
+ * rebindee su dominio hacia 127.0.0.1 llega con `Host: <su dominio>` y cae aca.
66
+ */
67
+ export function trustedHost(req, allowed = []) {
68
+ const host = hostNameOf(req?.headers?.host);
69
+ if (!host) return true;
70
+ if (net.isIP(host)) return true;
71
+ if (host === 'localhost') return true;
72
+ if (host === String(os.hostname() ?? '').toLowerCase()) return true;
73
+ if (TRUSTED_HOST_SUFFIXES.some((s) => host.endsWith(s))) return true;
74
+ return (allowed ?? []).some((h) => hostNameOf(h) === host);
75
+ }
76
+
77
+ /** `trustedHost` o 403. Devuelve true si la request puede seguir. */
78
+ export function requireTrustedHost(req, res, allowed = []) {
79
+ if (trustedHost(req, allowed)) return true;
80
+ sendError(res, 403, 'Host no reconocido.');
81
+ return false;
82
+ }
83
+
84
+ /**
85
+ * Guarda CSRF: si el navegador manda `Origin`, debe ser el propio server (mismo
86
+ * host:puerto). Los clientes que no son un navegador no mandan `Origin` y se
87
+ * aceptan; las webs, incluso las de "simple request" (text/plain), siempre lo
88
+ * mandan en un POST. `Origin: null` (iframe sandbox, file:) se rechaza.
89
+ */
90
+ export function sameOrigin(req) {
91
+ const origin = String(req?.headers?.origin ?? '').trim();
92
+ if (!origin) return true;
93
+ if (origin === 'null') return false;
94
+ let parsed;
95
+ try {
96
+ parsed = new URL(origin);
97
+ } catch {
98
+ return false;
99
+ }
100
+ return normalizeHost(parsed.host) === normalizeHost(req?.headers?.host);
101
+ }
102
+
103
+ /** `sameOrigin` o 403. Devuelve true si la mutacion puede seguir. */
104
+ export function requireSameOrigin(req, res) {
105
+ if (sameOrigin(req)) return true;
106
+ sendError(res, 403, 'Origen de la petición no válido.');
107
+ return false;
108
+ }
109
+
30
110
  /** Filesystem roots (drive letters on Windows, `/` elsewhere). */
31
111
  export function rootDirs() {
32
112
  if (process.platform !== 'win32') return ['/'];
@@ -71,11 +151,19 @@ export function resolveDirOr400(res, raw, fallback) {
71
151
  }
72
152
 
73
153
  /** True si `target` es `root` o vive dentro de el (sin salirse por `..`). */
74
- export function isInside(root, target) {
154
+ function insideLexical(root, target) {
75
155
  const rel = path.relative(root, target);
76
156
  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
77
157
  }
78
158
 
159
+ /**
160
+ * Como el chequeo lexico, pero sobre `fs.realpath`: un symlink dentro del
161
+ * workspace puede apuntar afuera y el lexico no lo ve.
162
+ */
163
+ export function isInside(root, target) {
164
+ return insideLexical(root, target) && insideLexical(realpathDeep(root), realpathDeep(target));
165
+ }
166
+
79
167
  /**
80
168
  * Workspace for an HTTP request: the requested `cwd` (loopback-only) or the
81
169
  * server's default. Throws on invalid input.
@@ -31,20 +31,24 @@ export function registerAuth(route, ctx) {
31
31
  const password = String(body.password ?? '');
32
32
  const user = ctx.app.user;
33
33
 
34
- const left = loginLockRemaining(req, name);
34
+ const left = loginLockRemaining(req);
35
35
  if (left > 0) {
36
36
  sendError(res, 429, `Demasiados intentos. Espera ${left}s.`);
37
37
  return;
38
38
  }
39
39
 
40
- const ok = name.toLowerCase() === String(user.name).toLowerCase() && verifyPassword(password, user.password);
40
+ // Ambas verificaciones corren SIEMPRE: con `name === ... && verify(...)`
41
+ // el cortocircuito delataba por timing si el usuario existia.
42
+ const nameOk = name.toLowerCase() === String(user.name).toLowerCase();
43
+ const passOk = verifyPassword(password, user.password);
44
+ const ok = nameOk && passOk;
41
45
  if (!ok) {
42
- loginRecordFailure(req, name);
46
+ loginRecordFailure(req);
43
47
  sendError(res, 401, 'Usuario o contrasena incorrectos');
44
48
  return;
45
49
  }
46
50
 
47
- loginClear(req, name);
51
+ loginClear(req);
48
52
  const csrf = startSession(ctx.app, req, res);
49
53
  sendJson(res, 200, { ok: true, enabled: true, user: { name: user.name }, csrf });
50
54
  });
@@ -6,7 +6,7 @@ import {
6
6
  MAX_TOOL_ROUNDS,
7
7
  } from '../lib/core.js';
8
8
  import { openSse, readJson, sendJson, sendError } from '../lib/http.js';
9
- import { workspaceForRequest, resolveExistingDir } from '../lib/workspace.js';
9
+ import { workspaceForRequest, resolveExistingDir, localOk } from '../lib/workspace.js';
10
10
  import { createSubagentTool } from '../lib/tasks.js';
11
11
  import {
12
12
  combineToolset,
@@ -197,7 +197,11 @@ export function registerChat(route, ctx) {
197
197
  // Desconectar no cancela: el run sigue y los eventos quedan persistidos.
198
198
  req.on('close', () => unsubscribe());
199
199
 
200
- const autoApprove = body.yes ?? ctx.yes;
200
+ // `yes`/`baseUrl` del cuerpo solo valen en conexiones locales: remoto,
201
+ // auto-aprobar permisos o redirigir el proveedor (con la API key) a un
202
+ // baseUrl ajeno seria una puerta abierta.
203
+ const autoApprove = Boolean(localOk(req) ? (body.yes ?? ctx.yes) : ctx.yes);
204
+ const baseUrlOverride = localOk(req) ? String(body.baseUrl || '') : '';
201
205
  const asked = new Set();
202
206
 
203
207
  ctx.runs.emit(sessionId, 'start', {
@@ -217,7 +221,12 @@ export function registerChat(route, ctx) {
217
221
  ctx.runs.emit(sessionId, 'warning', { message: `MCP: ${error?.message ?? error}` });
218
222
  }
219
223
 
220
- const enabled = Array.isArray(body.tools) && body.tools.length ? body.tools : mode.tools ?? undefined;
224
+ // `body.tools` acota pero nunca amplia: se intersecta con las tools del modo
225
+ // (un request no puede pedir `bash` en modo Plan).
226
+ let enabled = mode.tools ?? undefined;
227
+ if (Array.isArray(body.tools) && body.tools.length) {
228
+ enabled = enabled ? body.tools.filter((t) => enabled.includes(t)) : body.tools;
229
+ }
221
230
  const onAsk = autoApprove
222
231
  ? undefined
223
232
  : async (name, args) => {
@@ -261,6 +270,7 @@ export function registerChat(route, ctx) {
261
270
  cwd: sessionCwd,
262
271
  providerId,
263
272
  model,
273
+ parentMode: mode,
264
274
  permissions: ctx.config.permissions,
265
275
  mcp,
266
276
  mcpMode: ctx.effectiveMcpMode(),
@@ -280,7 +290,7 @@ export function registerChat(route, ctx) {
280
290
  });
281
291
 
282
292
  const { provider, config } = resolveProviderFor(ctx, providerId, {
283
- baseUrl: body.baseUrl || ctx.providerSettings(providerId).baseUrl,
293
+ baseUrl: baseUrlOverride || ctx.providerSettings(providerId).baseUrl,
284
294
  });
285
295
 
286
296
  const userMessage = buildUserMessage(input, images);
@@ -330,6 +340,7 @@ export function registerChat(route, ctx) {
330
340
  execute,
331
341
  toolContext: { cwd: sessionCwd, sessionId },
332
342
  onLifecycle: onLifecycle ?? undefined,
343
+ redactExtra: ctx.secretExtra?.() ?? [],
333
344
  signal: ctx.runs.signal(sessionId),
334
345
  maxRounds: Number(body.maxRounds) || ctx.effectiveMaxRounds?.() || MAX_TOOL_ROUNDS,
335
346
  maxTokens: Number(body.maxTokens) || ctx.effectiveMaxTokens?.() || 0,
@@ -49,14 +49,22 @@ export function registerPreview(route, ctx) {
49
49
  try {
50
50
  started = await startTunnel(port, provider, { preview: true, httpsPort });
51
51
  } catch (error) {
52
- if (provider !== 'tunnelmole' && preview.fallback !== false) {
52
+ // El fallback a tunnelmole es un tunel PUBLICO: solo con consentimiento
53
+ // explicito (`allowPublic` o `preview.fallback: true`). Quien pidio un
54
+ // tunel privado no puede quedar expuesto sin saberlo.
55
+ const allowPublic = body.allowPublic === true || preview.fallback === true;
56
+ if (provider !== 'tunnelmole' && allowPublic) {
53
57
  try {
54
58
  started = await startTunnel(port, 'tunnelmole');
55
59
  } catch (fallbackError) {
56
60
  return sendError(res, 502, `No se pudo abrir el túnel: ${fallbackError.message}`);
57
61
  }
58
62
  } else {
59
- return sendError(res, 502, `No se pudo abrir el túnel: ${error.message}`);
63
+ return sendError(
64
+ res,
65
+ 502,
66
+ `No se pudo abrir el túnel: ${error.message}. Si querés exponerlo con tunnelmole (público), reintentá con ese proveedor.`
67
+ );
60
68
  }
61
69
  }
62
70
  tunnel = { url: started.url, port, provider: started.provider, stop: started.stop };
@@ -1,15 +1,18 @@
1
- import { MODES, MODE_IDS, resolveMode, PROVIDER_META, testProviderConnection, readApp, writeApp } from '../lib/core.js';
1
+ import {
2
+ MODES,
3
+ MODE_IDS,
4
+ resolveMode,
5
+ PROVIDER_META,
6
+ testProviderConnection,
7
+ readApp,
8
+ writeApp,
9
+ maskSecret,
10
+ } from '../lib/core.js';
2
11
  import { resolveAppearance, sanitizeAppearance } from '../lib/appearance.js';
3
12
  import { readJson, sendJson, sendError } from '../lib/http.js';
4
13
 
5
14
  const ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,39}$/i;
6
15
 
7
- function hint(apiKey) {
8
- if (!apiKey) return '';
9
- if (apiKey.startsWith('env:')) return apiKey;
10
- return apiKey.length <= 4 ? '••••' : `••••${apiKey.slice(-4)}`;
11
- }
12
-
13
16
  /** Merge built-in metadata with stored overrides into the provider view. */
14
17
  export function providerList(ctx) {
15
18
  const stored = new Map(ctx.store.listProviders().map((p) => [p.id, p]));
@@ -27,7 +30,7 @@ export function providerList(ctx) {
27
30
  builtin: true,
28
31
  stored: Boolean(row),
29
32
  hasKey: Boolean(row?.apiKey),
30
- apiKeyHint: hint(row?.apiKey),
33
+ apiKeyHint: maskSecret(row?.apiKey),
31
34
  models: row?.models ?? [],
32
35
  });
33
36
  }
@@ -44,7 +47,7 @@ export function providerList(ctx) {
44
47
  builtin: false,
45
48
  stored: true,
46
49
  hasKey: Boolean(row.apiKey),
47
- apiKeyHint: hint(row.apiKey),
50
+ apiKeyHint: maskSecret(row.apiKey),
48
51
  models: row.models,
49
52
  });
50
53
  }
@@ -19,6 +19,7 @@ import {
19
19
  scopeDirective,
20
20
  } from './lib/core.js';
21
21
  import { sendJson, sendError } from './lib/http.js';
22
+ import { requireTrustedHost, requireSameOrigin } from './lib/workspace.js';
22
23
  import { createPromptBuilder, DEFAULT_SYSTEM_PROMPT } from './lib/prompt.js';
23
24
  import { profilePrompt, resolveProfile } from './lib/profile.js';
24
25
  import { skillsBlock } from './lib/skills.js';
@@ -226,6 +227,20 @@ export async function createTamnoraServer({
226
227
  providerSettings,
227
228
  customProviderDefs,
228
229
  apiKeyFor: (id) => resolveApiKey(providerSettings(id).apiKey, process.env),
230
+ // Secretos conocidos (API keys guardadas, authSecret, token de Telegram):
231
+ // la redaccion de tool results los tapa aunque no matcheen los patrones
232
+ // genericos de tokens.
233
+ secretExtra: () => {
234
+ const values = [];
235
+ for (const provider of store.listProviders()) {
236
+ const key = String(provider.apiKey ?? '');
237
+ if (key && !key.startsWith('env:')) values.push(key);
238
+ }
239
+ for (const value of [app.authSecret, app.user?.password, ctx.channels?.readConfig?.().token]) {
240
+ if (value) values.push(String(value));
241
+ }
242
+ return values;
243
+ },
229
244
  resolveProvider: resolveProvider ?? ((settings) => resolveProviderRuntime(settings)),
230
245
  getMcp,
231
246
  reloadMcp,
@@ -359,6 +374,10 @@ export async function createTamnoraServer({
359
374
  if (url.pathname.startsWith('/api/')) {
360
375
  const matched = matchRoute(req.method, url.pathname);
361
376
  if (!matched) return sendError(res, 404, `Ruta no encontrada: ${req.method} ${url.pathname}`);
377
+ // Guardas de request SIEMPRE activas (con o sin auth): DNS-rebinding y
378
+ // CSRF drive-by. Una web ajena no puede leer ni mutar el server local.
379
+ if (!requireTrustedHost(req, res, config.server?.allowedHosts)) return;
380
+ if (req.method !== 'GET' && req.method !== 'HEAD' && !requireSameOrigin(req, res)) return;
362
381
  if (authEnabled && !AUTH_OPEN.has(url.pathname)) {
363
382
  if (!requireLogin(app, req, res)) return;
364
383
  if (req.method !== 'GET' && req.method !== 'HEAD' && !requireCsrf(app, req, res)) return;