@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.
- package/CHANGELOG.md +9 -0
- package/harness/packages/core/src/agent/runAgent.js +9 -5
- 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/tunnel.js +15 -5
- package/harness/packages/core/test/tools.test.js +44 -1
- package/harness/packages/server/public/app.js +343 -6754
- package/harness/packages/server/public/chat.js +1893 -0
- package/harness/packages/server/public/chrome.js +812 -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/index.html +19 -19
- package/harness/packages/server/public/sessions.js +274 -0
- package/harness/packages/server/public/settings.js +1076 -0
- package/harness/packages/server/public/styles.css +25 -2
- 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.js +1 -0
- package/harness/packages/server/src/lib/presence.js +12 -1
- package/harness/packages/server/src/lib/scheduler.js +11 -2
- package/harness/packages/server/src/lib/tasks.js +20 -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 +15 -4
- package/harness/packages/server/src/routes/preview.js +10 -2
- package/harness/packages/server/src/routes/providers.js +12 -9
- package/harness/packages/server/src/server.js +19 -0
- package/harness/packages/server/test/chat.test.js +586 -0
- package/harness/packages/server/test/helpers.js +107 -0
- package/harness/packages/server/test/ops.test.js +511 -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 +465 -0
- package/harness/tamnora.example.json +1 -1
- package/package.json +1 -1
- 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
|
|
129
|
-
//
|
|
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
|
|
138
|
+
function clientKey(req) {
|
|
137
139
|
const ip = (req.socket && req.socket.remoteAddress) || 'desconocido';
|
|
138
|
-
return
|
|
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
|
|
151
|
+
export function loginLockRemaining(req) {
|
|
150
152
|
const now = Date.now();
|
|
151
|
-
const st = attempts.get(clientKey(req
|
|
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
|
|
159
|
+
export function loginRecordFailure(req) {
|
|
158
160
|
const now = Date.now();
|
|
159
161
|
sweepStale(now);
|
|
160
|
-
const key = clientKey(req
|
|
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
|
|
169
|
-
attempts.delete(clientKey(req
|
|
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(() =>
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
}
|
|
@@ -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;
|