@danieltmn/tamnora 0.21.2 → 0.23.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 (77) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +3 -1
  3. package/SECURITY.md +1 -1
  4. package/harness/packages/cli/bin/tamnora.mjs +7 -1
  5. package/harness/packages/cli/src/commands/doctor.js +42 -4
  6. package/harness/packages/cli/src/commands/repl.js +1 -0
  7. package/harness/packages/cli/src/commands/run.js +1 -0
  8. package/harness/packages/cli/src/commands/tui.js +1 -0
  9. package/harness/packages/cli/src/lib/runtime.js +2 -0
  10. package/harness/packages/cli/src/tui/app.js +2 -0
  11. package/harness/packages/core/src/agent/runAgent.js +9 -5
  12. package/harness/packages/core/src/browser/index.js +27 -13
  13. package/harness/packages/core/src/index.js +1 -0
  14. package/harness/packages/core/src/mcp/stdio.js +15 -0
  15. package/harness/packages/core/src/net/hosts.js +70 -0
  16. package/harness/packages/core/src/redact.js +2 -0
  17. package/harness/packages/core/src/tools/executors.js +19 -24
  18. package/harness/packages/core/src/tools/index.js +1 -1
  19. package/harness/packages/core/src/tools/paths.js +35 -5
  20. package/harness/packages/core/src/tools/permissions.js +41 -2
  21. package/harness/packages/core/src/tools/registry.js +8 -3
  22. package/harness/packages/core/src/tts/edge.js +34 -4
  23. package/harness/packages/core/src/tunnel.js +15 -5
  24. package/harness/packages/core/test/agent.test.js +18 -0
  25. package/harness/packages/core/test/servicios-vivos.test.js +71 -0
  26. package/harness/packages/core/test/tools.test.js +44 -1
  27. package/harness/packages/core/test/tts.test.js +81 -0
  28. package/harness/packages/server/bin/tamnora-server.mjs +7 -1
  29. package/harness/packages/server/public/app.js +347 -6757
  30. package/harness/packages/server/public/cards.js +470 -0
  31. package/harness/packages/server/public/chat.js +1343 -0
  32. package/harness/packages/server/public/chatFormat.js +156 -0
  33. package/harness/packages/server/public/chrome.js +813 -0
  34. package/harness/packages/server/public/commands.js +403 -0
  35. package/harness/packages/server/public/core.js +209 -0
  36. package/harness/packages/server/public/explorer.js +625 -0
  37. package/harness/packages/server/public/icons/logo_dark.png +0 -0
  38. package/harness/packages/server/public/icons/logo_light.png +0 -0
  39. package/harness/packages/server/public/index.html +40 -41
  40. package/harness/packages/server/public/sessions.js +274 -0
  41. package/harness/packages/server/public/settings.js +1080 -0
  42. package/harness/packages/server/public/styles.css +45 -52
  43. package/harness/packages/server/public/system.js +495 -0
  44. package/harness/packages/server/public/voice.js +1058 -0
  45. package/harness/packages/server/src/auth.js +12 -10
  46. package/harness/packages/server/src/lib/agentRun.js +6 -1
  47. package/harness/packages/server/src/lib/channels/telegram/api.js +102 -0
  48. package/harness/packages/server/src/lib/channels/telegram/commands.js +309 -0
  49. package/harness/packages/server/src/lib/channels/telegram/media.js +64 -0
  50. package/harness/packages/server/src/lib/channels/telegram/permissions.js +0 -0
  51. package/harness/packages/server/src/lib/channels/telegram/projects.js +102 -0
  52. package/harness/packages/server/src/lib/channels/telegram/run.js +206 -0
  53. package/harness/packages/server/src/lib/channels/telegram/text.js +57 -0
  54. package/harness/packages/server/src/lib/channels/telegram.js +163 -932
  55. package/harness/packages/server/src/lib/presence.js +12 -1
  56. package/harness/packages/server/src/lib/scheduler.js +12 -2
  57. package/harness/packages/server/src/lib/tasks.js +21 -3
  58. package/harness/packages/server/src/lib/workspace.js +90 -2
  59. package/harness/packages/server/src/routes/auth.js +8 -4
  60. package/harness/packages/server/src/routes/chat.js +16 -5
  61. package/harness/packages/server/src/routes/preview.js +10 -2
  62. package/harness/packages/server/src/routes/providers.js +14 -9
  63. package/harness/packages/server/src/server.js +25 -0
  64. package/harness/packages/server/test/chat.test.js +586 -0
  65. package/harness/packages/server/test/chatFormat.test.js +119 -0
  66. package/harness/packages/server/test/consoleModules.test.js +301 -0
  67. package/harness/packages/server/test/consoleSmoke.test.js +34 -0
  68. package/harness/packages/server/test/helpers.js +121 -0
  69. package/harness/packages/server/test/ops.test.js +515 -0
  70. package/harness/packages/server/test/runs.test.js +816 -0
  71. package/harness/packages/server/test/security.test.js +112 -9
  72. package/harness/packages/server/test/sessions.test.js +437 -0
  73. package/harness/packages/server/test/settings.test.js +482 -0
  74. package/harness/scripts/canary.mjs +24 -0
  75. package/harness/tamnora.example.json +1 -1
  76. package/package.json +3 -2
  77. package/harness/packages/server/test/server.test.js +0 -2885
@@ -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,11 +102,13 @@ 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,
106
109
  maxDurationMs: ctx.effectiveMaxDurationMs?.() || 0,
107
110
  maxOutputTokens: ctx.effectiveMaxOutputTokens?.() || 0,
111
+ promptCaching: ctx.effectivePromptCaching?.() ?? ctx.config.promptCaching === true,
108
112
  onEvent(event) {
109
113
  if (event.type === 'text') emit('text', { full: event.full });
110
114
  else if (event.type === 'reasoning') emit('reasoning', { full: event.full });
@@ -189,7 +193,13 @@ export function createScheduler(ctx, { intervalMs = 30000, log = () => {} } = {}
189
193
 
190
194
  function start() {
191
195
  if (timer) return;
192
- timer = setInterval(() => tick().catch(() => {}), intervalMs);
196
+ timer = setInterval(
197
+ () =>
198
+ tick().catch((error) => {
199
+ process.stderr.write(`tamnora: tick del planificador falló: ${error?.message ?? error}\n`);
200
+ }),
201
+ intervalMs
202
+ );
193
203
  timer.unref?.();
194
204
  }
195
205
 
@@ -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,9 +114,11 @@ 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,
121
+ promptCaching: ctx.effectivePromptCaching?.() ?? ctx.config.promptCaching === true,
104
122
  onEvent(event) {
105
123
  if (event.type === 'text') emit('text', { full: event.full, agentId }, agentId);
106
124
  else if (event.type === 'reasoning') emit('reasoning', { full: event.full, agentId }, agentId);
@@ -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,12 +340,13 @@ 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,
336
347
  maxDurationMs: Number(body.maxDurationMs) || ctx.effectiveMaxDurationMs?.() || 0,
337
348
  maxOutputTokens: Number(body.maxOutputTokens) || ctx.effectiveMaxOutputTokens?.() || 0,
338
- promptCaching: ctx.config.promptCaching === true,
349
+ promptCaching: ctx.effectivePromptCaching?.() ?? ctx.config.promptCaching === true,
339
350
  stallWarnMs: body.stallWarnMs ?? ctx.config.stallWarnMs,
340
351
  contextTokens,
341
352
  onEvent(event) {
@@ -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
  }
@@ -77,6 +80,7 @@ export function registerProviders(route, ctx) {
77
80
  maxTokens: ctx.effectiveMaxTokens(),
78
81
  maxDurationMs: ctx.effectiveMaxDurationMs(),
79
82
  maxOutputTokens: ctx.effectiveMaxOutputTokens(),
83
+ promptCaching: ctx.effectivePromptCaching(),
80
84
  defaultCwd: ctx.store.getSetting('defaultCwd') || '',
81
85
  strictScope: ctx.effectiveStrictScope(),
82
86
  mcpMode: ctx.effectiveMcpMode(),
@@ -95,6 +99,7 @@ export function registerProviders(route, ctx) {
95
99
  if (body.mode !== undefined) ctx.store.setSetting('activeMode', resolveMode(String(body.mode)).id);
96
100
  if (body.defaultCwd !== undefined) ctx.store.setSetting('defaultCwd', String(body.defaultCwd ?? '').trim() || null);
97
101
  if (body.strictScope !== undefined) ctx.store.setSetting('strictScope', Boolean(body.strictScope));
102
+ if (body.promptCaching !== undefined) ctx.store.setSetting('promptCaching', Boolean(body.promptCaching));
98
103
  if (body.mcpMode !== undefined) {
99
104
  const mode = String(body.mcpMode);
100
105
  ctx.store.setSetting('mcpMode', ['auto', 'eager', 'lazy'].includes(mode) ? mode : 'auto');
@@ -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';
@@ -180,6 +181,11 @@ export async function createTamnoraServer({
180
181
  const effectiveMaxDurationMs = () => settingNumber('maxDurationMs', config.maxDurationMs);
181
182
  const effectiveMaxOutputTokens = () => settingNumber('maxOutputTokens', config.maxOutputTokens);
182
183
  const effectiveStrictScope = () => store.getSetting('strictScope') === true;
184
+ const effectivePromptCaching = () => {
185
+ const stored = store.getSetting('promptCaching');
186
+ if (stored === null || stored === undefined) return config.promptCaching === true;
187
+ return stored === true;
188
+ };
183
189
  const MCP_MODES = new Set(['auto', 'eager', 'lazy']);
184
190
  const effectiveMcpMode = () => {
185
191
  const value = String(store.getSetting('mcpMode') ?? config.mcpMode ?? 'auto');
@@ -220,12 +226,27 @@ export async function createTamnoraServer({
220
226
  effectiveMaxDurationMs,
221
227
  effectiveMaxOutputTokens,
222
228
  effectiveStrictScope,
229
+ effectivePromptCaching,
223
230
  effectiveMcpMode,
224
231
  effectiveMcpPinned,
225
232
  mcpText,
226
233
  providerSettings,
227
234
  customProviderDefs,
228
235
  apiKeyFor: (id) => resolveApiKey(providerSettings(id).apiKey, process.env),
236
+ // Secretos conocidos (API keys guardadas, authSecret, token de Telegram):
237
+ // la redaccion de tool results los tapa aunque no matcheen los patrones
238
+ // genericos de tokens.
239
+ secretExtra: () => {
240
+ const values = [];
241
+ for (const provider of store.listProviders()) {
242
+ const key = String(provider.apiKey ?? '');
243
+ if (key && !key.startsWith('env:')) values.push(key);
244
+ }
245
+ for (const value of [app.authSecret, app.user?.password, ctx.channels?.readConfig?.().token]) {
246
+ if (value) values.push(String(value));
247
+ }
248
+ return values;
249
+ },
229
250
  resolveProvider: resolveProvider ?? ((settings) => resolveProviderRuntime(settings)),
230
251
  getMcp,
231
252
  reloadMcp,
@@ -359,6 +380,10 @@ export async function createTamnoraServer({
359
380
  if (url.pathname.startsWith('/api/')) {
360
381
  const matched = matchRoute(req.method, url.pathname);
361
382
  if (!matched) return sendError(res, 404, `Ruta no encontrada: ${req.method} ${url.pathname}`);
383
+ // Guardas de request SIEMPRE activas (con o sin auth): DNS-rebinding y
384
+ // CSRF drive-by. Una web ajena no puede leer ni mutar el server local.
385
+ if (!requireTrustedHost(req, res, config.server?.allowedHosts)) return;
386
+ if (req.method !== 'GET' && req.method !== 'HEAD' && !requireSameOrigin(req, res)) return;
362
387
  if (authEnabled && !AUTH_OPEN.has(url.pathname)) {
363
388
  if (!requireLogin(app, req, res)) return;
364
389
  if (req.method !== 'GET' && req.method !== 'HEAD' && !requireCsrf(app, req, res)) return;