@danieltmn/tamnora 0.7.1 → 0.9.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 (66) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +9 -0
  3. package/SECURITY.md +11 -6
  4. package/harness/packages/cli/src/cli.js +1 -1
  5. package/harness/packages/cli/src/commands/bridge.js +180 -119
  6. package/harness/packages/cli/src/commands/run.js +1 -1
  7. package/harness/packages/cli/src/commands/server.js +26 -50
  8. package/harness/packages/cli/src/commands/tui.js +1 -1
  9. package/harness/packages/cli/src/lib/daemon.js +92 -40
  10. package/harness/packages/cli/test/release.test.js +9 -0
  11. package/harness/packages/core/src/agent/modes.js +1 -1
  12. package/harness/packages/core/src/agent/runAgent.js +3 -0
  13. package/harness/packages/core/src/git/index.js +177 -0
  14. package/harness/packages/core/src/index.js +16 -1
  15. package/harness/packages/core/src/open.js +43 -0
  16. package/harness/packages/core/src/pid.js +35 -0
  17. package/harness/packages/core/src/procs/kill.js +95 -0
  18. package/harness/packages/core/src/tools/executors.js +16 -0
  19. package/harness/packages/core/src/tools/labels.js +2 -0
  20. package/harness/packages/core/src/tools/permissions.js +1 -0
  21. package/harness/packages/core/src/tools/schema.js +17 -0
  22. package/harness/packages/core/src/tts/edge.js +232 -0
  23. package/harness/packages/core/src/tunnel.js +180 -19
  24. package/harness/packages/core/test/open.test.js +19 -0
  25. package/harness/packages/core/test/procs.test.js +50 -0
  26. package/harness/packages/core/test/tools.test.js +12 -0
  27. package/harness/packages/core/test/tts.test.js +41 -0
  28. package/harness/packages/server/public/app.js +4715 -3669
  29. package/harness/packages/server/public/index.html +94 -5
  30. package/harness/packages/server/public/landing/index.html +5 -5
  31. package/harness/packages/server/public/reconcile.js +121 -0
  32. package/harness/packages/server/public/relayClient.js +170 -0
  33. package/harness/packages/server/public/runStream.js +207 -0
  34. package/harness/packages/server/public/styles.css +196 -23
  35. package/harness/packages/server/src/auth.js +29 -5
  36. package/harness/packages/server/src/lib/agentRun.js +77 -0
  37. package/harness/packages/server/src/lib/announcer.js +92 -0
  38. package/harness/packages/server/src/lib/approvals.js +25 -3
  39. package/harness/packages/server/src/lib/channels/telegram.js +85 -20
  40. package/harness/packages/server/src/lib/ffmpeg.js +48 -0
  41. package/harness/packages/server/src/lib/procs.js +3 -18
  42. package/harness/packages/server/src/lib/runManager.js +24 -2
  43. package/harness/packages/server/src/lib/sessionView.js +43 -0
  44. package/harness/packages/server/src/lib/tasks.js +4 -9
  45. package/harness/packages/server/src/lib/ttsSettings.js +42 -0
  46. package/harness/packages/server/src/lib/workspace.js +37 -1
  47. package/harness/packages/server/src/routes/chat.js +51 -47
  48. package/harness/packages/server/src/routes/events.js +35 -0
  49. package/harness/packages/server/src/routes/files.js +22 -26
  50. package/harness/packages/server/src/routes/folders.js +9 -17
  51. package/harness/packages/server/src/routes/git.js +35 -0
  52. package/harness/packages/server/src/routes/procs.js +25 -15
  53. package/harness/packages/server/src/routes/sessions.js +12 -19
  54. package/harness/packages/server/src/routes/shell.js +19 -0
  55. package/harness/packages/server/src/routes/state.js +32 -0
  56. package/harness/packages/server/src/routes/tts.js +82 -0
  57. package/harness/packages/server/src/server.js +25 -1
  58. package/harness/packages/server/test/announcer.test.js +51 -0
  59. package/harness/packages/server/test/reconcile.test.js +146 -0
  60. package/harness/packages/server/test/relay.test.js +219 -0
  61. package/harness/packages/server/test/runStream.test.js +245 -0
  62. package/harness/packages/server/test/security.test.js +252 -0
  63. package/harness/packages/server/test/server.test.js +617 -1
  64. package/harness/packages/server/test/telegram-voz.test.js +156 -0
  65. package/harness/packages/server/test/tts.test.js +133 -0
  66. package/package.json +1 -1
@@ -0,0 +1,95 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ const RUN_TIMEOUT_MS = 8000;
4
+
5
+ /** Run a command, resolving `{ code, stdout }` (never rejects). */
6
+ function run(command, args, { timeout = RUN_TIMEOUT_MS } = {}) {
7
+ return new Promise((resolve) => {
8
+ let child;
9
+ try {
10
+ child = spawn(command, args, { windowsHide: true });
11
+ } catch {
12
+ resolve({ code: -1, stdout: '' });
13
+ return;
14
+ }
15
+ let stdout = '';
16
+ let settled = false;
17
+ const finish = (result) => {
18
+ if (settled) return;
19
+ settled = true;
20
+ clearTimeout(timer);
21
+ resolve(result);
22
+ };
23
+ const timer = setTimeout(() => {
24
+ try {
25
+ child.kill();
26
+ } catch {
27
+ // already gone
28
+ }
29
+ finish({ code: -1, stdout });
30
+ }, timeout);
31
+ child.stdout?.on('data', (chunk) => {
32
+ if (stdout.length < 20000) stdout += chunk.toString();
33
+ });
34
+ child.on('error', () => finish({ code: -1, stdout }));
35
+ child.on('close', (code) => finish({ code: code ?? -1, stdout }));
36
+ });
37
+ }
38
+
39
+ function uniquePids(values) {
40
+ return [...new Set(values.map(Number).filter((pid) => Number.isInteger(pid) && pid > 0))];
41
+ }
42
+
43
+ /** Find PIDs listening on a TCP/UDP port (Windows `netstat`). */
44
+ async function pidsOnWindows(port) {
45
+ const { stdout } = await run('netstat', ['-ano']);
46
+ const pids = [];
47
+ for (const line of stdout.split(/\r?\n/)) {
48
+ const fields = line.trim().split(/\s+/);
49
+ if (fields.length < 4) continue;
50
+ if (fields[0].toUpperCase() !== 'TCP' && fields[0].toUpperCase() !== 'UDP') continue;
51
+ if (!fields[1].endsWith(`:${port}`)) continue;
52
+ pids.push(fields[fields.length - 1]);
53
+ }
54
+ return uniquePids(pids);
55
+ }
56
+
57
+ /** Find PIDs listening on a port (Unix: lsof, fuser or ss). */
58
+ async function pidsOnUnix(port) {
59
+ const lsof = await run('lsof', ['-ti', `tcp:${port}`]);
60
+ if (lsof.code === 0 && lsof.stdout.trim()) {
61
+ return uniquePids(lsof.stdout.split(/\s+/));
62
+ }
63
+ const fuser = await run('fuser', [`${port}/tcp`]);
64
+ if (fuser.code === 0 && fuser.stdout.trim()) {
65
+ return uniquePids(fuser.stdout.match(/\d+/g) ?? []);
66
+ }
67
+ const ss = await run('ss', ['-ltnp', `sport = :${port}`]);
68
+ if (ss.code === 0 && ss.stdout.trim()) {
69
+ return uniquePids([...ss.stdout.matchAll(/pid=(\d+)/g)].map((match) => match[1]));
70
+ }
71
+ return [];
72
+ }
73
+
74
+ /**
75
+ * Kill every process listening on `port`.
76
+ * @returns {Promise<{port:number,pids:number[],killed:number[],errors:string[]}>}
77
+ */
78
+ export async function killPort(port) {
79
+ const value = Number(port);
80
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
81
+ throw new Error('Puerto inválido (usá 1-65535).');
82
+ }
83
+ const pids = process.platform === 'win32' ? await pidsOnWindows(value) : await pidsOnUnix(value);
84
+ const killed = [];
85
+ const errors = [];
86
+ for (const pid of pids) {
87
+ const result =
88
+ process.platform === 'win32'
89
+ ? await run('taskkill', ['/PID', String(pid), '/T', '/F'])
90
+ : await run('kill', ['-9', String(pid)]);
91
+ if (result.code === 0) killed.push(pid);
92
+ else errors.push(String(pid));
93
+ }
94
+ return { port: value, pids, killed, errors };
95
+ }
@@ -404,6 +404,21 @@ export async function gitCommitTool(root, { message, files } = {}) {
404
404
  return runGit(root, ['commit', '-m', message]);
405
405
  }
406
406
 
407
+ const MAX_SPEAK_TEXT = 1200;
408
+
409
+ /**
410
+ * Pedido de audio: valida el texto y lo devuelve. La sintesis y la reproduccion
411
+ * son del cliente (la consola lo reproduce y Telegram lo manda como nota de
412
+ * voz), asi que aca solo se normaliza el pedido y se acota el largo.
413
+ */
414
+ export function speakTool(_root, { texto, voice, rate } = {}) {
415
+ const value = String(texto ?? '').replace(/\s+/g, ' ').trim();
416
+ if (!value) return 'Error: falta "texto".';
417
+ const recortado = value.length > MAX_SPEAK_TEXT ? `${value.slice(0, MAX_SPEAK_TEXT - 1).trimEnd()}…` : value;
418
+ const opciones = [voice, rate].filter(Boolean).join(', ');
419
+ return `Audio pedido${opciones ? ` (${opciones})` : ''}: "${recortado}". El cliente lo reproduce o lo envía como nota de voz.`;
420
+ }
421
+
407
422
  export const EXECUTORS = {
408
423
  read: readTool,
409
424
  write: writeTool,
@@ -417,4 +432,5 @@ export const EXECUTORS = {
417
432
  git_diff: gitDiffTool,
418
433
  git_log: gitLogTool,
419
434
  git_commit: gitCommitTool,
435
+ speak: speakTool,
420
436
  };
@@ -50,6 +50,8 @@ export function summarizeToolCall(name, args = {}) {
50
50
  }
51
51
  case 'fetch_url':
52
52
  return shorten(a.url, 90);
53
+ case 'speak':
54
+ return shorten(a.texto, 90);
53
55
  case 'git_diff':
54
56
  return [a.ref ? `vs ${shorten(a.ref, 30)}` : a.staged ? 'staged' : 'unstaged', a.path ? path(a.path) : '']
55
57
  .filter(Boolean)
@@ -13,6 +13,7 @@ export const DEFAULT_PERMISSIONS = {
13
13
  git_diff: 'allow',
14
14
  git_log: 'allow',
15
15
  fetch_url: 'allow',
16
+ speak: 'allow',
16
17
  write: 'ask',
17
18
  edit: 'ask',
18
19
  bash: 'ask',
@@ -133,6 +133,23 @@ export const TOOL_DEFINITIONS = [
133
133
  },
134
134
  },
135
135
  },
136
+ {
137
+ type: 'function',
138
+ function: {
139
+ name: 'speak',
140
+ description:
141
+ 'Speak a short text aloud to the user with text-to-speech. Use it when the user asks to hear something (e.g. an audio summary). Keep it concise: the web console plays it and Telegram sends it as a voice note.',
142
+ parameters: {
143
+ type: 'object',
144
+ properties: {
145
+ texto: { type: 'string', description: 'Text to speak (long text is trimmed)' },
146
+ voice: { type: 'string', description: 'Optional voice id, e.g. "es-AR-ElenaNeural"' },
147
+ rate: { type: 'string', description: 'Optional speed, e.g. "+10%"' },
148
+ },
149
+ required: ['texto'],
150
+ },
151
+ },
152
+ },
136
153
  {
137
154
  type: 'function',
138
155
  function: {
@@ -0,0 +1,232 @@
1
+ // Sintesis de voz (TTS) con el servicio Edge Read Aloud de Microsoft: las
2
+ // voces neuronales gratuitas de Edge, sin API key ni cuenta de Azure.
3
+ //
4
+ // Port del cliente de streaming de voice-tts, adaptado al WebSocket global de
5
+ // Node (>=22, undici): ese WebSocket acepta `origin` y `headers` como bag de
6
+ // opciones (extension no estandar de undici), que el servicio exige (User-Agent
7
+ // de Edge + token anti-abuso Sec-MS-GEC en la URL). Sin dependencias.
8
+ //
9
+ // Aqui la salida es buffered (Promise<Buffer>): las frases que habla el harness
10
+ // son cortas, el MP3 completo ronda los 50-150 KB y no vale la pena exponer un
11
+ // stream con backpressure. Trampas heredadas del original:
12
+ // - sin Sec-MS-GEC (SHA-256 del file time de Windows + token) el servicio da 403;
13
+ // - un cierre del socket antes de `turn.end` deja un MP3 "truncado pero
14
+ // reproducible": eso se convierte en error, no en silencio.
15
+
16
+ import { createHash, randomBytes } from 'node:crypto';
17
+
18
+ /** Token publico del cliente de Edge (via en el codigo del navegador). */
19
+ export const TRUSTED_CLIENT_TOKEN = '6A5AA1D4EAFF4E9FB37E23D68491D6F4';
20
+
21
+ /** Version de Edge que declaramos. Si el servicio empieza a rechazar, subirla. */
22
+ export const EDGE_VERSION = process.env.TTS_EDGE_VERSION ?? '143.0.3650.75';
23
+
24
+ /** Formato por defecto: MP3 a 24 kHz, 48 kbps, mono (CBR). */
25
+ export const FORMATO_POR_DEFECTO = 'audio-24khz-48kbitrate-mono-mp3';
26
+
27
+ /** Tope de texto por sintesis: el servicio rechaza mas de ~3000 caracteres. */
28
+ export const MAX_TEXTO = 2000;
29
+
30
+ /** Voz por defecto del harness. */
31
+ export const VOZ_POR_DEFECTO = 'es-AR-ElenaNeural';
32
+
33
+ const URL_BASE = 'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1';
34
+ const URL_VOCES = `https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list?trustedclienttoken=${TRUSTED_CLIENT_TOKEN}`;
35
+ const ORIGEN_EDGE = 'chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold';
36
+ const SEGUNDOS_EPOCA_WINDOWS = 11644473600;
37
+ const VENTANA_TICKS = 3000000000n; // 5 minutos en ticks de 100 ns
38
+ const DELIMITADOR_AUDIO = Buffer.from('Path:audio\r\n');
39
+
40
+ const UA_EDGE = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${EDGE_VERSION.split('.')[0]}.0.0.0 Safari/537.36 Edg/${EDGE_VERSION.split('.')[0]}.0.0.0`;
41
+
42
+ /**
43
+ * Token anti-abuso `Sec-MS-GEC` que exige el servicio: SHA-256 del file time
44
+ * de Windows redondeado a 5 minutos + el client token.
45
+ *
46
+ * @param {number} [ahora] Marca de tiempo en ms (inyectable para pruebas).
47
+ * @returns {string} SHA-256 en mayusculas.
48
+ */
49
+ export function generarSecMsGec(ahora = Date.now()) {
50
+ const segundos = Math.floor(ahora / 1000 + SEGUNDOS_EPOCA_WINDOWS);
51
+ const ticks = BigInt(segundos) * 10000000n;
52
+ const redondeado = ticks - (ticks % VENTANA_TICKS);
53
+ return createHash('sha256').update(`${redondeado}${TRUSTED_CLIENT_TOKEN}`, 'ascii').digest('hex').toUpperCase();
54
+ }
55
+
56
+ function escaparXml(valor) {
57
+ return String(valor).replace(/[<>&"']/g, (c) => {
58
+ switch (c) {
59
+ case '<': return '&lt;';
60
+ case '>': return '&gt;';
61
+ case '&': return '&amp;';
62
+ case '"': return '&quot;';
63
+ default: return '&apos;';
64
+ }
65
+ });
66
+ }
67
+
68
+ /** SSML de una sintesis. Exportado para probar el escape y la prosodia. */
69
+ export function generarSsml({ voz, lang, rate, pitch, volume, texto }) {
70
+ const locale = lang ?? (/\w{2}-\w{2}/.exec(String(voz ?? ''))?.[0] ?? '');
71
+ if (!locale) throw new Error(`No se pudo deducir el locale de la voz "${voz}".`);
72
+ return (
73
+ `<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="${escaparXml(locale)}">` +
74
+ `<voice name="${escaparXml(voz)}">` +
75
+ `<prosody rate="${escaparXml(rate)}" pitch="${escaparXml(pitch)}" volume="${escaparXml(volume)}">` +
76
+ `${escaparXml(texto)}` +
77
+ '</prosody></voice></speak>'
78
+ );
79
+ }
80
+
81
+ function urlSintesis() {
82
+ const parametros = new URLSearchParams({
83
+ TrustedClientToken: TRUSTED_CLIENT_TOKEN,
84
+ 'Sec-MS-GEC': generarSecMsGec(),
85
+ 'Sec-MS-GEC-Version': `1-${EDGE_VERSION}`,
86
+ ConnectionId: randomBytes(16).toString('hex'),
87
+ });
88
+ return `${URL_BASE}?${parametros.toString()}`;
89
+ }
90
+
91
+ /**
92
+ * Sintetiza texto y devuelve el MP3 completo.
93
+ *
94
+ * @param {string} texto Texto a sintetizar (se recorta a MAX_TEXTO).
95
+ * @param {object} [opciones]
96
+ * @param {string} [opciones.voice] Voz (ej. "es-AR-ElenaNeural").
97
+ * @param {string} [opciones.rate] Velocidad, ej. "+20%".
98
+ * @param {string} [opciones.pitch] Tono, ej. "-10Hz".
99
+ * @param {string} [opciones.volume] Volumen, ej. "+0%".
100
+ * @param {string} [opciones.outputFormat] Formato de salida del servicio.
101
+ * @param {number} [opciones.idleTimeout] Maximo de ms sin frames antes de abortar.
102
+ * @param {AbortSignal} [opciones.signal] Para cancelar la sintesis.
103
+ * @returns {Promise<Buffer>} MP3.
104
+ */
105
+ export function sintetizar(texto, opciones = {}) {
106
+ const {
107
+ voice: voz = VOZ_POR_DEFECTO,
108
+ rate = '+0%',
109
+ pitch = '+0Hz',
110
+ volume = '+0%',
111
+ outputFormat = FORMATO_POR_DEFECTO,
112
+ idleTimeout = 15000,
113
+ signal,
114
+ } = opciones;
115
+
116
+ const contenido = String(texto ?? '').slice(0, MAX_TEXTO).trim();
117
+ if (!contenido) throw new Error('El texto está vacío: no hay nada que sintetizar.');
118
+ if (signal?.aborted) throw new Error('La síntesis se canceló antes de empezar.');
119
+ if (typeof WebSocket === 'undefined') {
120
+ throw new Error('Este Node no trae WebSocket global (se necesita Node 22+).');
121
+ }
122
+
123
+ return new Promise((resolve, reject) => {
124
+ const chunks = [];
125
+ const ws = new WebSocket(urlSintesis(), {
126
+ origin: ORIGEN_EDGE,
127
+ headers: {
128
+ Pragma: 'no-cache',
129
+ 'Cache-Control': 'no-cache',
130
+ 'Accept-Encoding': 'gzip, deflate, br, zstd',
131
+ 'Accept-Language': 'en-US,en;q=0.9',
132
+ 'User-Agent': UA_EDGE,
133
+ },
134
+ });
135
+ ws.binaryType = 'arraybuffer';
136
+
137
+ let termino = false;
138
+ let vigilante = null;
139
+
140
+ const fallar = (error) => {
141
+ if (termino) return;
142
+ termino = true;
143
+ clearTimeout(vigilante);
144
+ signal?.removeEventListener('abort', alAbortar);
145
+ try {
146
+ ws.close();
147
+ } catch {
148
+ // ya cerrado
149
+ }
150
+ reject(error);
151
+ };
152
+
153
+ const alAbortar = () => fallar(new Error('La síntesis se canceló.'));
154
+ const vigilar = () => {
155
+ clearTimeout(vigilante);
156
+ vigilante = setTimeout(() => fallar(new Error(`El servicio no envió audio en ${idleTimeout} ms.`)), idleTimeout);
157
+ vigilante.unref?.();
158
+ };
159
+
160
+ signal?.addEventListener('abort', alAbortar, { once: true });
161
+ vigilar();
162
+
163
+ ws.addEventListener('open', () => {
164
+ ws.send(
165
+ 'Content-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n' +
166
+ JSON.stringify({
167
+ context: {
168
+ synthesis: {
169
+ audio: {
170
+ metadataoptions: { sentenceBoundaryEnabled: false, wordBoundaryEnabled: false },
171
+ outputFormat,
172
+ },
173
+ },
174
+ },
175
+ })
176
+ );
177
+ ws.send(
178
+ `X-RequestId:${randomBytes(16).toString('hex')}\r\nContent-Type:application/ssml+xml\r\nPath:ssml\r\n\r\n` +
179
+ generarSsml({ voz, rate, pitch, volume, texto: contenido })
180
+ );
181
+ });
182
+
183
+ ws.addEventListener('message', (event) => {
184
+ vigilar();
185
+ if (event.data instanceof ArrayBuffer) {
186
+ const frame = Buffer.from(event.data);
187
+ const corte = frame.indexOf(DELIMITADOR_AUDIO);
188
+ if (corte === -1) return;
189
+ const audio = frame.subarray(corte + DELIMITADOR_AUDIO.length);
190
+ if (audio.length) chunks.push(Buffer.from(audio));
191
+ return;
192
+ }
193
+ const frame = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString();
194
+ if (frame.includes('Path:turn.end')) {
195
+ termino = true;
196
+ clearTimeout(vigilante);
197
+ signal?.removeEventListener('abort', alAbortar);
198
+ ws.close();
199
+ if (!chunks.length) reject(new Error('El servicio cerró sin entregar audio.'));
200
+ else resolve(Buffer.concat(chunks));
201
+ }
202
+ });
203
+
204
+ ws.addEventListener('error', (event) => {
205
+ fallar(new Error(`Error de WebSocket con el servicio: ${event.message ?? 'sin detalle'}`));
206
+ });
207
+
208
+ ws.addEventListener('close', () => {
209
+ fallar(new Error('El servicio cerró la conexión antes de terminar: el audio quedaría truncado.'));
210
+ });
211
+ });
212
+ }
213
+
214
+ /**
215
+ * Catalogo de voces del servicio (endpoint publico, con cache propio).
216
+ *
217
+ * @param {string} [filtro] Prefijo de locale (ej. "es-"). Vacio = todas.
218
+ * @param {object} [opciones]
219
+ * @param {AbortSignal} [opciones.signal] Para cancelar la peticion.
220
+ * @returns {Promise<Array<object>>} Voces que empiezan con el filtro.
221
+ */
222
+ export async function listarVoces(filtro = 'es-', { signal } = {}) {
223
+ const respuesta = await fetch(URL_VOCES, {
224
+ headers: { 'User-Agent': UA_EDGE, Accept: 'application/json' },
225
+ signal,
226
+ });
227
+ if (!respuesta.ok) throw new Error(`El servicio de voces respondió HTTP ${respuesta.status}.`);
228
+ const voces = await respuesta.json();
229
+ if (!Array.isArray(voces)) throw new Error('La respuesta del servicio de voces no es una lista.');
230
+ if (!filtro) return voces;
231
+ return voces.filter((v) => String(v.Locale ?? '').startsWith(filtro));
232
+ }
@@ -1,4 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { bridgeStatePath } from './config/home.js';
4
+ import { pidAlive, killPidTree } from './pid.js';
2
5
 
3
6
  /**
4
7
  * Tuneles publicos. v1 soporta tunnelmole (gratis, sin cuenta; URL aleatoria)
@@ -7,27 +10,39 @@ import { spawn } from 'node:child_process';
7
10
 
8
11
  function killTree(child) {
9
12
  if (!child || child.killed) return;
10
- const pid = child.pid;
13
+ killPidTree(child.pid);
14
+ }
15
+
16
+ /**
17
+ * Estado del bridge (escrito por `tamnora bridge`), si el supervisor sigue
18
+ * vivo. Lo consulta el server para saber que hay un tunel publico activo y
19
+ * ajustar el modelo de confianza (guards locales, cookie Secure).
20
+ * Cache de 1 s: se lee en cada request sensible y el pid no cambia tan rapido.
21
+ */
22
+ let tunnelCache = { key: null, at: 0, state: null };
23
+ export function tunnelState() {
24
+ let statePath;
11
25
  try {
12
- if (process.platform === 'win32' && pid) {
13
- spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
14
- } else {
15
- if (pid) {
16
- try {
17
- process.kill(-pid, 'SIGTERM');
18
- } catch {
19
- /* sin grupo */
20
- }
21
- }
22
- try {
23
- child.kill('SIGTERM');
24
- } catch {
25
- /* ya murio */
26
- }
27
- }
26
+ statePath = bridgeStatePath();
27
+ } catch {
28
+ return null;
29
+ }
30
+ const now = Date.now();
31
+ if (tunnelCache.key === statePath && now - tunnelCache.at < 1000) return tunnelCache.state;
32
+ let state = null;
33
+ try {
34
+ const parsed = JSON.parse(readFileSync(statePath, 'utf8'));
35
+ if (parsed?.pid && pidAlive(parsed.pid)) state = parsed;
28
36
  } catch {
29
- /* ya murio */
37
+ /* sin bridge */
30
38
  }
39
+ tunnelCache = { key: statePath, at: now, state };
40
+ return state;
41
+ }
42
+
43
+ /** True si hay un tunel publico activo hacia este server. */
44
+ export function tunnelActive() {
45
+ return Boolean(tunnelState());
31
46
  }
32
47
 
33
48
  export function parseTunnelmoleUrls(text) {
@@ -74,7 +89,7 @@ function spawnTunnel(bin, args, opts = {}) {
74
89
  settled = true;
75
90
  clearTimeout(timer);
76
91
  log(`${label}: ${url} -> localhost:${opts.port}`);
77
- resolve({ url, provider: opts.provider, pid: child.pid, stop: () => killTree(child) });
92
+ resolve({ url, provider: opts.provider, pid: child.pid, child, stop: () => killTree(child) });
78
93
  }
79
94
  };
80
95
  child.stdout.on('data', feed);
@@ -104,10 +119,156 @@ export function startTunnelmole(port, opts = {}) {
104
119
  });
105
120
  }
106
121
 
122
+ /**
123
+ * Relay propio (harness.tamnora.com): mailbox HTTP sobre cPanel (solo PHP).
124
+ * El daemon no recibe conexiones: llama hacia afuera (a=poll), ejecuta cada
125
+ * request contra el server local y empuja las respuestas (a=push).
126
+ */
127
+ export function startRelay(port, opts = {}) {
128
+ const relayUrl = String(opts.relayUrl ?? '').replace(/\/+$/, '');
129
+ const token = String(opts.token ?? '');
130
+ if (!relayUrl || !/^https?:\/\//.test(relayUrl)) {
131
+ throw new Error('relay: falta tunnel.relayUrl (https://harness.tamnora.com/index.php)');
132
+ }
133
+ if (!token) throw new Error('relay: falta tunnel.token');
134
+ const log = opts.log ?? (() => {});
135
+ const local = `http://127.0.0.1:${port}`;
136
+ const state = { stopped: false };
137
+
138
+ const call = async (action, body = {}, { timeoutMs = 30000 } = {}) => {
139
+ const res = await fetch(`${relayUrl}?a=${action}`, {
140
+ method: 'POST',
141
+ headers: { 'Content-Type': 'application/json' },
142
+ body: JSON.stringify({ token, ...body }),
143
+ signal: AbortSignal.timeout(timeoutMs),
144
+ });
145
+ if (!res.ok) throw new Error(`relay ${action}: HTTP ${res.status}`);
146
+ return res.json();
147
+ };
148
+
149
+ async function push(rid, chunks) {
150
+ await call('push', { rid, chunks }, { timeoutMs: 60000 });
151
+ }
152
+
153
+ async function handleRequest(row) {
154
+ const envelope = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
155
+ const method = envelope.method ?? 'GET';
156
+ const path = String(envelope.path ?? '/');
157
+ const target = `${local}${path.startsWith('/') ? path : `/${path}`}`;
158
+ const init = { method, headers: { ...envelope.headers } };
159
+ delete init.headers.host;
160
+ if (envelope.body != null) init.body = envelope.body;
161
+ const res = await fetch(target, init);
162
+ const contentType = String(res.headers.get('content-type') ?? '');
163
+ const flat = {};
164
+ for (const header of ['content-type']) {
165
+ const value = res.headers.get(header);
166
+ if (value) flat[header] = value;
167
+ }
168
+ const cookies = res.headers.getSetCookie?.() ?? [];
169
+ if (cookies.length) flat['set-cookie'] = cookies;
170
+
171
+ if (!contentType.includes('text/event-stream')) {
172
+ const buffer = Buffer.from(await res.arrayBuffer());
173
+ await push(row.id, [
174
+ {
175
+ status: res.status,
176
+ headers: flat,
177
+ body: buffer.toString('base64'),
178
+ done: true,
179
+ },
180
+ ]);
181
+ return;
182
+ }
183
+
184
+ // SSE: reenviamos cada frame tal cual (el cliente sabe reconectar con cursor).
185
+ let seq = 0;
186
+ await push(row.id, [{ seq: seq++, status: res.status, headers: flat, done: false }]);
187
+ const reader = res.body.getReader();
188
+ const decoder = new TextDecoder();
189
+ let buffer = '';
190
+ for (;;) {
191
+ const { value, done } = await reader.read();
192
+ if (done) break;
193
+ buffer += decoder.decode(value, { stream: true });
194
+ let index;
195
+ while ((index = buffer.indexOf('\n\n')) !== -1) {
196
+ const frame = buffer.slice(0, index);
197
+ buffer = buffer.slice(index + 2);
198
+ let ev = '';
199
+ let data = '';
200
+ let id = null;
201
+ for (const line of frame.split('\n')) {
202
+ if (line.startsWith('event:')) ev = line.slice(6).trim();
203
+ else if (line.startsWith('data:')) data += line.slice(5).trim();
204
+ else if (line.startsWith('id:')) id = line.slice(3).trim();
205
+ }
206
+ if (!ev && !data) continue;
207
+ await push(row.id, [{ seq: seq++, ev: ev || 'message', data, id, done: false }]);
208
+ }
209
+ }
210
+ await push(row.id, [{ done: true }]);
211
+ }
212
+
213
+ async function pump() {
214
+ const registered = await call('register', {}, { timeoutMs: 20000 });
215
+ let cursor = Number(registered.cursor) || 0;
216
+ log(`relay: conectado a ${relayUrl} (inbox desde ${cursor})`);
217
+ while (!state.stopped) {
218
+ let rows;
219
+ try {
220
+ const batch = await call('poll', { after: cursor, wait: 20 }, { timeoutMs: 40000 });
221
+ rows = batch.rows ?? [];
222
+ } catch (error) {
223
+ if (state.stopped) break;
224
+ log(`relay poll: ${error?.message ?? error}`);
225
+ await new Promise((r) => setTimeout(r, 3000));
226
+ continue;
227
+ }
228
+ for (const row of rows) {
229
+ cursor = Math.max(cursor, Number(row.id) || 0);
230
+ try {
231
+ await handleRequest(row);
232
+ } catch (error) {
233
+ log(`relay request ${row.id}: ${error?.message ?? error}`);
234
+ try {
235
+ await push(row.id, [
236
+ {
237
+ status: 502,
238
+ headers: { 'content-type': 'application/json; charset=utf-8' },
239
+ body: Buffer.from(JSON.stringify({ error: `harness inaccesible: ${error?.message ?? error}` })).toString('base64'),
240
+ done: true,
241
+ },
242
+ ]);
243
+ } catch {
244
+ /* nada */
245
+ }
246
+ }
247
+ }
248
+ }
249
+ }
250
+
251
+ pump().catch((error) => {
252
+ if (!state.stopped) log(`relay pump: ${error?.message ?? error}`);
253
+ });
254
+
255
+ return {
256
+ url: relayUrl,
257
+ provider: 'relay',
258
+ pid: 0,
259
+ child: null,
260
+ probeUrl: `${relayUrl}?a=status`,
261
+ stop() {
262
+ state.stopped = true;
263
+ },
264
+ };
265
+ }
266
+
107
267
  /** Devuelve { url, provider, pid, stop }. provider 'none' no abre nada. */
108
268
  export async function startTunnel(port, provider = 'tunnelmole', opts = {}) {
109
269
  const p = String(provider || 'tunnelmole').toLowerCase();
110
270
  if (p === 'none' || p === 'off' || p === '') return { url: '', provider: 'none', pid: 0, stop: () => {} };
111
271
  if (p === 'tunnelmole' || p === 'tmole') return startTunnelmole(port, opts);
272
+ if (p === 'relay') return startRelay(port, opts);
112
273
  throw new Error(`proveedor de tunel no soportado: ${provider}`);
113
274
  }
@@ -0,0 +1,19 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { openerFor } from '../src/open.js';
4
+
5
+ test('usa el abridor nativo de cada plataforma', () => {
6
+ // En Windows `rundll32` evita el shell: una URL con & o ? no debe pasar por cmd.
7
+ assert.deepEqual(openerFor('http://127.0.0.1:4517', 'win32'), [
8
+ 'rundll32.exe',
9
+ ['url.dll,FileProtocolHandler', 'http://127.0.0.1:4517'],
10
+ ]);
11
+ assert.deepEqual(openerFor('http://127.0.0.1:4517', 'darwin'), ['open', ['http://127.0.0.1:4517']]);
12
+ assert.deepEqual(openerFor('http://127.0.0.1:4517', 'linux'), ['xdg-open', ['http://127.0.0.1:4517']]);
13
+ });
14
+
15
+ test('mantiene la query string intacta', () => {
16
+ const url = 'http://127.0.0.1:4517/?a=1&b=2';
17
+ const [, args] = openerFor(url, 'linux');
18
+ assert.deepEqual(args, [url]);
19
+ });