@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.
- package/CHANGELOG.md +28 -0
- package/README.md +3 -1
- package/SECURITY.md +1 -1
- package/harness/packages/cli/bin/tamnora.mjs +7 -1
- package/harness/packages/cli/src/commands/doctor.js +42 -4
- package/harness/packages/cli/src/commands/repl.js +1 -0
- package/harness/packages/cli/src/commands/run.js +1 -0
- package/harness/packages/cli/src/commands/tui.js +1 -0
- package/harness/packages/cli/src/lib/runtime.js +2 -0
- package/harness/packages/cli/src/tui/app.js +2 -0
- package/harness/packages/core/src/agent/runAgent.js +9 -5
- package/harness/packages/core/src/browser/index.js +27 -13
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/mcp/stdio.js +15 -0
- package/harness/packages/core/src/net/hosts.js +70 -0
- package/harness/packages/core/src/redact.js +2 -0
- package/harness/packages/core/src/tools/executors.js +19 -24
- package/harness/packages/core/src/tools/index.js +1 -1
- package/harness/packages/core/src/tools/paths.js +35 -5
- package/harness/packages/core/src/tools/permissions.js +41 -2
- package/harness/packages/core/src/tools/registry.js +8 -3
- package/harness/packages/core/src/tts/edge.js +34 -4
- package/harness/packages/core/src/tunnel.js +15 -5
- package/harness/packages/core/test/agent.test.js +18 -0
- package/harness/packages/core/test/servicios-vivos.test.js +71 -0
- package/harness/packages/core/test/tools.test.js +44 -1
- package/harness/packages/core/test/tts.test.js +81 -0
- package/harness/packages/server/bin/tamnora-server.mjs +7 -1
- package/harness/packages/server/public/app.js +347 -6757
- package/harness/packages/server/public/cards.js +470 -0
- package/harness/packages/server/public/chat.js +1343 -0
- package/harness/packages/server/public/chatFormat.js +156 -0
- package/harness/packages/server/public/chrome.js +813 -0
- package/harness/packages/server/public/commands.js +403 -0
- package/harness/packages/server/public/core.js +209 -0
- package/harness/packages/server/public/explorer.js +625 -0
- package/harness/packages/server/public/icons/logo_dark.png +0 -0
- package/harness/packages/server/public/icons/logo_light.png +0 -0
- package/harness/packages/server/public/index.html +40 -41
- package/harness/packages/server/public/sessions.js +274 -0
- package/harness/packages/server/public/settings.js +1080 -0
- package/harness/packages/server/public/styles.css +45 -52
- package/harness/packages/server/public/system.js +495 -0
- package/harness/packages/server/public/voice.js +1058 -0
- package/harness/packages/server/src/auth.js +12 -10
- package/harness/packages/server/src/lib/agentRun.js +6 -1
- package/harness/packages/server/src/lib/channels/telegram/api.js +102 -0
- package/harness/packages/server/src/lib/channels/telegram/commands.js +309 -0
- package/harness/packages/server/src/lib/channels/telegram/media.js +64 -0
- package/harness/packages/server/src/lib/channels/telegram/permissions.js +0 -0
- package/harness/packages/server/src/lib/channels/telegram/projects.js +102 -0
- package/harness/packages/server/src/lib/channels/telegram/run.js +206 -0
- package/harness/packages/server/src/lib/channels/telegram/text.js +57 -0
- package/harness/packages/server/src/lib/channels/telegram.js +163 -932
- package/harness/packages/server/src/lib/presence.js +12 -1
- package/harness/packages/server/src/lib/scheduler.js +12 -2
- package/harness/packages/server/src/lib/tasks.js +21 -3
- package/harness/packages/server/src/lib/workspace.js +90 -2
- package/harness/packages/server/src/routes/auth.js +8 -4
- package/harness/packages/server/src/routes/chat.js +16 -5
- package/harness/packages/server/src/routes/preview.js +10 -2
- package/harness/packages/server/src/routes/providers.js +14 -9
- package/harness/packages/server/src/server.js +25 -0
- package/harness/packages/server/test/chat.test.js +586 -0
- package/harness/packages/server/test/chatFormat.test.js +119 -0
- package/harness/packages/server/test/consoleModules.test.js +301 -0
- package/harness/packages/server/test/consoleSmoke.test.js +34 -0
- package/harness/packages/server/test/helpers.js +121 -0
- package/harness/packages/server/test/ops.test.js +515 -0
- package/harness/packages/server/test/runs.test.js +816 -0
- package/harness/packages/server/test/security.test.js +112 -9
- package/harness/packages/server/test/sessions.test.js +437 -0
- package/harness/packages/server/test/settings.test.js +482 -0
- package/harness/scripts/canary.mjs +24 -0
- package/harness/tamnora.example.json +1 -1
- package/package.json +3 -2
- 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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
46
|
+
loginRecordFailure(req);
|
|
43
47
|
sendError(res, 401, 'Usuario o contrasena incorrectos');
|
|
44
48
|
return;
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
loginClear(req
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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:
|
|
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:
|
|
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;
|