@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +37 -3
  3. package/SECURITY.md +8 -0
  4. package/harness/packages/cli/src/commands/mcp.js +31 -4
  5. package/harness/packages/cli/src/commands/repl.js +7 -2
  6. package/harness/packages/cli/src/commands/run.js +5 -2
  7. package/harness/packages/cli/src/commands/tui.js +9 -2
  8. package/harness/packages/cli/src/lib/runtime.js +23 -1
  9. package/harness/packages/cli/test/tunnel.test.js +13 -1
  10. package/harness/packages/core/src/agent/modes.js +3 -0
  11. package/harness/packages/core/src/config/index.js +3 -1
  12. package/harness/packages/core/src/index.js +15 -1
  13. package/harness/packages/core/src/mcp/catalog.js +79 -0
  14. package/harness/packages/core/src/mcp/index.js +14 -2
  15. package/harness/packages/core/src/mcp/registry.js +29 -1
  16. package/harness/packages/core/src/redact.js +25 -0
  17. package/harness/packages/core/src/skills/catalog.js +78 -0
  18. package/harness/packages/core/src/skills/remote.js +51 -0
  19. package/harness/packages/core/src/tools/executors.js +93 -2
  20. package/harness/packages/core/src/tools/index.js +27 -6
  21. package/harness/packages/core/src/tools/labels.js +2 -0
  22. package/harness/packages/core/src/tools/mcp.js +91 -0
  23. package/harness/packages/core/src/tools/permissions.js +53 -8
  24. package/harness/packages/core/src/tools/registry.js +14 -7
  25. package/harness/packages/core/src/tools/schema.js +86 -0
  26. package/harness/packages/core/src/tts/edge.js +28 -7
  27. package/harness/packages/core/src/tunnel.js +51 -5
  28. package/harness/packages/core/test/compact.test.js +2 -0
  29. package/harness/packages/core/test/labels.test.js +1 -0
  30. package/harness/packages/core/test/mcp-registry.test.js +37 -1
  31. package/harness/packages/core/test/mcp-routing.test.js +115 -0
  32. package/harness/packages/core/test/redact.test.js +22 -0
  33. package/harness/packages/core/test/skills.test.js +11 -0
  34. package/harness/packages/core/test/tools.test.js +97 -3
  35. package/harness/packages/server/public/app.js +501 -23
  36. package/harness/packages/server/public/index.html +41 -2
  37. package/harness/packages/server/public/styles.css +99 -3
  38. package/harness/packages/server/src/lib/agentTools.js +45 -3
  39. package/harness/packages/server/src/lib/channels/telegram.js +2 -0
  40. package/harness/packages/server/src/lib/prompt.js +4 -1
  41. package/harness/packages/server/src/lib/questions.js +72 -0
  42. package/harness/packages/server/src/lib/scheduler.js +12 -1
  43. package/harness/packages/server/src/lib/tasks.js +3 -1
  44. package/harness/packages/server/src/routes/chat.js +31 -2
  45. package/harness/packages/server/src/routes/mcp.js +54 -2
  46. package/harness/packages/server/src/routes/preview.js +49 -28
  47. package/harness/packages/server/src/routes/providers.js +12 -0
  48. package/harness/packages/server/src/routes/sessions.js +3 -1
  49. package/harness/packages/server/src/routes/skills.js +48 -1
  50. package/harness/packages/server/src/routes/state.js +8 -1
  51. package/harness/packages/server/src/server.js +37 -1
  52. package/harness/packages/server/test/preview.test.js +111 -0
  53. package/harness/packages/server/test/server.test.js +186 -19
  54. package/harness/packages/server/test/telegram-features.test.js +2 -0
  55. package/harness/packages/server/test/telegram-voz.test.js +2 -0
  56. package/harness/packages/server/test/telegram.test.js +2 -0
  57. package/harness/tamnora.example.json +3 -1
  58. 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: abre un tunel aparte al puerto
6
- * del proceso (asi el celular puede embeber la app aunque este detras del
7
- * tunel del harness). Devuelve `{ close }` para liberar el tunel al apagar.
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 ? { url: tunnel.url, port: tunnel.port } : null });
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
- if (tunnel) {
21
- try {
22
- tunnel.stop?.();
23
- } catch {
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
- const provider = ctx.config.preview?.provider || 'tunnelmole';
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
- const started = await startTunnel(port, provider);
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
- sendError(res, 502, `No se pudo abrir el túnel: ${error.message}`);
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
- try {
40
- tunnel?.stop?.();
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
- tunnel = null;
70
+ stopCurrent();
45
71
  sendJson(res, 200, { ok: true });
46
72
  });
47
73
 
48
74
  return {
49
75
  close() {
50
- try {
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 { homeDir, parseSkillRef, fetchSkillFromGithub, writeSkillFiles } from '../lib/core.js';
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 awaiting = awaitingBySession(permissions);
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) mcpPromise = buildMcpRegistry(servers);
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
+ });