@danieltmn/tamnora 0.10.0 → 0.11.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 CHANGED
@@ -5,6 +5,17 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.11.0] - 2026-10-09
9
+
10
+ ### Quitado
11
+
12
+ - **Tuneles publicos retirados**: `tamnora bridge` ahora soporta unicamente
13
+ **tailscale**. Se elimino el relay propio (`relay/index.php`, el deploy FTPS
14
+ `scripts/deploy-relay.mjs` y el transporte `relayClient.js` de la consola) y el
15
+ proveedor `tunnelmole` dejo de ofrecerse para el acceso remoto (se conserva solo
16
+ para el feature de `preview`). Un `app.json` con `tunnel.provider` en
17
+ `tunnelmole`/`relay` se migra a `tailscale` al leerlo.
18
+
8
19
  ## [0.10.0] - 2026-10-09
9
20
 
10
21
  ### Agregado
package/README.md CHANGED
@@ -144,11 +144,10 @@ corre en modo local sin login. Al cambiar el usuario se detiene el server
144
144
 
145
145
  ### Acceso remoto
146
146
 
147
- La via recomendada es **Tailscale** (VPN mesh privada, rapida y sin exponer nada
148
- a Internet). El server sigue escuchando en `127.0.0.1`; `tailscale serve`
149
- publica HTTPS dentro del tailnet y reenvia por loopback, asi que la consola y
150
- todas las tools funcionan igual que en local, sin sincronizar datos (una sola
151
- base SQLite).
147
+ El acceso remoto usa **Tailscale** (VPN mesh privada, rapida y sin exponer nada a
148
+ Internet). El server sigue escuchando en `127.0.0.1`; `tailscale serve` publica
149
+ HTTPS dentro del tailnet y reenvia por loopback, asi que la consola y todas las
150
+ tools funcionan igual que en local, sin sincronizar datos (una sola base SQLite).
152
151
 
153
152
  ```bash
154
153
  # 1. Instalar Tailscale en esta PC y en el dispositivo remoto (misma cuenta)
@@ -156,7 +155,7 @@ base SQLite).
156
155
  tamnora user --name yo --password unaClaveLarga
157
156
 
158
157
  # 2. Server + acceso remoto
159
- tamnora bridge --start # proveedor default: tailscale (URL + QR)
158
+ tamnora bridge --start # tailscale serve (URL + QR)
160
159
  tamnora bridge --status # https://<pc>.<tailnet>.ts.net
161
160
  tamnora bridge --stop # tailscale serve reset
162
161
  ```
@@ -165,14 +164,8 @@ tamnora bridge --stop # tailscale serve reset
165
164
  Sigue protegido por el login del server: configura un usuario antes de exponerlo
166
165
  (ver [`SECURITY.md`](SECURITY.md)).
167
166
 
168
- Proveedores **legacy** (tuneles publicos; utiles solo si en el dispositivo remoto
169
- no podes instalar Tailscale). **Se retiran en la proxima version**; `tunnelmole`
170
- se conservara unicamente para el feature de *preview*:
171
-
172
- ```bash
173
- tamnora bridge --start --tunnel tunnelmole # tunel publico gratis, URL aleatoria
174
- tamnora bridge --start --tunnel relay # mailbox propio (harness.tamnora.com)
175
- ```
167
+ Los tuneles publicos (tunnelmole, relay) fueron **retirados**; `tunnelmole` se
168
+ conserva solo para el feature de *preview*.
176
169
 
177
170
  ### Telegram (canales de mensajeria)
178
171
 
package/SECURITY.md CHANGED
@@ -20,12 +20,10 @@ asi que se lo debe tratar como codigo con acceso a tu usuario.
20
20
  **usuario** con contrasena fuerte (defensa en profundidad). Como entra por
21
21
  loopback, una vez autenticado se alcanza toda la consola y las tools
22
22
  (explorador de archivos, shell, git): tratalo como acceso completo a tu PC.
23
- - **Tuneles publicos (legacy: tunnelmole / relay, se retiran en la proxima
24
- version)**: la URL es accesible desde Internet. Configura un **usuario** con
25
- **contrasena fuerte**; nunca expongas un server sin login. El tunnel reenvia
26
- por loopback, de modo que una vez autenticado se alcanza toda la consola:
27
- tratalo como acceso completo a tu maquina. Cierra el tunnel (`tamnora bridge
28
- --stop`) cuando no lo uses.
23
+ - **Tuneles publicos (retirados)**: las versiones anteriores usaban tunnelmole y
24
+ un relay propio para exponer la consola desde Internet. Se retiraron; el acceso
25
+ remoto hoy es solo por Tailscale. `tunnelmole` sobrevive unicamente para el
26
+ feature de *preview* de dev servers, que no expone la consola.
29
27
  - **Credenciales**: las API keys de proveedores se guardan en la base SQLite de
30
28
  la casa local (fuera del repo) **en texto plano**; protege el archivo con los
31
29
  permisos del sistema. Para no persistirlas, usa `"apiKey": "env:VAR"` y
@@ -72,7 +72,7 @@ Uso:
72
72
  tamnora mcp Listar servidores y herramientas MCP
73
73
  tamnora server [--init|--start|--reload|--stop|--status|--logs] Server en segundo plano (abre el navegador)
74
74
  tamnora user Usuario local (login del server)
75
- tamnora bridge [--start|--reload|--stop|--status] Tunel publico (tunnelmole) + QR
75
+ tamnora bridge [--start|--reload|--stop|--status] Acceso remoto con Tailscale + QR
76
76
  tamnora autostart install|remove Arranque automatico del server
77
77
  tamnora serve Consola web local (primer plano)
78
78
  tamnora doctor Verificar Node, casa, puerto y base
@@ -1,11 +1,4 @@
1
- import {
2
- homeDir,
3
- ensureHome,
4
- ensureApp,
5
- writeApp,
6
- readApp,
7
- randomToken,
8
- } from '../../../core/src/index.js';
1
+ import { homeDir, ensureHome, ensureApp } from '../../../core/src/index.js';
9
2
  import { parse, num } from '../lib/util.js';
10
3
  import {
11
4
  applyHomeOverride,
@@ -21,17 +14,6 @@ import {
21
14
  import { startTunnel, probeTailscale, resetTailscaleServe } from '../lib/tunnel.js';
22
15
  import { qrTerminal } from '../lib/qr.js';
23
16
 
24
- // Tuneles publicos legacy: se retiran en la proxima version. `tunnelmole` se
25
- // conserva solo para el feature de `preview` (no como proveedor de `bridge`).
26
- const LEGACY_PROVIDERS = new Set(['tunnelmole', 'tmole', 'relay']);
27
-
28
- function warnLegacy(provider) {
29
- const p = String(provider).toLowerCase();
30
- if (!LEGACY_PROVIDERS.has(p)) return;
31
- console.log(`Aviso: "${p}" es legacy (tunel publico) y se retirara en la proxima version.`);
32
- console.log('El camino recomendado es --tunnel tailscale (VPN privada).');
33
- }
34
-
35
17
  const OPTIONS = {
36
18
  start: { type: 'boolean' },
37
19
  reload: { type: 'boolean' },
@@ -51,6 +33,8 @@ const readBridgeState = () => bridgeState.read();
51
33
  const writeBridgeState = (data) => bridgeState.write(data);
52
34
  const clearBridgeState = () => bridgeState.clear();
53
35
 
36
+ const isTailscale = (provider) => provider === 'tailscale' || provider === 'ts';
37
+
54
38
  export async function bridgeCommand(argv) {
55
39
  const { values } = parse(argv, OPTIONS);
56
40
  if (values.help) {
@@ -73,61 +57,42 @@ export async function bridgeCommand(argv) {
73
57
  function resolve(values) {
74
58
  ensureHome();
75
59
  const app = ensureApp();
60
+ const requested = values.tunnel ? String(values.tunnel).toLowerCase() : '';
76
61
  return {
77
62
  port: num(values.port, app.port || 4517),
78
- provider: String(values.tunnel || app.tunnel?.provider || 'tailscale').toLowerCase(),
63
+ provider: requested || String(app.tunnel?.provider || 'tailscale').toLowerCase(),
79
64
  app,
80
65
  };
81
66
  }
82
67
 
83
- /** Asegura la configuracion del relay (url + token) en app.json y la devuelve. */
84
- function ensureRelayConfig(app) {
85
- const tunnel = { ...app.tunnel };
86
- if (!tunnel.relayUrl) {
87
- tunnel.relayUrl = process.env.TAMNORA_RELAY_URL || 'https://harness.tamnora.com/index.php';
88
- }
89
- if (!tunnel.token) {
90
- tunnel.token = process.env.TAMNORA_RELAY_TOKEN || randomToken(24);
91
- const current = readApp();
92
- writeApp({ ...current, tunnel: { ...current.tunnel, token: tunnel.token, relayUrl: tunnel.relayUrl } });
93
- console.log(`Token de relay nuevo (guardado en app.json): ${tunnel.token}`);
94
- }
95
- return tunnel;
96
- }
97
-
98
68
  async function cmdStart(values) {
99
69
  const { port, provider } = resolve(values);
100
70
  if (provider === 'none') {
101
- console.error('El tunel esta desactivado (provider "none"). Usa --tunnel tailscale (recomendado) o --tunnel tunnelmole.');
71
+ console.error('El acceso remoto esta desactivado (provider "none"). Usa --tunnel tailscale.');
72
+ return 1;
73
+ }
74
+ if (!isTailscale(provider)) {
75
+ console.error(`Proveedor no soportado: "${provider}". El acceso remoto usa Tailscale.`);
76
+ console.error('Los tuneles publicos (tunnelmole/relay) fueron retirados.');
102
77
  return 1;
103
78
  }
104
79
 
105
- // Exponer el server a Internet sin usuario configurado deja /api/* abierto
106
- // (incluido /api/chat con tool bash): hay que pedirlo explicito.
80
+ // Por la red privada igual conviene un usuario: defensa en profundidad.
107
81
  const app = ensureApp();
108
82
  if (!app.user?.password && !values.insecure) {
109
- console.error('No hay usuario configurado: por el tunel cualquiera con la URL tendria acceso total.');
83
+ console.error('No hay usuario configurado: sin login cualquiera en tu tailnet tendria acceso total.');
110
84
  console.error('Crea uno con: tamnora user --name <usuario> --password <clave>');
111
85
  console.error('Si igual queres abrirlo sin proteccion: tamnora bridge --insecure');
112
86
  return 1;
113
87
  }
114
88
 
115
- if (provider === 'tailscale' || provider === 'ts') {
116
- try {
117
- const status = await probeTailscale();
118
- if (!status.connected) throw new Error('Tailscale no esta conectado. Inicia sesion con `tailscale up`.');
119
- } catch (error) {
120
- console.error(error.message);
121
- console.error('Instala Tailscale (https://tailscale.com/download) o usa --tunnel tunnelmole (legacy).');
122
- return 1;
123
- }
124
- } else {
125
- warnLegacy(provider);
126
- }
127
-
128
- if (provider === 'tunnelmole' || provider === 'tmole') {
129
- console.log('Aviso: tunnelmole gratuito admite una sola conexion; si abriste el');
130
- console.log('preview de la consola, cerralo (o el bridge) antes de usar el otro.');
89
+ try {
90
+ const status = await probeTailscale();
91
+ if (!status.connected) throw new Error('Tailscale no esta conectado. Inicia sesion con `tailscale up`.');
92
+ } catch (error) {
93
+ console.error(error.message);
94
+ console.error('Instala Tailscale: https://tailscale.com/download');
95
+ return 1;
131
96
  }
132
97
 
133
98
  const current = readBridgeState();
@@ -138,11 +103,11 @@ async function cmdStart(values) {
138
103
  }
139
104
 
140
105
  const bin = process.argv[1];
141
- const args = [bin, 'bridge', '--foreground', '--port', String(port), '--tunnel', provider];
106
+ const args = [bin, 'bridge', '--foreground', '--port', String(port)];
142
107
  if (process.env.TAMNORA_HOME) args.push('--home', process.env.TAMNORA_HOME);
143
108
  if (values.verbose) args.push('--verbose');
144
109
  const { child, isDead } = spawnDetached(args, { envFlag: 'TAMNORA_BRIDGE_CHILD' });
145
- console.log(`Abriendo acceso remoto (${provider}) en el puerto ${port}...`);
110
+ console.log(`Abriendo acceso remoto (tailscale) en el puerto ${port}...`);
146
111
 
147
112
  const state = await waitForState(() => readBridgeState(), {
148
113
  childPid: child.pid,
@@ -160,17 +125,16 @@ async function cmdStart(values) {
160
125
  return 0;
161
126
  }
162
127
 
163
- /** URL publica para el celular: la consola agregada al relay con el token. */
164
- function relayConsoleUrl(extra) {
165
- const base = String(extra.relayUrl ?? '').replace(/\/+$/, '').replace(/index\.php$/, '').replace(/\/$/, '');
166
- return `${base}/console/?t=${extra.token}`;
167
- }
168
-
169
128
  /**
170
129
  * Tailscale es configuracion persistente (`tailscale serve`), no un proceso:
171
130
  * no hay health-check ni bucle de reconexion, solo esperar la senal de apagado.
172
131
  */
173
- async function runTailscaleForeground(values, port) {
132
+ async function runForeground(values) {
133
+ const { port, provider } = resolve(values);
134
+ if (!isTailscale(provider)) {
135
+ console.error(`Proveedor no soportado: "${provider}". El acceso remoto usa Tailscale.`);
136
+ return 1;
137
+ }
174
138
  const log = createFileLogger(bridgeLogPath());
175
139
  let tun;
176
140
  try {
@@ -210,121 +174,9 @@ async function runTailscaleForeground(values, port) {
210
174
  return 0;
211
175
  }
212
176
 
213
- async function runForeground(values) {
214
- const { port, provider, app } = resolve(values);
215
- if (provider === 'tailscale' || provider === 'ts') return runTailscaleForeground(values, port);
216
- const log = createFileLogger(bridgeLogPath());
217
- const extra = { provider };
218
- if (provider === 'relay') {
219
- const relay = ensureRelayConfig(app);
220
- extra.relayUrl = relay.relayUrl;
221
- extra.token = relay.token;
222
- }
223
-
224
- // Senal de apagado: el supervisor de abajo la consulta entre intentos.
225
- let interrupted = false;
226
- let notifyInterrupt = null;
227
- const interruptPromise = new Promise((r) => {
228
- notifyInterrupt = r;
229
- });
230
- const shutdown = () => {
231
- interrupted = true;
232
- notifyInterrupt();
233
- };
234
- process.once('SIGINT', shutdown);
235
- process.once('SIGTERM', shutdown);
236
- const sleep = (ms) =>
237
- new Promise((r) => {
238
- const t = setTimeout(r, ms);
239
- interruptPromise.then(() => {
240
- clearTimeout(t);
241
- r();
242
- });
243
- });
244
-
245
- // Supervision: si el proceso del tunel muere (el servicio gratuito lo hace a
246
- // menudo), reconecta con backoff y reescribe el estado con la URL nueva.
247
- let attempt = 0;
248
- let tun;
249
- for (;;) {
250
- try {
251
- tun = await startTunnel(port, provider, { log, verbose: values.verbose, ...extra });
252
- } catch (err) {
253
- if (interrupted) break;
254
- log(`ERROR: ${err.message}`);
255
- return 1;
256
- }
257
- if (!tun.url) {
258
- log('ERROR: el tunel no devolvio URL.');
259
- return 1;
260
- }
261
-
262
- writeBridgeState({
263
- pid: process.pid,
264
- url: tun.url,
265
- publicUrl: provider === 'relay' ? relayConsoleUrl(extra) : undefined,
266
- provider: tun.provider,
267
- port,
268
- childPid: tun.pid,
269
- startedAt: new Date().toISOString(),
270
- });
271
- printStatus(readBridgeState(), true);
272
- log(`Tunel publico: ${tun.url}`);
273
- attempt = 0;
274
-
275
- const exitCode = await new Promise((r) => {
276
- // Health-check cada 60 s: si la URL publica deja de responder dos veces
277
- // seguidas, matamos el tunel y el supervisor abre otro.
278
- let fails = 0;
279
- const probe = async () => {
280
- const probeUrl = tun.probeUrl ?? `${tun.url}/api/health`;
281
- try {
282
- const res = await fetch(probeUrl, { signal: AbortSignal.timeout(8000) });
283
- if (res.ok) fails = 0;
284
- else fails += 1;
285
- } catch {
286
- fails += 1;
287
- }
288
- if (fails >= 2) {
289
- log(`La URL publica no responde (${tun.url}); reiniciando el tunel...`);
290
- tun.stop();
291
- }
292
- };
293
- const health = setInterval(probe, 60000);
294
- health.unref?.();
295
- tun.child?.once('exit', (code) => {
296
- clearInterval(health);
297
- r(code ?? 1);
298
- });
299
- if (!tun.child) {
300
- clearInterval(health);
301
- r(0);
302
- }
303
- interruptPromise.then(() => {
304
- clearInterval(health);
305
- r('signal');
306
- });
307
- });
308
-
309
- if (interrupted || exitCode === 'signal') break;
310
- attempt += 1;
311
- const wait = Math.min(30000, 2000 * attempt);
312
- log(`El tunel termino (codigo ${exitCode}); reconectando en ${Math.round(wait / 1000)} s...`);
313
- await sleep(wait);
314
- }
315
-
316
- try {
317
- tun?.stop?.();
318
- } catch {
319
- /* nada */
320
- }
321
- clearBridgeState();
322
- return 0;
323
- }
324
-
325
177
  async function cmdReload(values) {
326
178
  // Leer el estado ANTES de parar: clearBridgeState borra el archivo y despues
327
- // no habria nada que esperar (el tunel viejo seguia muriendo).
179
+ // no habria nada que esperar.
328
180
  const previous = readBridgeState();
329
181
  await cmdStop();
330
182
  for (let i = 0; i < 25 && previous?.pid && pidAlive(previous.pid); i++) {
@@ -336,29 +188,22 @@ async function cmdReload(values) {
336
188
  async function cmdStop() {
337
189
  const state = readBridgeState();
338
190
  const stopped = [];
339
- if (state?.provider === 'tailscale') {
340
- try {
341
- await resetTailscaleServe();
342
- stopped.push('tailscale serve');
343
- } catch (error) {
344
- console.log(`No pude ejecutar tailscale serve reset: ${error.message}`);
345
- }
191
+ if (!state) {
192
+ console.log('El bridge no esta corriendo.');
193
+ return 0;
194
+ }
195
+ try {
196
+ await resetTailscaleServe();
197
+ stopped.push('tailscale serve');
198
+ } catch (error) {
199
+ console.log(`No pude ejecutar tailscale serve reset: ${error.message}`);
346
200
  }
347
- // Mata el supervisor y tambien el proceso del tunel (aunque el supervisor
348
- // haya muerto y haya quedado huerfano).
349
- if (state?.pid && pidAlive(state.pid)) {
201
+ // Mata el supervisor si quedo vivo.
202
+ if (state.pid && pidAlive(state.pid)) {
350
203
  killPid(state.pid);
351
204
  stopped.push(`bridge ${state.pid}`);
352
205
  }
353
- if (state?.childPid && pidAlive(state.childPid)) {
354
- killPid(state.childPid);
355
- stopped.push(`tunel ${state.childPid}`);
356
- }
357
206
  clearBridgeState();
358
- if (!stopped.length) {
359
- console.log('El bridge no esta corriendo.');
360
- return 0;
361
- }
362
207
  console.log(`Bridge detenido (${stopped.join(', ')}).`);
363
208
  return 0;
364
209
  }
@@ -374,18 +219,17 @@ async function cmdStatus() {
374
219
  }
375
220
 
376
221
  function printStatus(state, withQr = false) {
377
- const shown = state.publicUrl || state.url;
378
222
  console.log(`Bridge corriendo (pid ${state.pid})`);
379
- console.log(` url : ${shown}`);
223
+ console.log(` url : ${state.url}`);
380
224
  console.log(` proveedor: ${state.provider} puerto local: ${state.port}`);
381
- if (state.provider === 'tailscale') console.log(' red : privada (tailnet; solo tus dispositivos)');
225
+ console.log(' red : privada (tailnet; solo tus dispositivos)');
382
226
  console.log(` casa : ${homeDir()}`);
383
227
  if (withQr && state.url) {
384
- const art = qrTerminal(shown, 2);
228
+ const art = qrTerminal(state.url, 2);
385
229
  if (art) {
386
230
  console.log('');
387
231
  console.log(art);
388
- console.log(` escanea para abrir ${shown}`);
232
+ console.log(` escanea para abrir ${state.url}`);
389
233
  }
390
234
  }
391
235
  }
@@ -393,23 +237,20 @@ function printStatus(state, withQr = false) {
393
237
  function printHelp() {
394
238
  console.log(`Uso: tamnora bridge [accion] [opciones]
395
239
 
396
- Da acceso remoto al server local y muestra la URL + QR.
240
+ Da acceso remoto al server local con Tailscale (VPN privada) y muestra la URL + QR.
397
241
 
398
242
  Acciones:
399
243
  --start Abre el acceso en segundo plano + QR (default)
400
244
  --reload Reinicia
401
- --stop Cierra
245
+ --stop Cierra (tailscale serve reset)
402
246
  --status Estado y URL
403
247
  --foreground Corre en primer plano (debug)
404
248
 
405
249
  Opciones:
406
- --tunnel <prov> Proveedor (default: el de app.json; hoy: tailscale)
407
- tailscale VPN privada via \`tailscale serve\` (recomendado)
408
- tunnelmole tunel publico gratis, URL aleatoria (legacy*)
409
- relay mailbox propio en harness.tamnora.com (legacy*)
410
-
411
- Tailscale requiere instalarlo y estar logueado (https://tailscale.com/download).
250
+ --port <n> Puerto local a exponer (default: el del server)
251
+ --home <path> Casa de Tamnora (default: TAMNORA_HOME o datos del sistema)
252
+ --insecure Permitir el acceso sin usuario configurado (peligroso)
412
253
 
413
- * Los tuneles publicos (tunnelmole/relay) se retiran en la proxima version.
414
- tunnelmole se conserva solo para el feature de "preview".`);
254
+ Requiere Tailscale instalado y logueado (https://tailscale.com/download) en la PC
255
+ y en el dispositivo remoto.`);
415
256
  }
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { parseTunnelmoleUrls, parseTailscaleStatus } from '../src/lib/tunnel.js';
3
+ import { parseTunnelmoleUrls, parseTailscaleStatus, startTunnel } from '../src/lib/tunnel.js';
4
4
  import { qrTerminal } from '../src/lib/qr.js';
5
5
 
6
6
  test('parseTunnelmoleUrls extracts the https URL', () => {
@@ -28,6 +28,10 @@ test('parseTailscaleStatus marca desconectado sin DNSName', () => {
28
28
  assert.equal(out.dnsName, '');
29
29
  });
30
30
 
31
+ test('startTunnel rechaza el proveedor retirado (relay)', async () => {
32
+ await assert.rejects(() => startTunnel(4517, 'relay'), /no soportado/);
33
+ });
34
+
31
35
  test('qrTerminal renders a block matrix for a URL', () => {
32
36
  const art = qrTerminal('https://abc-123.tunnelmole.net', 1);
33
37
  assert.ok(art.length > 0);
@@ -29,12 +29,10 @@ export function readApp() {
29
29
  }
30
30
  const app = { ...DEFAULT_APP, ...(data && typeof data === 'object' ? data : {}) };
31
31
  app.tunnel = { ...DEFAULT_APP.tunnel, ...(app.tunnel || {}) };
32
- // tunnelmole fue el default viejo y quedo persistido en instalaciones previas:
33
- // ahora es legacy, asi que se migra a tailscale (seguis pudiendo forzar
34
- // `--tunnel tunnelmole` en cada corrida).
35
- if (app.tunnel.provider === 'tunnelmole' || app.tunnel.provider === 'tmole') {
36
- app.tunnel.provider = 'tailscale';
37
- }
32
+ // `tunnelmole` fue el default viejo y `relay` fue un proveedor retirado: lo
33
+ // persistido en instalaciones previas se migra a tailscale.
34
+ const legacy = ['tunnelmole', 'tmole', 'relay'];
35
+ if (legacy.includes(app.tunnel.provider)) app.tunnel.provider = 'tailscale';
38
36
  return app;
39
37
  }
40
38
 
@@ -101,7 +101,6 @@ export {
101
101
  resetTailscaleServe,
102
102
  startTailscaleServe,
103
103
  startTunnelmole,
104
- startRelay,
105
104
  startTunnel,
106
105
  tunnelState,
107
106
  tunnelActive,
@@ -6,11 +6,7 @@ import { pidAlive, killPidTree } from './pid.js';
6
6
  /**
7
7
  * Acceso remoto. Proveedores:
8
8
  * - tailscale: VPN mesh privada + `tailscale serve` (recomendado; sin exposicion).
9
- * - tunnelmole: tunel publico gratis (URL aleatoria) — LEGACY.
10
- * - relay: mailbox HTTP propio (harness.tamnora.com) — LEGACY.
11
- *
12
- * Los tuneles publicos se retiran en la proxima version. `tunnelmole` se
13
- * conserva solo para el feature de `preview`; `startRelay` esta condenado.
9
+ * - tunnelmole: tunel publico gratis (URL aleatoria); solo para `preview`.
14
10
  */
15
11
 
16
12
  function killTree(child) {
@@ -116,7 +112,7 @@ function spawnTunnel(bin, args, opts = {}) {
116
112
  });
117
113
  }
118
114
 
119
- // LEGACY: proveedor legacy de `bridge`; se conserva para el feature `preview`.
115
+ // Tunel publico para el feature `preview` (no lo usa `bridge`).
120
116
  export function startTunnelmole(port, opts = {}) {
121
117
  return spawnTunnel('npx', ['--yes', 'tunnelmole', String(port)], {
122
118
  ...opts,
@@ -127,13 +123,6 @@ export function startTunnelmole(port, opts = {}) {
127
123
  });
128
124
  }
129
125
 
130
- /**
131
- * Relay propio (harness.tamnora.com): mailbox HTTP sobre cPanel (solo PHP).
132
- * El daemon no recibe conexiones: llama hacia afuera (a=poll), ejecuta cada
133
- * request contra el server local y empuja las respuestas (a=push).
134
- *
135
- * LEGACY / condenado: se retira en la proxima version (usar tailscale).
136
- */
137
126
  /**
138
127
  * Tailscale: VPN mesh (WireGuard). `tailscale serve` publica HTTPS dentro del
139
128
  * tailnet reenviando a loopback, asi que no hay exposicion publica y las
@@ -245,152 +234,11 @@ export function resetTailscaleServe() {
245
234
  return runTailscale(['serve', 'reset'], { timeoutMs: 30000 });
246
235
  }
247
236
 
248
- export function startRelay(port, opts = {}) {
249
- const relayUrl = String(opts.relayUrl ?? '').replace(/\/+$/, '');
250
- const token = String(opts.token ?? '');
251
- if (!relayUrl || !/^https?:\/\//.test(relayUrl)) {
252
- throw new Error('relay: falta tunnel.relayUrl (https://harness.tamnora.com/index.php)');
253
- }
254
- if (!token) throw new Error('relay: falta tunnel.token');
255
- const log = opts.log ?? (() => {});
256
- const local = `http://127.0.0.1:${port}`;
257
- const state = { stopped: false };
258
-
259
- const call = async (action, body = {}, { timeoutMs = 30000 } = {}) => {
260
- const res = await fetch(`${relayUrl}?a=${action}`, {
261
- method: 'POST',
262
- headers: { 'Content-Type': 'application/json' },
263
- body: JSON.stringify({ token, ...body }),
264
- signal: AbortSignal.timeout(timeoutMs),
265
- });
266
- if (!res.ok) throw new Error(`relay ${action}: HTTP ${res.status}`);
267
- return res.json();
268
- };
269
-
270
- async function push(rid, chunks) {
271
- await call('push', { rid, chunks }, { timeoutMs: 60000 });
272
- }
273
-
274
- async function handleRequest(row) {
275
- const envelope = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
276
- const method = envelope.method ?? 'GET';
277
- const path = String(envelope.path ?? '/');
278
- const target = `${local}${path.startsWith('/') ? path : `/${path}`}`;
279
- const init = { method, headers: { ...envelope.headers } };
280
- delete init.headers.host;
281
- if (envelope.body != null) init.body = envelope.body;
282
- const res = await fetch(target, init);
283
- const contentType = String(res.headers.get('content-type') ?? '');
284
- const flat = {};
285
- for (const header of ['content-type']) {
286
- const value = res.headers.get(header);
287
- if (value) flat[header] = value;
288
- }
289
- const cookies = res.headers.getSetCookie?.() ?? [];
290
- if (cookies.length) flat['set-cookie'] = cookies;
291
-
292
- if (!contentType.includes('text/event-stream')) {
293
- const buffer = Buffer.from(await res.arrayBuffer());
294
- await push(row.id, [
295
- {
296
- status: res.status,
297
- headers: flat,
298
- body: buffer.toString('base64'),
299
- done: true,
300
- },
301
- ]);
302
- return;
303
- }
304
-
305
- // SSE: reenviamos cada frame tal cual (el cliente sabe reconectar con cursor).
306
- let seq = 0;
307
- await push(row.id, [{ seq: seq++, status: res.status, headers: flat, done: false }]);
308
- const reader = res.body.getReader();
309
- const decoder = new TextDecoder();
310
- let buffer = '';
311
- for (;;) {
312
- const { value, done } = await reader.read();
313
- if (done) break;
314
- buffer += decoder.decode(value, { stream: true });
315
- let index;
316
- while ((index = buffer.indexOf('\n\n')) !== -1) {
317
- const frame = buffer.slice(0, index);
318
- buffer = buffer.slice(index + 2);
319
- let ev = '';
320
- let data = '';
321
- let id = null;
322
- for (const line of frame.split('\n')) {
323
- if (line.startsWith('event:')) ev = line.slice(6).trim();
324
- else if (line.startsWith('data:')) data += line.slice(5).trim();
325
- else if (line.startsWith('id:')) id = line.slice(3).trim();
326
- }
327
- if (!ev && !data) continue;
328
- await push(row.id, [{ seq: seq++, ev: ev || 'message', data, id, done: false }]);
329
- }
330
- }
331
- await push(row.id, [{ done: true }]);
332
- }
333
-
334
- async function pump() {
335
- const registered = await call('register', {}, { timeoutMs: 20000 });
336
- let cursor = Number(registered.cursor) || 0;
337
- log(`relay: conectado a ${relayUrl} (inbox desde ${cursor})`);
338
- while (!state.stopped) {
339
- let rows;
340
- try {
341
- const batch = await call('poll', { after: cursor, wait: 20 }, { timeoutMs: 40000 });
342
- rows = batch.rows ?? [];
343
- } catch (error) {
344
- if (state.stopped) break;
345
- log(`relay poll: ${error?.message ?? error}`);
346
- await new Promise((r) => setTimeout(r, 3000));
347
- continue;
348
- }
349
- for (const row of rows) {
350
- cursor = Math.max(cursor, Number(row.id) || 0);
351
- try {
352
- await handleRequest(row);
353
- } catch (error) {
354
- log(`relay request ${row.id}: ${error?.message ?? error}`);
355
- try {
356
- await push(row.id, [
357
- {
358
- status: 502,
359
- headers: { 'content-type': 'application/json; charset=utf-8' },
360
- body: Buffer.from(JSON.stringify({ error: `harness inaccesible: ${error?.message ?? error}` })).toString('base64'),
361
- done: true,
362
- },
363
- ]);
364
- } catch {
365
- /* nada */
366
- }
367
- }
368
- }
369
- }
370
- }
371
-
372
- pump().catch((error) => {
373
- if (!state.stopped) log(`relay pump: ${error?.message ?? error}`);
374
- });
375
-
376
- return {
377
- url: relayUrl,
378
- provider: 'relay',
379
- pid: 0,
380
- child: null,
381
- probeUrl: `${relayUrl}?a=status`,
382
- stop() {
383
- state.stopped = true;
384
- },
385
- };
386
- }
387
-
388
237
  /** Devuelve { url, provider, pid, stop }. provider 'none' no abre nada. */
389
238
  export async function startTunnel(port, provider = 'tunnelmole', opts = {}) {
390
239
  const p = String(provider || 'tunnelmole').toLowerCase();
391
240
  if (p === 'none' || p === 'off' || p === '') return { url: '', provider: 'none', pid: 0, stop: () => {} };
392
241
  if (p === 'tailscale' || p === 'ts') return startTailscaleServe(port, opts);
393
242
  if (p === 'tunnelmole' || p === 'tmole') return startTunnelmole(port, opts);
394
- if (p === 'relay') return startRelay(port, opts);
395
243
  throw new Error(`proveedor de tunel no soportado: ${provider}`);
396
244
  }
@@ -48,13 +48,15 @@ test('ensureApp creates app.json with a secret and roundtrips', async () => {
48
48
  assert.equal(readApp().port, 4599);
49
49
  });
50
50
 
51
- test('readApp migra un tunnelmole guardado a tailscale (legacy)', async () => {
51
+ test('readApp migra los proveedores retirados (tunnelmole/relay) a tailscale', async () => {
52
52
  const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-home-'));
53
53
  setHome(dir);
54
- const app = ensureApp();
55
- app.tunnel = { ...app.tunnel, provider: 'tunnelmole' };
56
- writeApp(app);
57
- assert.equal(readApp().tunnel.provider, 'tailscale');
54
+ for (const provider of ['tunnelmole', 'relay']) {
55
+ const app = ensureApp();
56
+ app.tunnel = { ...app.tunnel, provider };
57
+ writeApp(app);
58
+ assert.equal(readApp().tunnel.provider, 'tailscale', provider);
59
+ }
58
60
  });
59
61
 
60
62
  test('migrateLegacyDb copies the legacy project database to the global home', async () => {
@@ -3,7 +3,6 @@
3
3
  import { renderMarkdown, speechText } from './markdown.js';
4
4
  import { streamSse, followRun } from './runStream.js';
5
5
  import { diffState, pollDelayMs, localState, summarizeArgs } from './reconcile.js';
6
- import { installRelayTransport, showRelayTokenGate } from './relayClient.js';
7
6
 
8
7
  const $ = (sel) => document.querySelector(sel);
9
8
  let csrfToken = '';
@@ -97,30 +96,7 @@ function icon(name, size = 16, className = '') {
97
96
 
98
97
  /* --- HTTP / SSE ---------------------------------------------------------- */
99
98
 
100
- // Tamnora Relay: cuando la consola se sirve desde harness.tamnora.com/console,
101
- // el page define `window.TAMNORA_RELAY = { base }` y todo el trafico /api pasa
102
- // por el mailbox del relay (relaClient). Sin esa variable es el harness local.
103
- const RELAY_CONFIG = window.TAMNORA_RELAY ?? null;
104
- const relayToken = RELAY_CONFIG
105
- ? new URLSearchParams(location.search).get('t') || localStorage.getItem('tamnora-relay-token') || ''
106
- : '';
107
- const relayTransport = RELAY_CONFIG
108
- ? installRelayTransport({ base: RELAY_CONFIG.base, token: relayToken })
109
- : null;
110
-
111
- if (RELAY_CONFIG && !relayTransport.enabled) {
112
- showRelayTokenGate();
113
- throw new Error('[relay] falta el token de acceso');
114
- }
115
-
116
99
  async function api(path, { method = 'GET', body, headers = {} } = {}) {
117
- if (relayTransport?.enabled) {
118
- return relayTransport.request(path, {
119
- method,
120
- body,
121
- headers: method !== 'GET' && csrfToken ? { 'x-csrf': csrfToken, ...headers } : headers,
122
- });
123
- }
124
100
  const init = {};
125
101
  if (body) init['Content-Type'] = 'application/json';
126
102
  if (method !== 'GET' && csrfToken) init['x-csrf'] = csrfToken;
@@ -145,13 +121,10 @@ async function api(path, { method = 'GET', body, headers = {} } = {}) {
145
121
  }
146
122
 
147
123
  const streamPost = (url, body, onEvent, signal) =>
148
- relayTransport?.enabled
149
- ? relayTransport.stream(url, { method: 'POST', body, signal, headers: csrfToken ? { 'x-csrf': csrfToken } : {} }, onEvent)
150
- : streamSse(url, { method: 'POST', body, csrf: csrfToken, signal }, onEvent);
124
+ streamSse(url, { method: 'POST', body, csrf: csrfToken, signal }, onEvent);
151
125
 
152
126
  /** SSE GET de la consola (/api/sessions/:id/stream, /api/events). */
153
- const openStream = (url, opts, onEvent) =>
154
- relayTransport?.enabled ? relayTransport.stream(url, opts, onEvent) : streamSse(url, opts, onEvent);
127
+ const openStream = (url, opts, onEvent) => streamSse(url, opts, onEvent);
155
128
 
156
129
  /* --- localStorage (todas las lecturas/escrituras pasan por aca) ------------ */
157
130
 
@@ -601,7 +574,7 @@ if (logout) {
601
574
  } catch {
602
575
  // igual salimos
603
576
  }
604
- location.href = relayTransport?.enabled ? location.pathname : '/landing';
577
+ location.href = '/landing';
605
578
  });
606
579
  }
607
580
 
@@ -37,7 +37,7 @@
37
37
  </head>
38
38
  <body>
39
39
  <header class="topbar">
40
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span class="ver">v0.10.0</span></div>
40
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span class="ver">v0.11.0</span></div>
41
41
  <nav class="tabs">
42
42
  <button class="tab active" data-tab="chat">chat</button>
43
43
  <button class="tab" data-tab="providers">proveedores</button>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",
@@ -1,170 +0,0 @@
1
- // Transporte relay para la consola estatica (harness.tamnora.com/console/).
2
- //
3
- // La misma consola corre en el harness local y en el tamnora del sitio: aqui
4
- // cada llamada a `api()` y cada stream se manda por el mailbox del relay y el
5
- // daemon local (`tamnora bridge --tunnel relay`) la ejecuta contra su propio
6
- // server. El token (?t=...) es la credencial compartida con el mailbox.
7
- //
8
- // Requiere runStream.js (streamSse/delay) que ya carga la consola.
9
-
10
- import { streamSse, delay } from './runStream.js';
11
-
12
- export function installRelayTransport({ base, token }) {
13
- const cleanToken = String(token ?? '').trim();
14
- if (!cleanToken) {
15
- console.warn('[relay] sin token: la consola vera solo la pantalla de login');
16
- }
17
-
18
- const b64decode = (value) =>
19
- new TextDecoder().decode(Uint8Array.from(atob(String(value ?? '')), (c) => c.charCodeAt(0)));
20
-
21
- const relayUrl = (action) => `${base}?a=${action}&t=${encodeURIComponent(cleanToken)}`;
22
-
23
- async function post(action, payload, signal) {
24
- const res = await fetch(relayUrl(action), {
25
- method: 'POST',
26
- headers: { 'Content-Type': 'application/json' },
27
- body: JSON.stringify(payload),
28
- signal,
29
- });
30
- if (!res.ok) {
31
- const error = new Error(`relay HTTP ${res.status}`);
32
- error.status = res.status;
33
- throw error;
34
- }
35
- return res.json();
36
- }
37
-
38
- function applyCookies(headers) {
39
- for (const cookie of headers?.['set-cookie'] ?? []) {
40
- const kv = String(cookie).split(';')[0];
41
- if (kv && kv.includes('=')) document.cookie = `${kv}; Path=/`;
42
- }
43
- }
44
-
45
- /** Request sincrono: PHP espera inline la respuesta completa del mailbox. */
46
- async function request(path, { method = 'GET', body, headers = {} } = {}) {
47
- const envelope = {
48
- method,
49
- path,
50
- headers,
51
- body: body === undefined ? null : JSON.stringify(body),
52
- stream: false,
53
- };
54
- const data = await post('send', envelope);
55
- if (data.error) {
56
- const error = new Error(data.error);
57
- error.status = Number(data.status) || 0;
58
- throw error;
59
- }
60
- applyCookies(data.headers);
61
- const text = b64decode(data.body);
62
- let parsed = null;
63
- try {
64
- parsed = JSON.parse(text);
65
- } catch {
66
- parsed = { raw: text };
67
- }
68
- if (Number(data.status) >= 400) {
69
- const error = new Error(parsed?.error ?? `HTTP ${data.status}`);
70
- error.status = Number(data.status);
71
- throw error;
72
- }
73
- return parsed;
74
- }
75
-
76
- const parseOrRaw = (text) => {
77
- try {
78
- return JSON.parse(text);
79
- } catch {
80
- return { raw: text };
81
- }
82
- };
83
-
84
- /**
85
- * Stream via mailbox: abre el rid y consume `a=stream` (SSE con cursor).
86
- * Si el PHP corta (max_execution_time) reconecta y repite desde el cursor:
87
- * el replay es del mailbox, NO re-ejecuta el request original.
88
- */
89
- async function stream(path, { method = 'POST', body, headers = {}, signal, silenceMs } = {}, onEvent) {
90
- const envelope = {
91
- method,
92
- path,
93
- headers,
94
- body: body === undefined ? null : JSON.stringify(body),
95
- stream: true,
96
- };
97
- const opened = await post('send', envelope, signal);
98
- if (opened.error) {
99
- const error = new Error(opened.error);
100
- error.status = Number(opened.status) || 0;
101
- throw error;
102
- }
103
- const rid = opened.rid;
104
- let cursor = 0;
105
- let attempt = 0;
106
- for (;;) {
107
- if (signal?.aborted) {
108
- const error = new Error('Aborted', 'AbortError');
109
- error.name = 'AbortError';
110
- throw error;
111
- }
112
- try {
113
- let finished = false;
114
- await streamSse(
115
- `${base}?a=stream&t=${encodeURIComponent(cleanToken)}&rid=${encodeURIComponent(rid)}&from=${cursor}`,
116
- { signal, silenceMs },
117
- (event, data, id) => {
118
- if (id != null) cursor = Number(id) || cursor;
119
- if (event === 'chunk') {
120
- const payload = data?.payload ?? {};
121
- if (!payload.done) {
122
- onEvent(payload.ev || 'message', payload.data != null ? parseOrRaw(payload.data) : {}, payload.id ?? null);
123
- }
124
- return;
125
- }
126
- // meta: headers del run o cierre del daemon.
127
- if (data?.payload?.done) finished = true;
128
- else if (data?.payload?.error) {
129
- const error = new Error(data.payload.error);
130
- error.fatalRelay = true;
131
- throw error;
132
- }
133
- }
134
- );
135
- return { finished };
136
- } catch (error) {
137
- if (error?.name === 'AbortError' || error?.fatalRelay) throw error;
138
- attempt += 1;
139
- await delay(Math.min(8000, 1000 * 2 ** (attempt - 1)), signal);
140
- }
141
- }
142
- }
143
-
144
- window.relayTransport = { token: cleanToken, base, request, stream, enabled: Boolean(cleanToken) };
145
- return window.relayTransport;
146
- }
147
-
148
- /** Pantalla minima si no hay token: pide uno y recarga. */
149
- export function showRelayTokenGate() {
150
- document.body.innerHTML = '';
151
- document.body.style.visibility = 'visible';
152
- const form = document.createElement('form');
153
- form.style.cssText = 'max-width:420px;margin:15vh auto;font-family:ui-sans-serif,system-ui;padding:0 16px';
154
- form.innerHTML = `
155
- <h1 style="font-size:20px">Tamnora Console</h1>
156
- <p style="color:#777;font-size:14px">Pegá el token del bridge (o escaneá el QR del <code>tamnora bridge --tunnel relay</code>).</p>
157
- <input name="t" placeholder="token" style="width:100%;padding:10px;font-size:14px" autocomplete="off" />
158
- <button style="margin-top:10px;padding:10px 16px">entrar</button>`;
159
- form.addEventListener('submit', (event) => {
160
- event.preventDefault();
161
- const value = form.elements.t.value.trim();
162
- if (!value) return;
163
- localStorage.setItem('tamnora-relay-token', value);
164
- const params = new URLSearchParams(location.search);
165
- params.delete('t');
166
- location.href = location.pathname + (params.toString() ? `?${params}` : '');
167
- location.reload();
168
- });
169
- document.body.append(form);
170
- }
@@ -1,219 +0,0 @@
1
- import { test } from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { spawn } from 'node:child_process';
4
- import { mkdtemp } from 'node:fs/promises';
5
- import { execFileSync } from 'node:child_process';
6
- import { tmpdir } from 'node:os';
7
- import path from 'node:path';
8
-
9
-
10
- import { createTamnoraServer } from '../src/index.js';
11
- import { setHome, startRelay, randomToken } from '../../core/src/index.js';
12
-
13
- const HAS_PHP = (() => {
14
- try {
15
- execFileSync('php', ['-v'], { stdio: 'ignore' });
16
- return true;
17
- } catch {
18
- return false;
19
- }
20
- })();
21
-
22
- const fakeProvider = {
23
- id: 'fake',
24
- async streamChat(_config, { onEvent }) {
25
- onEvent?.({ type: 'text', delta: 'hola', full: 'hola' });
26
- return { content: 'hola', reasoning: '', toolCalls: [] };
27
- },
28
- };
29
-
30
- /** SSE crudo por GET crudo (fetch con streaming del lado Node). */
31
- async function sseGet(url, { expectFrames = 1, timeoutMs = 30000 } = {}) {
32
- const frames = [];
33
- const controller = new AbortController();
34
- const timer = setTimeout(() => controller.abort(), timeoutMs);
35
- try {
36
- const res = await fetch(url, { signal: controller.signal });
37
- const reader = res.body.getReader();
38
- const decoder = new TextDecoder();
39
- let buf = '';
40
- while (frames.length < expectFrames) {
41
- const { value, done } = await reader.read();
42
- if (done) break;
43
- buf += decoder.decode(value, { stream: true });
44
- let index;
45
- while ((index = buf.indexOf('\n\n')) !== -1) {
46
- const frame = buf.slice(0, index);
47
- buf = buf.slice(index + 2);
48
- let event = null;
49
- let data = '';
50
- let id = null;
51
- for (const line of frame.split('\n')) {
52
- if (line.startsWith('event:')) event = line.slice(6).trim();
53
- else if (line.startsWith('data:')) data += line.slice(5).trim();
54
- else if (line.startsWith('id:')) id = line.slice(3).trim();
55
- }
56
- if (!event && !data) continue;
57
- let parsed = {};
58
- try {
59
- parsed = JSON.parse(data);
60
- } catch {
61
- parsed = { raw: data };
62
- }
63
- frames.push({ event, data: parsed, id });
64
- if (frames.length >= expectFrames) {
65
- controller.abort();
66
- break;
67
- }
68
- }
69
- }
70
- } catch (error) {
71
- if (error?.name !== 'AbortError' && frames.length < expectFrames) throw error;
72
- } finally {
73
- clearTimeout(timer);
74
- }
75
- return frames;
76
- }
77
-
78
- async function phpServer(docroot) {
79
- const port = 44000 + Math.floor(Math.random() * 4000);
80
- const args = ['-S', `127.0.0.1:${port}`, '-t', docroot];
81
- const child = spawn('php', args, {
82
- env: { ...process.env },
83
- stdio: ['ignore', 'pipe', 'pipe'],
84
- windowsHide: true,
85
- });
86
- let stderr = '';
87
- child.stderr.on('data', (d) => (stderr += String(d)));
88
- let exited = null;
89
- child.on('exit', (code) => (exited = code));
90
- const base = `http://127.0.0.1:${port}/index.php`;
91
- // esperar a que escuche
92
- let lastError = '';
93
- for (let i = 0; i < 60; i++) {
94
- try {
95
- await fetch(`${base}?a=status`, { signal: AbortSignal.timeout(1000) });
96
- return { child, base };
97
- } catch (error) {
98
- lastError = error?.message ?? String(error);
99
- if (exited !== null) break;
100
- await new Promise((r) => setTimeout(r, 250));
101
- }
102
- }
103
- throw new Error(
104
- `php server no arranco: ${JSON.stringify({ docroot, exited, lastError, stderr: stderr.slice(0, 400) })}`
105
- );
106
- }
107
-
108
- /**
109
- * El PHP built-in en Windows es monohilo (PHP_CLI_SERVER_WORKERS no existe
110
- * alla); send/poll largos se bloquearian mutuamente. Para el test corremos
111
- * DOS servers sobre la misma SQLite (WAL es multi-proceso): el agente usa
112
- * uno, el browser el otro. En cPanel con FPM esto no hace falta.
113
- */
114
- async function relayStack(docroot) {
115
- const agent = await phpServer(docroot);
116
- const browser = await phpServer(docroot);
117
- return { children: [agent.child, browser.child], relayBase: agent.base, browserBase: browser.base };
118
- }
119
-
120
- test('el relay PHP mailbox carga y hace GC', async (t) => {
121
- if (!HAS_PHP) return t.skip('sin PHP disponible');
122
- const docroot = path.join(import.meta.dirname, '..', '..', '..', '..', 'relay');
123
- const { child, base } = await phpServer(docroot);
124
- try {
125
- const status = await (await fetch(`${base}?a=status`)).json();
126
- assert.equal(status.ok, true);
127
-
128
- const token = randomToken(24);
129
- const reg = await (
130
- await fetch(`${base}?a=register`, {
131
- method: 'POST',
132
- headers: { 'Content-Type': 'application/json' },
133
- body: JSON.stringify({ token }),
134
- })
135
- ).json();
136
- assert.equal(reg.ok, true);
137
- assert.equal(reg.cursor, 0);
138
-
139
- const bad = await fetch(`${base}?a=send&t=${token}`, {
140
- method: 'POST',
141
- body: JSON.stringify({ path: 'nope' }),
142
- });
143
- assert.equal(bad.status, 400);
144
- } finally {
145
- child.kill('SIGTERM');
146
- }
147
- });
148
-
149
- test('end-to-end: request no-stream y stream de chat via relay', async (t) => {
150
- if (!HAS_PHP) return t.skip('sin PHP disponible');
151
-
152
- const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-relay-'));
153
- setHome(cwd);
154
- const instance = await createTamnoraServer({
155
- cwd,
156
- dbPath: path.join(cwd, 'tamnora.db'),
157
- loadMcp: false,
158
- resolveProvider: () => ({ provider: fakeProvider, config: {} }),
159
- });
160
- const local = await instance.listen({ port: 0 });
161
-
162
- const docroot = path.join(import.meta.dirname, '..', '..', '..', '..', 'relay');
163
- const { children, relayBase, browserBase } = await relayStack(docroot);
164
- const token = randomToken(24);
165
- const logs = [];
166
- const pump = startRelay(local.port, { relayUrl: relayBase, token, log: (line) => logs.push(line) });
167
-
168
- try {
169
- // 1) request sincrono: /api/health pasa entero
170
- const health = await (
171
- await fetch(`${browserBase}?a=send&t=${token}`, {
172
- method: 'POST',
173
- headers: { 'Content-Type': 'application/json' },
174
- body: JSON.stringify({ method: 'GET', path: '/api/health' }),
175
- })
176
- ).json();
177
- assert.equal(health.status, 200);
178
- assert.equal(JSON.parse(Buffer.from(health.body, 'base64').toString('utf8')).ok, true);
179
-
180
- // 2) crear sesion y correr un turno SSE via mailbox
181
- const session = await (
182
- await fetch(`${browserBase}?a=send&t=${token}`, {
183
- method: 'POST',
184
- headers: { 'Content-Type': 'application/json' },
185
- body: JSON.stringify({ method: 'POST', path: '/api/sessions', body: JSON.stringify({ title: 'relay' }) }),
186
- })
187
- ).json();
188
- assert.equal(session.status, 201, `sesion: ${session.body && Buffer.from(session.body, 'base64').toString('utf8')}`);
189
- const sessionId = JSON.parse(Buffer.from(session.body, 'base64').toString('utf8')).session.id;
190
-
191
- const opened = await (
192
- await fetch(`${browserBase}?a=send&t=${token}`, {
193
- method: 'POST',
194
- headers: { 'Content-Type': 'application/json' },
195
- body: JSON.stringify({
196
- method: 'POST',
197
- path: '/api/chat',
198
- body: JSON.stringify({ input: 'hola', sessionId, model: 'm' }),
199
- stream: true,
200
- }),
201
- })
202
- ).json();
203
- assert.ok(opened.rid, 'a=send stream devuelve rid');
204
-
205
- const frames = await sseGet(`${browserBase}?a=stream&t=${token}&rid=${opened.rid}&from=0`, { expectFrames: 20 });
206
- const events = frames.filter((f) => f.event === 'chunk').map((f) => f.data.payload?.ev);
207
- assert.ok(events.includes('start'), `start en ${JSON.stringify(events)}`);
208
- assert.ok(events.includes('text'), `text en ${JSON.stringify(events)}`);
209
- const doneRow = frames.some((f) => f.event === 'meta' && f.data.payload?.done === true);
210
- assert.ok(doneRow, 'la fila final done cierra el stream');
211
- } catch (error) {
212
- error.logs = logs.slice();
213
- throw error;
214
- } finally {
215
- pump.stop();
216
- for (const child of children) child.kill('SIGTERM');
217
- await instance.close();
218
- }
219
- });