@danieltmn/tamnora 0.17.0 → 0.18.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 +38 -0
- package/README.md +37 -3
- package/SECURITY.md +8 -0
- package/harness/packages/cli/src/commands/mcp.js +31 -4
- package/harness/packages/cli/src/commands/repl.js +7 -2
- package/harness/packages/cli/src/commands/run.js +5 -2
- package/harness/packages/cli/src/commands/tui.js +9 -2
- package/harness/packages/cli/src/lib/runtime.js +23 -1
- package/harness/packages/cli/test/tunnel.test.js +13 -1
- package/harness/packages/core/src/agent/modes.js +3 -0
- package/harness/packages/core/src/config/index.js +3 -1
- package/harness/packages/core/src/index.js +15 -1
- package/harness/packages/core/src/mcp/catalog.js +79 -0
- package/harness/packages/core/src/mcp/index.js +14 -2
- package/harness/packages/core/src/mcp/registry.js +29 -1
- package/harness/packages/core/src/redact.js +25 -0
- package/harness/packages/core/src/skills/catalog.js +78 -0
- package/harness/packages/core/src/skills/remote.js +51 -0
- package/harness/packages/core/src/tools/executors.js +93 -2
- package/harness/packages/core/src/tools/index.js +27 -6
- package/harness/packages/core/src/tools/labels.js +2 -0
- package/harness/packages/core/src/tools/mcp.js +91 -0
- package/harness/packages/core/src/tools/permissions.js +53 -8
- package/harness/packages/core/src/tools/registry.js +14 -7
- package/harness/packages/core/src/tools/schema.js +86 -0
- package/harness/packages/core/src/tts/edge.js +28 -7
- package/harness/packages/core/src/tunnel.js +51 -5
- package/harness/packages/core/test/compact.test.js +2 -0
- package/harness/packages/core/test/labels.test.js +1 -0
- package/harness/packages/core/test/mcp-registry.test.js +37 -1
- package/harness/packages/core/test/mcp-routing.test.js +115 -0
- package/harness/packages/core/test/redact.test.js +22 -0
- package/harness/packages/core/test/skills.test.js +11 -0
- package/harness/packages/core/test/tools.test.js +97 -3
- package/harness/packages/server/public/app.js +501 -23
- package/harness/packages/server/public/index.html +41 -2
- package/harness/packages/server/public/styles.css +99 -3
- package/harness/packages/server/src/lib/agentTools.js +45 -3
- package/harness/packages/server/src/lib/channels/telegram.js +2 -0
- package/harness/packages/server/src/lib/prompt.js +4 -1
- package/harness/packages/server/src/lib/questions.js +72 -0
- package/harness/packages/server/src/lib/scheduler.js +12 -1
- package/harness/packages/server/src/lib/tasks.js +3 -1
- package/harness/packages/server/src/routes/chat.js +31 -2
- package/harness/packages/server/src/routes/mcp.js +54 -2
- package/harness/packages/server/src/routes/preview.js +49 -28
- package/harness/packages/server/src/routes/providers.js +12 -0
- package/harness/packages/server/src/routes/sessions.js +3 -1
- package/harness/packages/server/src/routes/skills.js +48 -1
- package/harness/packages/server/src/routes/state.js +8 -1
- package/harness/packages/server/src/server.js +37 -1
- package/harness/packages/server/test/preview.test.js +111 -0
- package/harness/packages/server/test/server.test.js +186 -19
- package/harness/packages/server/test/telegram-features.test.js +2 -0
- package/harness/packages/server/test/telegram-voz.test.js +2 -0
- package/harness/packages/server/test/telegram.test.js +2 -0
- package/harness/tamnora.example.json +3 -1
- package/package.json +1 -1
|
@@ -1,58 +1,79 @@
|
|
|
1
1
|
import { startTunnel } from '../lib/core.js';
|
|
2
2
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
|
+
import { requireLocal, localOk } from '../lib/workspace.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
|
-
* Preview de un dev server para acceso remoto
|
|
6
|
-
*
|
|
7
|
-
* tunel
|
|
6
|
+
* Preview de un dev server para acceso remoto. Con `tailscale` publica el puerto
|
|
7
|
+
* en el tailnet (`tailscale serve --https=8443`, privado); con `tunnelmole` abre
|
|
8
|
+
* un tunel publico. El estado vive en memoria y se expone en `GET /api/preview`
|
|
9
|
+
* para que el cliente lo recupere tras una recarga. Devuelve `{ close }` para
|
|
10
|
+
* liberar el tunel al apagar.
|
|
8
11
|
*/
|
|
9
12
|
export function registerPreview(route, ctx) {
|
|
10
13
|
let tunnel = null;
|
|
11
14
|
|
|
15
|
+
const view = (t) => (t ? { url: t.url, port: t.port, provider: t.provider } : null);
|
|
16
|
+
|
|
17
|
+
const stopCurrent = () => {
|
|
18
|
+
try {
|
|
19
|
+
tunnel?.stop?.();
|
|
20
|
+
} catch {
|
|
21
|
+
// ignore
|
|
22
|
+
}
|
|
23
|
+
tunnel = null;
|
|
24
|
+
};
|
|
25
|
+
|
|
12
26
|
route('GET', '/api/preview', ({ res }) => {
|
|
13
|
-
sendJson(res, 200, { tunnel: tunnel
|
|
27
|
+
sendJson(res, 200, { tunnel: view(tunnel) });
|
|
14
28
|
});
|
|
15
29
|
|
|
16
30
|
route('POST', '/api/preview/tunnel', async ({ req, res }) => {
|
|
31
|
+
if (!ctx.authEnabled && !requireLocal(req, res, 'Abrir túneles solo está disponible en conexiones locales.')) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
17
34
|
const body = await readJson(req);
|
|
18
35
|
const port = Number(body.port);
|
|
19
36
|
if (!Number.isInteger(port) || port < 1 || port > 65535) return sendError(res, 400, 'Puerto inválido.');
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
// ignore
|
|
25
|
-
}
|
|
26
|
-
tunnel = null;
|
|
37
|
+
// Remoto autenticado: solo puertos de procesos que el server ya corre.
|
|
38
|
+
if (ctx.authEnabled && !localOk(req)) {
|
|
39
|
+
const known = ctx.procs?.list?.().some((proc) => proc.running && proc.port === port);
|
|
40
|
+
if (!known) return sendError(res, 403, 'Solo podés exponer puertos de procesos activos.');
|
|
27
41
|
}
|
|
28
|
-
|
|
42
|
+
|
|
43
|
+
const preview = ctx.config.preview || {};
|
|
44
|
+
const provider = String(body.provider || preview.provider || 'tailscale').toLowerCase();
|
|
45
|
+
const httpsPort = Number(preview.tailscalePort) || 8443;
|
|
46
|
+
|
|
47
|
+
stopCurrent();
|
|
48
|
+
let started;
|
|
29
49
|
try {
|
|
30
|
-
|
|
31
|
-
tunnel = { url: started.url, port, stop: started.stop };
|
|
32
|
-
sendJson(res, 200, { tunnel: { url: tunnel.url, port } });
|
|
50
|
+
started = await startTunnel(port, provider, { preview: true, httpsPort });
|
|
33
51
|
} catch (error) {
|
|
34
|
-
|
|
52
|
+
if (provider !== 'tunnelmole' && preview.fallback !== false) {
|
|
53
|
+
try {
|
|
54
|
+
started = await startTunnel(port, 'tunnelmole');
|
|
55
|
+
} catch (fallbackError) {
|
|
56
|
+
return sendError(res, 502, `No se pudo abrir el túnel: ${fallbackError.message}`);
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
return sendError(res, 502, `No se pudo abrir el túnel: ${error.message}`);
|
|
60
|
+
}
|
|
35
61
|
}
|
|
62
|
+
tunnel = { url: started.url, port, provider: started.provider, stop: started.stop };
|
|
63
|
+
sendJson(res, 200, { tunnel: view(tunnel) });
|
|
36
64
|
});
|
|
37
65
|
|
|
38
|
-
route('DELETE', '/api/preview/tunnel', ({ res }) => {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
} catch {
|
|
42
|
-
// ignore
|
|
66
|
+
route('DELETE', '/api/preview/tunnel', ({ req, res }) => {
|
|
67
|
+
if (!ctx.authEnabled && !requireLocal(req, res, 'Cerrar túneles solo está disponible en conexiones locales.')) {
|
|
68
|
+
return;
|
|
43
69
|
}
|
|
44
|
-
|
|
70
|
+
stopCurrent();
|
|
45
71
|
sendJson(res, 200, { ok: true });
|
|
46
72
|
});
|
|
47
73
|
|
|
48
74
|
return {
|
|
49
75
|
close() {
|
|
50
|
-
|
|
51
|
-
tunnel?.stop?.();
|
|
52
|
-
} catch {
|
|
53
|
-
// ignore
|
|
54
|
-
}
|
|
55
|
-
tunnel = null;
|
|
76
|
+
stopCurrent();
|
|
56
77
|
},
|
|
57
78
|
};
|
|
58
79
|
}
|
|
@@ -78,6 +78,8 @@ export function registerProviders(route, ctx) {
|
|
|
78
78
|
maxOutputTokens: ctx.effectiveMaxOutputTokens(),
|
|
79
79
|
defaultCwd: ctx.store.getSetting('defaultCwd') || '',
|
|
80
80
|
strictScope: ctx.effectiveStrictScope(),
|
|
81
|
+
mcpMode: ctx.effectiveMcpMode(),
|
|
82
|
+
mcpPinned: ctx.effectiveMcpPinned(),
|
|
81
83
|
});
|
|
82
84
|
|
|
83
85
|
route('GET', '/api/settings', ({ res }) => {
|
|
@@ -91,6 +93,16 @@ export function registerProviders(route, ctx) {
|
|
|
91
93
|
if (body.mode !== undefined) ctx.store.setSetting('activeMode', resolveMode(String(body.mode)).id);
|
|
92
94
|
if (body.defaultCwd !== undefined) ctx.store.setSetting('defaultCwd', String(body.defaultCwd ?? '').trim() || null);
|
|
93
95
|
if (body.strictScope !== undefined) ctx.store.setSetting('strictScope', Boolean(body.strictScope));
|
|
96
|
+
if (body.mcpMode !== undefined) {
|
|
97
|
+
const mode = String(body.mcpMode);
|
|
98
|
+
ctx.store.setSetting('mcpMode', ['auto', 'eager', 'lazy'].includes(mode) ? mode : 'auto');
|
|
99
|
+
}
|
|
100
|
+
if (body.mcpPinned !== undefined) {
|
|
101
|
+
const list = Array.isArray(body.mcpPinned)
|
|
102
|
+
? body.mcpPinned.map((v) => String(v).trim()).filter(Boolean)
|
|
103
|
+
: String(body.mcpPinned).split(/[\s,]+/).map((v) => v.trim()).filter(Boolean);
|
|
104
|
+
ctx.store.setSetting('mcpPinned', list);
|
|
105
|
+
}
|
|
94
106
|
for (const key of ['maxRounds', 'maxTokens', 'maxDurationMs', 'maxOutputTokens']) {
|
|
95
107
|
if (body[key] === undefined) continue;
|
|
96
108
|
const value = Number(body[key]);
|
|
@@ -6,7 +6,7 @@ import { rewindSession, checkpointTurns } from '../lib/checkpoints.js';
|
|
|
6
6
|
|
|
7
7
|
export function registerSessions(route, ctx) {
|
|
8
8
|
route('GET', '/api/sessions', ({ res }) => {
|
|
9
|
-
const awaiting = awaitingBySession(ctx.approvals.list());
|
|
9
|
+
const awaiting = awaitingBySession([...ctx.approvals.list(), ...ctx.questions.list()]);
|
|
10
10
|
const sessions = ctx.store.listSessions(50).map((session) => sessionState(session, ctx, awaiting.get(session.id) ?? 0));
|
|
11
11
|
sendJson(res, 200, { sessions });
|
|
12
12
|
});
|
|
@@ -68,6 +68,8 @@ export function registerSessions(route, ctx) {
|
|
|
68
68
|
// mensajes), asi que va aparte: sin esto, recargar la pagina dejaba el run
|
|
69
69
|
// esperando una decision que no se podia tomar.
|
|
70
70
|
permissions: ctx.approvals.list({ sessionId: params.id }),
|
|
71
|
+
// Idem para los formularios interactivos (`ask_user`).
|
|
72
|
+
questions: ctx.questions.list({ sessionId: params.id }),
|
|
71
73
|
});
|
|
72
74
|
});
|
|
73
75
|
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
homeDir,
|
|
4
|
+
parseSkillRef,
|
|
5
|
+
fetchSkillFromGithub,
|
|
6
|
+
searchSkillsGithub,
|
|
7
|
+
writeSkillFiles,
|
|
8
|
+
SKILLS_CATALOG,
|
|
9
|
+
skillCatalogEntry,
|
|
10
|
+
envValue,
|
|
11
|
+
} from '../lib/core.js';
|
|
3
12
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
4
13
|
import {
|
|
5
14
|
SKILL_ID_PATTERN,
|
|
15
|
+
allSkills,
|
|
6
16
|
skillsView,
|
|
7
17
|
findSkill,
|
|
8
18
|
writeSkill,
|
|
@@ -46,6 +56,43 @@ export function registerSkills(route, ctx) {
|
|
|
46
56
|
sendJson(res, 200, { skills: skillsView(ctx) });
|
|
47
57
|
});
|
|
48
58
|
|
|
59
|
+
route('GET', '/api/skills/catalog', ({ res }) => {
|
|
60
|
+
sendJson(res, 200, {
|
|
61
|
+
catalog: SKILLS_CATALOG.map((entry) => ({
|
|
62
|
+
id: entry.id,
|
|
63
|
+
name: entry.name,
|
|
64
|
+
description: entry.description ?? '',
|
|
65
|
+
when: entry.when ?? '',
|
|
66
|
+
installed: Boolean(findSkill(ctx, entry.id)),
|
|
67
|
+
})),
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
route('GET', '/api/skills/search', async ({ res, query }) => {
|
|
72
|
+
const q = String(query.get('q') ?? '').trim();
|
|
73
|
+
if (!q) return sendJson(res, 200, { results: [] });
|
|
74
|
+
let results;
|
|
75
|
+
try {
|
|
76
|
+
results = await searchSkillsGithub(q, { token: envValue(['GITHUB_TOKEN', 'GH_TOKEN']) });
|
|
77
|
+
} catch (error) {
|
|
78
|
+
return sendError(res, 502, `No pude buscar en GitHub: ${error.message}`);
|
|
79
|
+
}
|
|
80
|
+
const installed = new Set(allSkills(ctx.cwd).map((s) => s.id));
|
|
81
|
+
sendJson(res, 200, { results: results.map((r) => ({ ...r, installed: installed.has(r.slug || r.repo) })) });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/** Instala una skill del catalogo (queda deshabilitada hasta que la prendan). */
|
|
85
|
+
route('POST', '/api/skills', async ({ req, res }) => {
|
|
86
|
+
const body = await readJson(req);
|
|
87
|
+
const entry = skillCatalogEntry(String(body.id ?? ''));
|
|
88
|
+
if (!entry) return sendError(res, 404, 'Esa skill no está en el catálogo.');
|
|
89
|
+
const id = sanitizeId(entry.id);
|
|
90
|
+
if (!SKILL_ID_PATTERN.test(id)) return sendError(res, 500, 'Id de skill inválido.');
|
|
91
|
+
await writeSkill(id, { name: entry.name, description: entry.description, when: entry.when, body: entry.body });
|
|
92
|
+
setSkillEnabled(ctx.store, id, false);
|
|
93
|
+
sendJson(res, 200, { installed: id, skills: skillsView(ctx) });
|
|
94
|
+
});
|
|
95
|
+
|
|
49
96
|
route('GET', '/api/skills/:id', ({ res, params }) => {
|
|
50
97
|
const skill = findSkill(ctx, params.id);
|
|
51
98
|
if (!skill) return sendError(res, 404, 'Skill no encontrada.');
|
|
@@ -14,7 +14,8 @@ import { sessionState, awaitingBySession } from '../lib/sessionView.js';
|
|
|
14
14
|
export function registerState(route, ctx) {
|
|
15
15
|
route('GET', '/api/state', ({ res }) => {
|
|
16
16
|
const permissions = ctx.approvals.list();
|
|
17
|
-
const
|
|
17
|
+
const questions = ctx.questions.list();
|
|
18
|
+
const awaiting = awaitingBySession([...permissions, ...questions]);
|
|
18
19
|
const sessions = ctx.store.listSessions(50).map((session) => sessionState(session, ctx, awaiting.get(session.id) ?? 0));
|
|
19
20
|
sendJson(res, 200, {
|
|
20
21
|
sessions,
|
|
@@ -25,6 +26,12 @@ export function registerState(route, ctx) {
|
|
|
25
26
|
args,
|
|
26
27
|
requestedAt,
|
|
27
28
|
})),
|
|
29
|
+
questions: questions.map(({ askId, sessionId, title, requestedAt }) => ({
|
|
30
|
+
askId,
|
|
31
|
+
sessionId,
|
|
32
|
+
title,
|
|
33
|
+
requestedAt,
|
|
34
|
+
})),
|
|
28
35
|
// Para que el cliente pueda detectar que el server va en otro reloj.
|
|
29
36
|
now: Date.now(),
|
|
30
37
|
});
|
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
resolveApiKey,
|
|
13
13
|
composeProviderSettings,
|
|
14
14
|
buildMcpRegistry,
|
|
15
|
+
buildMcpIndex,
|
|
16
|
+
useLazyMcp,
|
|
17
|
+
MCP_AUTO_THRESHOLD,
|
|
15
18
|
readPackageVersion,
|
|
16
19
|
scopeDirective,
|
|
17
20
|
} from './lib/core.js';
|
|
@@ -20,6 +23,7 @@ import { createPromptBuilder, DEFAULT_SYSTEM_PROMPT } from './lib/prompt.js';
|
|
|
20
23
|
import { profilePrompt, resolveProfile } from './lib/profile.js';
|
|
21
24
|
import { skillsBlock } from './lib/skills.js';
|
|
22
25
|
import { createApprovalBroker } from './lib/approvals.js';
|
|
26
|
+
import { createQuestionBroker } from './lib/questions.js';
|
|
23
27
|
import { createPresence } from './lib/presence.js';
|
|
24
28
|
import { createRunManager } from './lib/runManager.js';
|
|
25
29
|
import { createProcManager } from './lib/procs.js';
|
|
@@ -91,6 +95,7 @@ export async function createTamnoraServer({
|
|
|
91
95
|
const authEnabled = Boolean(app.user && app.user.password);
|
|
92
96
|
const store = new Store(dbPath ?? databasePath(cwd));
|
|
93
97
|
const approvals = createApprovalBroker({ timeoutMs: config.approvalTimeoutMs });
|
|
98
|
+
const questions = createQuestionBroker({ timeoutMs: config.questionTimeoutMs });
|
|
94
99
|
const presence = createPresence();
|
|
95
100
|
const runs = createRunManager({ store });
|
|
96
101
|
const procs = createProcManager({ allow: config.processes?.allow });
|
|
@@ -122,11 +127,17 @@ export async function createTamnoraServer({
|
|
|
122
127
|
};
|
|
123
128
|
|
|
124
129
|
let mcpPromise = null;
|
|
130
|
+
let mcpMeta = null;
|
|
125
131
|
const getMcp = async () => {
|
|
126
132
|
if (!loadMcp) return null;
|
|
127
133
|
const servers = mergedMcpServers().filter((s) => s.enabled !== false);
|
|
128
134
|
if (!servers.length) return null;
|
|
129
|
-
if (!mcpPromise)
|
|
135
|
+
if (!mcpPromise) {
|
|
136
|
+
mcpPromise = buildMcpRegistry(servers).then((registry) => {
|
|
137
|
+
mcpMeta = { total: registry.definitions.length, index: buildMcpIndex(registry.tools ?? []) };
|
|
138
|
+
return registry;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
130
141
|
return mcpPromise;
|
|
131
142
|
};
|
|
132
143
|
|
|
@@ -138,6 +149,7 @@ export async function createTamnoraServer({
|
|
|
138
149
|
// ignore MCP shutdown errors
|
|
139
150
|
}
|
|
140
151
|
mcpPromise = null;
|
|
152
|
+
mcpMeta = null;
|
|
141
153
|
};
|
|
142
154
|
|
|
143
155
|
const customProviderDefs = () =>
|
|
@@ -168,6 +180,24 @@ export async function createTamnoraServer({
|
|
|
168
180
|
const effectiveMaxDurationMs = () => settingNumber('maxDurationMs', config.maxDurationMs);
|
|
169
181
|
const effectiveMaxOutputTokens = () => settingNumber('maxOutputTokens', config.maxOutputTokens);
|
|
170
182
|
const effectiveStrictScope = () => store.getSetting('strictScope') === true;
|
|
183
|
+
const MCP_MODES = new Set(['auto', 'eager', 'lazy']);
|
|
184
|
+
const effectiveMcpMode = () => {
|
|
185
|
+
const value = String(store.getSetting('mcpMode') ?? config.mcpMode ?? 'auto');
|
|
186
|
+
return MCP_MODES.has(value) ? value : 'auto';
|
|
187
|
+
};
|
|
188
|
+
const effectiveMcpPinned = () => {
|
|
189
|
+
const stored = store.getSetting('mcpPinned');
|
|
190
|
+
if (Array.isArray(stored)) return stored.map(String);
|
|
191
|
+
return Array.isArray(config.mcpPinned) ? config.mcpPinned.map(String) : [];
|
|
192
|
+
};
|
|
193
|
+
// Indice de tools MCP (para carga diferida con `mcp_call`): solo si el modo
|
|
194
|
+
// es diferido y el registro ya se conecto. Se rearma con cada `reloadMcp`.
|
|
195
|
+
const mcpText = () => {
|
|
196
|
+
if (!mcpMeta?.index) return '';
|
|
197
|
+
const total = mcpMeta.total;
|
|
198
|
+
if (!useLazyMcp(effectiveMcpMode(), total, MCP_AUTO_THRESHOLD)) return '';
|
|
199
|
+
return mcpMeta.index;
|
|
200
|
+
};
|
|
171
201
|
|
|
172
202
|
const ctx = {
|
|
173
203
|
cwd,
|
|
@@ -176,6 +206,7 @@ export async function createTamnoraServer({
|
|
|
176
206
|
authEnabled,
|
|
177
207
|
store,
|
|
178
208
|
approvals,
|
|
209
|
+
questions,
|
|
179
210
|
presence,
|
|
180
211
|
runs,
|
|
181
212
|
isAutoApprove,
|
|
@@ -189,6 +220,9 @@ export async function createTamnoraServer({
|
|
|
189
220
|
effectiveMaxDurationMs,
|
|
190
221
|
effectiveMaxOutputTokens,
|
|
191
222
|
effectiveStrictScope,
|
|
223
|
+
effectiveMcpMode,
|
|
224
|
+
effectiveMcpPinned,
|
|
225
|
+
mcpText,
|
|
192
226
|
providerSettings,
|
|
193
227
|
customProviderDefs,
|
|
194
228
|
apiKeyFor: (id) => resolveApiKey(providerSettings(id).apiKey, process.env),
|
|
@@ -212,6 +246,7 @@ export async function createTamnoraServer({
|
|
|
212
246
|
files: config.instructions,
|
|
213
247
|
profileText: () => profilePrompt(resolveProfile(ctx.app)),
|
|
214
248
|
skillsText: () => skillsBlock(ctx),
|
|
249
|
+
mcpText: () => ctx.mcpText(),
|
|
215
250
|
scopeText: () => scopeDirective(ctx.effectiveStrictScope()),
|
|
216
251
|
ruleFiles: config.rules ?? [],
|
|
217
252
|
memory: config.memory?.enabled !== false,
|
|
@@ -377,6 +412,7 @@ export async function createTamnoraServer({
|
|
|
377
412
|
|
|
378
413
|
const close = async () => {
|
|
379
414
|
approvals.cancelAll();
|
|
415
|
+
questions.cancelAll();
|
|
380
416
|
runs.cancelAll();
|
|
381
417
|
ctx.scheduler?.stop();
|
|
382
418
|
channels.stop();
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, writeFile, unlink } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { createTamnoraServer } from '../src/index.js';
|
|
7
|
+
import { setHome, bridgeStatePath } from '../../core/src/index.js';
|
|
8
|
+
|
|
9
|
+
async function startServer() {
|
|
10
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-preview-'));
|
|
11
|
+
setHome(cwd);
|
|
12
|
+
// provider "none": la ruta no lanza un tunel real en los tests.
|
|
13
|
+
await writeFile(path.join(cwd, 'tamnora.json'), JSON.stringify({ preview: { provider: 'none' } }));
|
|
14
|
+
const instance = await createTamnoraServer({
|
|
15
|
+
cwd,
|
|
16
|
+
dbPath: path.join(cwd, 'tamnora.db'),
|
|
17
|
+
loadMcp: false,
|
|
18
|
+
telegram: false,
|
|
19
|
+
});
|
|
20
|
+
const info = await instance.listen({ port: 0 });
|
|
21
|
+
return { instance, base: info.url, cwd };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Deja un bridge.json apuntando a un tunel publico vivo (este proceso). */
|
|
25
|
+
async function fakePublicTunnel() {
|
|
26
|
+
await writeFile(
|
|
27
|
+
bridgeStatePath(),
|
|
28
|
+
`${JSON.stringify({ pid: process.pid, url: 'https://fake.tunnelmole.net', provider: 'tunnelmole', port: 4517 })}\n`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function clearTunnel() {
|
|
33
|
+
try {
|
|
34
|
+
await unlink(bridgeStatePath());
|
|
35
|
+
} catch {
|
|
36
|
+
/* nada */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('GET /api/preview arranca sin tunel', async () => {
|
|
41
|
+
const { instance, base } = await startServer();
|
|
42
|
+
try {
|
|
43
|
+
const data = await (await fetch(`${base}/api/preview`)).json();
|
|
44
|
+
assert.equal(data.tunnel, null);
|
|
45
|
+
} finally {
|
|
46
|
+
await clearTunnel();
|
|
47
|
+
await instance.close();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('POST/GET/DELETE /api/preview/tunnel mantiene el estado', async () => {
|
|
52
|
+
const { instance, base } = await startServer();
|
|
53
|
+
try {
|
|
54
|
+
const post = await fetch(`${base}/api/preview/tunnel`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'Content-Type': 'application/json' },
|
|
57
|
+
body: JSON.stringify({ port: 8080 }),
|
|
58
|
+
});
|
|
59
|
+
assert.equal(post.status, 200);
|
|
60
|
+
const created = await post.json();
|
|
61
|
+
assert.equal(created.tunnel.port, 8080);
|
|
62
|
+
assert.equal(created.tunnel.provider, 'none');
|
|
63
|
+
|
|
64
|
+
const got = await (await fetch(`${base}/api/preview`)).json();
|
|
65
|
+
assert.equal(got.tunnel.port, 8080);
|
|
66
|
+
|
|
67
|
+
const del = await fetch(`${base}/api/preview/tunnel`, { method: 'DELETE' });
|
|
68
|
+
assert.equal(del.status, 200);
|
|
69
|
+
const after = await (await fetch(`${base}/api/preview`)).json();
|
|
70
|
+
assert.equal(after.tunnel, null);
|
|
71
|
+
} finally {
|
|
72
|
+
await clearTunnel();
|
|
73
|
+
await instance.close();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('POST /api/preview/tunnel rechaza un puerto invalido', async () => {
|
|
78
|
+
const { instance, base } = await startServer();
|
|
79
|
+
try {
|
|
80
|
+
const post = await fetch(`${base}/api/preview/tunnel`, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'Content-Type': 'application/json' },
|
|
83
|
+
body: JSON.stringify({ port: 0 }),
|
|
84
|
+
});
|
|
85
|
+
assert.equal(post.status, 400);
|
|
86
|
+
} finally {
|
|
87
|
+
await clearTunnel();
|
|
88
|
+
await instance.close();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('con un tunel publico activo el preview queda cerrado para no locales', async () => {
|
|
93
|
+
const { instance, base } = await startServer();
|
|
94
|
+
try {
|
|
95
|
+
await fakePublicTunnel();
|
|
96
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
97
|
+
|
|
98
|
+
const post = await fetch(`${base}/api/preview/tunnel`, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'Content-Type': 'application/json' },
|
|
101
|
+
body: JSON.stringify({ port: 8080 }),
|
|
102
|
+
});
|
|
103
|
+
assert.equal(post.status, 403);
|
|
104
|
+
|
|
105
|
+
const del = await fetch(`${base}/api/preview/tunnel`, { method: 'DELETE' });
|
|
106
|
+
assert.equal(del.status, 403);
|
|
107
|
+
} finally {
|
|
108
|
+
await clearTunnel();
|
|
109
|
+
await instance.close();
|
|
110
|
+
}
|
|
111
|
+
});
|