@danieltmn/tamnora 0.1.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 +48 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/SECURITY.md +31 -0
- package/harness/packages/cli/bin/tamnora.mjs +11 -0
- package/harness/packages/cli/package.json +10 -0
- package/harness/packages/cli/src/cli.js +90 -0
- package/harness/packages/cli/src/commands/autostart.js +121 -0
- package/harness/packages/cli/src/commands/bridge.js +274 -0
- package/harness/packages/cli/src/commands/doctor.js +73 -0
- package/harness/packages/cli/src/commands/eval.js +183 -0
- package/harness/packages/cli/src/commands/init.js +31 -0
- package/harness/packages/cli/src/commands/mcp.js +101 -0
- package/harness/packages/cli/src/commands/repl.js +172 -0
- package/harness/packages/cli/src/commands/run.js +165 -0
- package/harness/packages/cli/src/commands/serve.js +1 -0
- package/harness/packages/cli/src/commands/server.js +345 -0
- package/harness/packages/cli/src/commands/tui.js +110 -0
- package/harness/packages/cli/src/commands/update.js +76 -0
- package/harness/packages/cli/src/commands/user.js +135 -0
- package/harness/packages/cli/src/lib/core.js +2 -0
- package/harness/packages/cli/src/lib/daemon.js +153 -0
- package/harness/packages/cli/src/lib/qr.js +203 -0
- package/harness/packages/cli/src/lib/runtime.js +110 -0
- package/harness/packages/cli/src/lib/tunnel.js +113 -0
- package/harness/packages/cli/src/lib/util.js +24 -0
- package/harness/packages/cli/src/tui/app.js +302 -0
- package/harness/packages/cli/src/tui/keys.js +78 -0
- package/harness/packages/cli/src/tui/layout.js +130 -0
- package/harness/packages/cli/src/tui/screen.js +13 -0
- package/harness/packages/cli/src/ui/color.js +15 -0
- package/harness/packages/cli/test/release.test.js +34 -0
- package/harness/packages/cli/test/tui.test.js +99 -0
- package/harness/packages/cli/test/tunnel.test.js +20 -0
- package/harness/packages/core/package.json +14 -0
- package/harness/packages/core/src/agent/context.js +66 -0
- package/harness/packages/core/src/agent/modes.js +26 -0
- package/harness/packages/core/src/agent/runAgent.js +257 -0
- package/harness/packages/core/src/config/app.js +49 -0
- package/harness/packages/core/src/config/home.js +98 -0
- package/harness/packages/core/src/config/identity.js +41 -0
- package/harness/packages/core/src/config/index.js +69 -0
- package/harness/packages/core/src/config/providers.js +24 -0
- package/harness/packages/core/src/env.js +19 -0
- package/harness/packages/core/src/eval/dataset.js +47 -0
- package/harness/packages/core/src/eval/judge.js +39 -0
- package/harness/packages/core/src/eval/report.js +48 -0
- package/harness/packages/core/src/eval/runTask.js +71 -0
- package/harness/packages/core/src/eval/runner.js +150 -0
- package/harness/packages/core/src/eval/scorers.js +217 -0
- package/harness/packages/core/src/eval/task.js +59 -0
- package/harness/packages/core/src/index.js +72 -0
- package/harness/packages/core/src/mcp/common.js +82 -0
- package/harness/packages/core/src/mcp/http.js +149 -0
- package/harness/packages/core/src/mcp/index.js +167 -0
- package/harness/packages/core/src/mcp/stdio.js +218 -0
- package/harness/packages/core/src/providers/index.js +119 -0
- package/harness/packages/core/src/providers/ollama.js +76 -0
- package/harness/packages/core/src/providers/openaiCompatible.js +57 -0
- package/harness/packages/core/src/providers/openrouter.js +57 -0
- package/harness/packages/core/src/providers/presets.js +19 -0
- package/harness/packages/core/src/providers/resilience.js +74 -0
- package/harness/packages/core/src/providers/stream.js +200 -0
- package/harness/packages/core/src/providers/utils.js +66 -0
- package/harness/packages/core/src/storage/sqlite.js +453 -0
- package/harness/packages/core/src/tools/diff.js +90 -0
- package/harness/packages/core/src/tools/executors.js +277 -0
- package/harness/packages/core/src/tools/glob.js +67 -0
- package/harness/packages/core/src/tools/index.js +28 -0
- package/harness/packages/core/src/tools/paths.js +29 -0
- package/harness/packages/core/src/tools/permissions.js +69 -0
- package/harness/packages/core/src/tools/registry.js +44 -0
- package/harness/packages/core/src/tools/schema.js +177 -0
- package/harness/packages/core/test/agent.test.js +237 -0
- package/harness/packages/core/test/config.test.js +33 -0
- package/harness/packages/core/test/context.test.js +44 -0
- package/harness/packages/core/test/diff.test.js +20 -0
- package/harness/packages/core/test/eval.test.js +144 -0
- package/harness/packages/core/test/git-tools.test.js +44 -0
- package/harness/packages/core/test/home.test.js +62 -0
- package/harness/packages/core/test/mcp.test.js +158 -0
- package/harness/packages/core/test/providers.test.js +80 -0
- package/harness/packages/core/test/resilience.test.js +61 -0
- package/harness/packages/core/test/storage.test.js +55 -0
- package/harness/packages/core/test/stream.test.js +91 -0
- package/harness/packages/core/test/tools.test.js +96 -0
- package/harness/packages/server/bin/tamnora-server.mjs +11 -0
- package/harness/packages/server/package.json +10 -0
- package/harness/packages/server/public/app.js +2188 -0
- package/harness/packages/server/public/img/fondo-agentes.webp +0 -0
- package/harness/packages/server/public/index.html +368 -0
- package/harness/packages/server/public/landing/index.html +523 -0
- package/harness/packages/server/public/landing/landing.js +89 -0
- package/harness/packages/server/public/markdown.js +174 -0
- package/harness/packages/server/public/styles.css +923 -0
- package/harness/packages/server/src/auth.js +146 -0
- package/harness/packages/server/src/cli.js +61 -0
- package/harness/packages/server/src/index.js +1 -0
- package/harness/packages/server/src/lib/approvals.js +51 -0
- package/harness/packages/server/src/lib/core.js +2 -0
- package/harness/packages/server/src/lib/http.js +74 -0
- package/harness/packages/server/src/lib/modelCache.js +32 -0
- package/harness/packages/server/src/lib/prompt.js +48 -0
- package/harness/packages/server/src/lib/workspace.js +74 -0
- package/harness/packages/server/src/routes/auth.js +56 -0
- package/harness/packages/server/src/routes/chat.js +222 -0
- package/harness/packages/server/src/routes/eval.js +100 -0
- package/harness/packages/server/src/routes/files.js +117 -0
- package/harness/packages/server/src/routes/folders.js +80 -0
- package/harness/packages/server/src/routes/mcp.js +50 -0
- package/harness/packages/server/src/routes/models.js +26 -0
- package/harness/packages/server/src/routes/providers.js +135 -0
- package/harness/packages/server/src/routes/sessions.js +157 -0
- package/harness/packages/server/src/server.js +249 -0
- package/harness/packages/server/src/static.js +67 -0
- package/harness/packages/server/test/markdown.test.js +36 -0
- package/harness/packages/server/test/modelCache.test.js +72 -0
- package/harness/packages/server/test/server.test.js +809 -0
- package/harness/scripts/example-dataset.jsonl +4 -0
- package/harness/scripts/mcp-echo-server.mjs +108 -0
- package/harness/scripts/mock-provider.mjs +134 -0
- package/harness/tamnora.example.json +18 -0
- package/package.json +70 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { sendError } from './lib/http.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Autenticacion web con cookie de sesion firmada (stateless), CSRF y bloqueo
|
|
6
|
+
* por intentos fallidos. Sin dependencias externas.
|
|
7
|
+
*
|
|
8
|
+
* La cookie es HttpOnly; el token CSRF se entrega aparte (`/api/auth/me`) y el
|
|
9
|
+
* cliente lo reenvia en el header `x-csrf` en las mutaciones.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const COOKIE = 'tamnora_session';
|
|
13
|
+
const SESSION_DAYS = 7;
|
|
14
|
+
|
|
15
|
+
function b64url(buf) {
|
|
16
|
+
return Buffer.from(buf).toString('base64url');
|
|
17
|
+
}
|
|
18
|
+
function sign(data, secret) {
|
|
19
|
+
return createHmac('sha256', secret).update(data).digest('base64url');
|
|
20
|
+
}
|
|
21
|
+
function safeEqual(a, b) {
|
|
22
|
+
const ba = Buffer.from(String(a));
|
|
23
|
+
const bb = Buffer.from(String(b));
|
|
24
|
+
if (ba.length !== bb.length) return false;
|
|
25
|
+
return timingSafeEqual(ba, bb);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseCookies(header) {
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const part of String(header || '').split(';')) {
|
|
31
|
+
const i = part.indexOf('=');
|
|
32
|
+
if (i < 0) continue;
|
|
33
|
+
const key = part.slice(0, i).trim();
|
|
34
|
+
if (key) out[key] = decodeURIComponent(part.slice(i + 1).trim());
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Detras del tunel el server recibe desde loopback: solo ahi confiamos en
|
|
40
|
+
// X-Forwarded-Proto para marcar la cookie Secure.
|
|
41
|
+
function isSecure(req) {
|
|
42
|
+
if (req.socket && req.socket.encrypted) return true;
|
|
43
|
+
const remote = req.socket && req.socket.remoteAddress;
|
|
44
|
+
const loopback = !remote || remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
|
|
45
|
+
if (!loopback) return false;
|
|
46
|
+
const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
|
|
47
|
+
return proto === 'https';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function serializeCookie(value, req, maxAge) {
|
|
51
|
+
const parts = [`${COOKIE}=${encodeURIComponent(value)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];
|
|
52
|
+
if (isSecure(req)) parts.push('Secure');
|
|
53
|
+
if (typeof maxAge === 'number') parts.push(`Max-Age=${maxAge}`);
|
|
54
|
+
return parts.join('; ');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function makeSession(app, csrf, exp, pv) {
|
|
58
|
+
const payload = b64url(JSON.stringify({ c: csrf, e: exp, pv }));
|
|
59
|
+
return `${payload}.${sign(payload, app.authSecret)}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function readSession(app, req) {
|
|
63
|
+
const raw = parseCookies(req.headers.cookie)[COOKIE];
|
|
64
|
+
if (!raw) return null;
|
|
65
|
+
const dot = raw.lastIndexOf('.');
|
|
66
|
+
if (dot < 0) return null;
|
|
67
|
+
const payload = raw.slice(0, dot);
|
|
68
|
+
const sig = raw.slice(dot + 1);
|
|
69
|
+
if (!safeEqual(sign(payload, app.authSecret), sig)) return null;
|
|
70
|
+
let data;
|
|
71
|
+
try {
|
|
72
|
+
data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (!data || typeof data.e !== 'number' || data.e < Math.floor(Date.now() / 1000)) return null;
|
|
77
|
+
if ((parseInt(data.pv, 10) || 1) !== (parseInt(app.user?.pv, 10) || 1)) return null;
|
|
78
|
+
return { c: data.c, e: data.e };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function startSession(app, req, res) {
|
|
82
|
+
const csrf = randomBytes(16).toString('hex');
|
|
83
|
+
const exp = Math.floor(Date.now() / 1000) + SESSION_DAYS * 86400;
|
|
84
|
+
const token = makeSession(app, csrf, exp, app.user?.pv);
|
|
85
|
+
res.setHeader('Set-Cookie', serializeCookie(token, req, SESSION_DAYS * 86400));
|
|
86
|
+
return csrf;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function endSession(req, res) {
|
|
90
|
+
res.setHeader('Set-Cookie', serializeCookie('', req, 0));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function requireLogin(app, req, res) {
|
|
94
|
+
const session = readSession(app, req);
|
|
95
|
+
if (!session) {
|
|
96
|
+
sendError(res, 401, 'No autorizado');
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return session;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function requireCsrf(app, req, res) {
|
|
103
|
+
const session = readSession(app, req);
|
|
104
|
+
const given = String(req.headers['x-csrf'] || '').trim();
|
|
105
|
+
if (!session || !given || !safeEqual(session.c, given)) {
|
|
106
|
+
sendError(res, 403, 'Sesion invalida, recarga la pagina.');
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return session;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Bloqueo por intentos fallidos (en memoria: el server es un proceso).
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
const MAX_ATTEMPTS = 5;
|
|
116
|
+
const WINDOW_MS = 15 * 60 * 1000;
|
|
117
|
+
const LOCK_MS = 15 * 60 * 1000;
|
|
118
|
+
const attempts = new Map();
|
|
119
|
+
|
|
120
|
+
function clientKey(req, user) {
|
|
121
|
+
const xff = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
|
122
|
+
const ip = xff || (req.socket && req.socket.remoteAddress) || 'desconocido';
|
|
123
|
+
return `${ip}|${String(user || '').trim().toLowerCase()}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function loginLockRemaining(req, user) {
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
const st = attempts.get(clientKey(req, user));
|
|
129
|
+
if (!st || !st.lockUntil) return 0;
|
|
130
|
+
const left = st.lockUntil - now;
|
|
131
|
+
return left > 0 ? Math.ceil(left / 1000) : 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function loginRecordFailure(req, user) {
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
const key = clientKey(req, user);
|
|
137
|
+
let st = attempts.get(key);
|
|
138
|
+
if (!st || (!st.lockUntil && now - st.first > WINDOW_MS)) st = { count: 0, first: now, lockUntil: 0 };
|
|
139
|
+
st.count += 1;
|
|
140
|
+
if (st.count >= MAX_ATTEMPTS) st.lockUntil = now + LOCK_MS;
|
|
141
|
+
attempts.set(key, st);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function loginClear(req, user) {
|
|
145
|
+
attempts.delete(clientKey(req, user));
|
|
146
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { createTamnoraServer } from './server.js';
|
|
4
|
+
|
|
5
|
+
const OPTIONS = {
|
|
6
|
+
port: { type: 'string' },
|
|
7
|
+
host: { type: 'string' },
|
|
8
|
+
dir: { type: 'string' },
|
|
9
|
+
static: { type: 'string' },
|
|
10
|
+
yes: { type: 'boolean', short: 'y' },
|
|
11
|
+
help: { type: 'boolean', short: 'h' },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Entry point shared by `tamnora serve` and the standalone server bin. */
|
|
15
|
+
export async function serveCommand(argv) {
|
|
16
|
+
const { values } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: false });
|
|
17
|
+
if (values.help) {
|
|
18
|
+
printHelp();
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const cwd = values.dir ? path.resolve(values.dir) : process.cwd();
|
|
23
|
+
const instance = await createTamnoraServer({
|
|
24
|
+
cwd,
|
|
25
|
+
port: values.port ? Number(values.port) : undefined,
|
|
26
|
+
host: values.host,
|
|
27
|
+
staticDir: values.static ? path.resolve(values.static) : undefined,
|
|
28
|
+
yes: Boolean(values.yes),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const info = await instance.listen();
|
|
32
|
+
process.stdout.write(`tamnora server → ${info.url}\n`);
|
|
33
|
+
process.stdout.write(` workspace ${cwd}\n`);
|
|
34
|
+
process.stdout.write(` ${instance.config.provider}/${instance.config.model || '(sin modelo)'}\n`);
|
|
35
|
+
process.stdout.write(' Ctrl+C para salir\n');
|
|
36
|
+
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
let stopping = false;
|
|
39
|
+
const shutdown = async () => {
|
|
40
|
+
if (stopping) return;
|
|
41
|
+
stopping = true;
|
|
42
|
+
await instance.close();
|
|
43
|
+
resolve(0);
|
|
44
|
+
};
|
|
45
|
+
process.once('SIGINT', shutdown);
|
|
46
|
+
process.once('SIGTERM', shutdown);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function printHelp() {
|
|
51
|
+
console.log(`Uso: tamnora serve [opciones]
|
|
52
|
+
|
|
53
|
+
Levanta la consola del harness (chat + evaluación) en localhost.
|
|
54
|
+
|
|
55
|
+
Opciones:
|
|
56
|
+
--port <n> Puerto (default: 4517)
|
|
57
|
+
--host <addr> Interfaz (default: 127.0.0.1)
|
|
58
|
+
--dir <path> Directorio del proyecto
|
|
59
|
+
--static <dir> Servir un build estático propio en "/"
|
|
60
|
+
-y, --yes Auto-aprobar tools con permiso "ask"`);
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createTamnoraServer } from './server.js';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Broker for interactive tool approvals. The chat route registers a pending
|
|
3
|
+
* request (`ask`) and awaits it while the SSE stream emits a `permission`
|
|
4
|
+
* event; the console answers by POSTing to `/api/permissions/:id`, which calls
|
|
5
|
+
* `decide`. Requests that nobody answers expire to `false` (deny).
|
|
6
|
+
*
|
|
7
|
+
* @param {object} [opts]
|
|
8
|
+
* @param {number} [opts.timeoutMs] - how long to wait before denying
|
|
9
|
+
*/
|
|
10
|
+
export function createApprovalBroker({ timeoutMs = 300000 } = {}) {
|
|
11
|
+
const pending = new Map();
|
|
12
|
+
|
|
13
|
+
function settle(id, allow) {
|
|
14
|
+
const entry = pending.get(id);
|
|
15
|
+
if (!entry) return false;
|
|
16
|
+
clearTimeout(entry.timer);
|
|
17
|
+
pending.delete(id);
|
|
18
|
+
entry.resolve(Boolean(allow));
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
/** Register a pending request; resolves to a boolean decision. */
|
|
24
|
+
ask(id) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const timer = setTimeout(() => settle(id, false), timeoutMs);
|
|
27
|
+
timer.unref?.();
|
|
28
|
+
pending.set(id, { resolve, timer });
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
/** Resolve a pending request. Returns false if it expired or is unknown. */
|
|
33
|
+
decide(id, allow) {
|
|
34
|
+
return settle(id, allow);
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
/** Deny every pending request in `ids` (used when the client disconnects). */
|
|
38
|
+
cancel(ids) {
|
|
39
|
+
for (const id of ids) settle(id, false);
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
/** Deny everything still pending (used on shutdown). */
|
|
43
|
+
cancelAll() {
|
|
44
|
+
for (const id of [...pending.keys()]) settle(id, false);
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
get pending() {
|
|
48
|
+
return pending.size;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Small HTTP helpers: JSON bodies/responses and Server-Sent Events.
|
|
2
|
+
|
|
3
|
+
export function sendJson(res, status, body) {
|
|
4
|
+
const payload = JSON.stringify(body);
|
|
5
|
+
res.writeHead(status, {
|
|
6
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
7
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
8
|
+
});
|
|
9
|
+
res.end(payload);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function sendError(res, status, message) {
|
|
13
|
+
sendJson(res, status, { error: message });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Read and parse a JSON request body (with a size cap). */
|
|
17
|
+
export async function readJson(req, { limit = 8 * 1024 * 1024 } = {}) {
|
|
18
|
+
const chunks = [];
|
|
19
|
+
let size = 0;
|
|
20
|
+
for await (const chunk of req) {
|
|
21
|
+
size += chunk.length;
|
|
22
|
+
if (size > limit) throw new Error('El cuerpo de la petición es demasiado grande.');
|
|
23
|
+
chunks.push(chunk);
|
|
24
|
+
}
|
|
25
|
+
if (!chunks.length) return {};
|
|
26
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(text);
|
|
29
|
+
} catch {
|
|
30
|
+
throw new Error('JSON inválido en el cuerpo de la petición.');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Start an SSE response and return helpers to emit events.
|
|
36
|
+
* Sends heartbeat comments so proxies/timeouts do not close the stream.
|
|
37
|
+
*/
|
|
38
|
+
export function openSse(req, res) {
|
|
39
|
+
res.writeHead(200, {
|
|
40
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
41
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
42
|
+
Connection: 'keep-alive',
|
|
43
|
+
'X-Accel-Buffering': 'no',
|
|
44
|
+
});
|
|
45
|
+
res.flushHeaders?.();
|
|
46
|
+
|
|
47
|
+
let closed = false;
|
|
48
|
+
const heartbeat = setInterval(() => {
|
|
49
|
+
if (!closed && !res.writableEnded) res.write(': ping\n\n');
|
|
50
|
+
}, 15000);
|
|
51
|
+
heartbeat.unref?.();
|
|
52
|
+
req.on('close', () => {
|
|
53
|
+
closed = true;
|
|
54
|
+
clearInterval(heartbeat);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
event(name, data) {
|
|
59
|
+
if (closed || res.writableEnded) return;
|
|
60
|
+
res.write(`event: ${name}\ndata: ${JSON.stringify(data ?? {})}\n\n`);
|
|
61
|
+
},
|
|
62
|
+
comment(text) {
|
|
63
|
+
if (closed || res.writableEnded) return;
|
|
64
|
+
res.write(`: ${text}\n\n`);
|
|
65
|
+
},
|
|
66
|
+
close() {
|
|
67
|
+
clearInterval(heartbeat);
|
|
68
|
+
if (!res.writableEnded) res.end();
|
|
69
|
+
},
|
|
70
|
+
get closed() {
|
|
71
|
+
return closed || res.writableEnded;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Last-good model catalog cache (openbridge pattern): a provider listing that
|
|
3
|
+
* fails or comes back empty must never wipe the last known catalog. Fresh
|
|
4
|
+
* entries are served from memory for `ttlMs`; after that a refresh is attempted
|
|
5
|
+
* and only replaced when it succeeds with data.
|
|
6
|
+
*/
|
|
7
|
+
export function createModelCache({ ttlMs = 30 * 60 * 1000, now = Date.now } = {}) {
|
|
8
|
+
const cache = new Map();
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
async get(key, fetcher) {
|
|
12
|
+
const hit = cache.get(key);
|
|
13
|
+
if (hit && now() - hit.at < ttlMs) return { models: hit.models, cached: true };
|
|
14
|
+
try {
|
|
15
|
+
const models = await fetcher();
|
|
16
|
+
if (Array.isArray(models) && models.length) {
|
|
17
|
+
cache.set(key, { at: now(), models });
|
|
18
|
+
return { models, cached: false };
|
|
19
|
+
}
|
|
20
|
+
if (hit) return { models: hit.models, cached: true, stale: 'respuesta vacía' };
|
|
21
|
+
return { models: [], cached: false };
|
|
22
|
+
} catch (error) {
|
|
23
|
+
const message = error?.message ?? String(error);
|
|
24
|
+
if (hit) return { models: hit.models, cached: true, stale: message };
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
clear() {
|
|
29
|
+
cache.clear();
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { loadInstructions } from './core.js';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_SYSTEM_PROMPT =
|
|
4
|
+
'Eres Tamnora, un asistente útil. Responde en el idioma del usuario y usa las herramientas cuando aporten valor.';
|
|
5
|
+
|
|
6
|
+
function workspacePreamble(cwd) {
|
|
7
|
+
return [
|
|
8
|
+
'Eres Tamnora, un agente de código local que trabaja sobre una carpeta de trabajo.',
|
|
9
|
+
'',
|
|
10
|
+
`Carpeta de trabajo: ${cwd}`,
|
|
11
|
+
'',
|
|
12
|
+
'Reglas:',
|
|
13
|
+
'- Tus herramientas (read, list, glob, grep, write, edit) usan rutas relativas a esa carpeta; bash se ejecuta ahí.',
|
|
14
|
+
'- No podés salir de la carpeta de trabajo.',
|
|
15
|
+
'- Antes de afirmar algo sobre el proyecto, inspeccionalo con las herramientas (list, glob, grep, read). No inventes archivos ni contenido.',
|
|
16
|
+
'- write, edit y bash pueden requerir aprobación del usuario; si una llamada se deniega, explicá qué querías hacer en vez de reintentar en bucle.',
|
|
17
|
+
'- Respondé en el idioma del usuario, de forma concisa.',
|
|
18
|
+
].join('\n');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build a `systemPromptFor(cwd)` function that composes a workspace-aware
|
|
23
|
+
* system prompt: a preamble naming the working directory plus that directory's
|
|
24
|
+
* own instruction files (AGENTS.md by default). Results are cached per path.
|
|
25
|
+
*
|
|
26
|
+
* The server previously baked a single prompt from its own cwd, so chatting in
|
|
27
|
+
* another folder still described the server's project. This keeps instructions
|
|
28
|
+
* scoped to the folder the session actually works in.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} opts
|
|
31
|
+
* @param {string} opts.cwd - server default workspace
|
|
32
|
+
* @param {string[]} opts.files - instruction file names (e.g. ['AGENTS.md'])
|
|
33
|
+
*/
|
|
34
|
+
export function createPromptBuilder({ cwd, files }) {
|
|
35
|
+
const cache = new Map();
|
|
36
|
+
|
|
37
|
+
async function buildFor(workspace) {
|
|
38
|
+
const instructions = (await loadInstructions(workspace, files)).trim();
|
|
39
|
+
const base = workspacePreamble(workspace);
|
|
40
|
+
return instructions ? `${base}\n\n---\n\n${instructions}` : base;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return function systemPromptFor(workspace = cwd) {
|
|
44
|
+
const key = workspace || cwd;
|
|
45
|
+
if (!cache.has(key)) cache.set(key, buildFor(key));
|
|
46
|
+
return cache.get(key);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** True for 127.x / ::1 / ::ffff:127.x addresses. */
|
|
5
|
+
export function isLoopback(address) {
|
|
6
|
+
const addr = String(address ?? '');
|
|
7
|
+
return addr === '::1' || addr === '0:0:0:0:0:0:0:1' || addr.startsWith('127.') || addr === '::ffff:127.0.0.1';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Filesystem roots (drive letters on Windows, `/` elsewhere). */
|
|
11
|
+
export function rootDirs() {
|
|
12
|
+
if (process.platform !== 'win32') return ['/'];
|
|
13
|
+
const roots = [];
|
|
14
|
+
for (let code = 65; code <= 90; code++) {
|
|
15
|
+
const drive = `${String.fromCharCode(code)}:\\`;
|
|
16
|
+
try {
|
|
17
|
+
if (fs.statSync(drive).isDirectory()) roots.push(drive);
|
|
18
|
+
} catch {
|
|
19
|
+
// unit not present
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return roots;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a user-supplied workspace directory: must be an absolute (or
|
|
27
|
+
* resolvable) path to an existing directory.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveExistingDir(input) {
|
|
30
|
+
const raw = String(input ?? '').trim();
|
|
31
|
+
if (!raw) throw new Error('Falta la carpeta de trabajo.');
|
|
32
|
+
const target = path.resolve(raw);
|
|
33
|
+
let stat;
|
|
34
|
+
try {
|
|
35
|
+
stat = fs.statSync(target);
|
|
36
|
+
} catch {
|
|
37
|
+
throw new Error(`La carpeta no existe: "${target}"`);
|
|
38
|
+
}
|
|
39
|
+
if (!stat.isDirectory()) throw new Error(`No es una carpeta: "${target}"`);
|
|
40
|
+
return target;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Workspace for an HTTP request: the requested `cwd` (loopback-only) or the
|
|
45
|
+
* server's default. Throws on invalid input.
|
|
46
|
+
*/
|
|
47
|
+
export function workspaceForRequest({ req, raw, fallback }) {
|
|
48
|
+
const value = String(raw ?? '').trim();
|
|
49
|
+
if (!value) return fallback;
|
|
50
|
+
if (!isLoopback(req.socket.localAddress)) {
|
|
51
|
+
throw new Error('Cambiar la carpeta de trabajo solo está permitido en conexiones locales.');
|
|
52
|
+
}
|
|
53
|
+
return resolveExistingDir(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** List the subdirectories of a directory, sorted by name. */
|
|
57
|
+
export function listSubdirs(dir) {
|
|
58
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
59
|
+
const dirs = [];
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const full = path.join(dir, entry.name);
|
|
62
|
+
let isDir = entry.isDirectory();
|
|
63
|
+
if (!isDir && entry.isSymbolicLink()) {
|
|
64
|
+
try {
|
|
65
|
+
isDir = fs.statSync(full).isDirectory();
|
|
66
|
+
} catch {
|
|
67
|
+
isDir = false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (isDir) dirs.push({ name: entry.name, path: full });
|
|
71
|
+
}
|
|
72
|
+
dirs.sort((a, b) => a.name.localeCompare(b.name, 'es'));
|
|
73
|
+
return dirs;
|
|
74
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { verifyPassword } from '../lib/core.js';
|
|
2
|
+
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
|
+
import {
|
|
4
|
+
startSession,
|
|
5
|
+
endSession,
|
|
6
|
+
readSession,
|
|
7
|
+
loginLockRemaining,
|
|
8
|
+
loginRecordFailure,
|
|
9
|
+
loginClear,
|
|
10
|
+
} from '../auth.js';
|
|
11
|
+
|
|
12
|
+
export function registerAuth(route, ctx) {
|
|
13
|
+
route('GET', '/api/auth/me', ({ req, res }) => {
|
|
14
|
+
const enabled = ctx.authEnabled;
|
|
15
|
+
const session = enabled ? readSession(ctx.app, req) : null;
|
|
16
|
+
sendJson(res, 200, {
|
|
17
|
+
enabled,
|
|
18
|
+
authenticated: enabled ? Boolean(session) : true,
|
|
19
|
+
user: enabled && session ? { name: ctx.app.user.name } : null,
|
|
20
|
+
csrf: session?.c || '',
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
route('POST', '/api/auth/login', async ({ req, res }) => {
|
|
25
|
+
if (!ctx.authEnabled) {
|
|
26
|
+
sendJson(res, 200, { ok: true, enabled: false });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const body = await readJson(req);
|
|
30
|
+
const name = String(body.username ?? body.name ?? '').trim();
|
|
31
|
+
const password = String(body.password ?? '');
|
|
32
|
+
const user = ctx.app.user;
|
|
33
|
+
|
|
34
|
+
const left = loginLockRemaining(req, name);
|
|
35
|
+
if (left > 0) {
|
|
36
|
+
sendError(res, 429, `Demasiados intentos. Espera ${left}s.`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ok = name.toLowerCase() === String(user.name).toLowerCase() && verifyPassword(password, user.password);
|
|
41
|
+
if (!ok) {
|
|
42
|
+
loginRecordFailure(req, name);
|
|
43
|
+
sendError(res, 401, 'Usuario o contrasena incorrectos');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
loginClear(req, name);
|
|
48
|
+
const csrf = startSession(ctx.app, req, res);
|
|
49
|
+
sendJson(res, 200, { ok: true, enabled: true, user: { name: user.name }, csrf });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
route('POST', '/api/auth/logout', ({ req, res }) => {
|
|
53
|
+
endSession(req, res);
|
|
54
|
+
sendJson(res, 200, { ok: true });
|
|
55
|
+
});
|
|
56
|
+
}
|