@danieltmn/tamnora 0.8.0 → 0.9.1

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 (57) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/harness/packages/cli/src/cli.js +1 -1
  3. package/harness/packages/cli/src/commands/bridge.js +180 -119
  4. package/harness/packages/cli/src/commands/server.js +26 -50
  5. package/harness/packages/cli/src/lib/daemon.js +92 -40
  6. package/harness/packages/cli/test/release.test.js +9 -0
  7. package/harness/packages/core/src/agent/modes.js +1 -1
  8. package/harness/packages/core/src/index.js +14 -1
  9. package/harness/packages/core/src/open.js +43 -0
  10. package/harness/packages/core/src/pid.js +35 -0
  11. package/harness/packages/core/src/tools/executors.js +16 -0
  12. package/harness/packages/core/src/tools/labels.js +2 -0
  13. package/harness/packages/core/src/tools/permissions.js +1 -0
  14. package/harness/packages/core/src/tools/schema.js +17 -0
  15. package/harness/packages/core/src/tts/edge.js +232 -0
  16. package/harness/packages/core/src/tunnel.js +180 -19
  17. package/harness/packages/core/test/open.test.js +19 -0
  18. package/harness/packages/core/test/tools.test.js +12 -0
  19. package/harness/packages/core/test/tts.test.js +41 -0
  20. package/harness/packages/server/public/app.js +4665 -3975
  21. package/harness/packages/server/public/index.html +62 -5
  22. package/harness/packages/server/public/reconcile.js +121 -0
  23. package/harness/packages/server/public/relayClient.js +170 -0
  24. package/harness/packages/server/public/runStream.js +207 -0
  25. package/harness/packages/server/public/styles.css +142 -5
  26. package/harness/packages/server/src/auth.js +29 -5
  27. package/harness/packages/server/src/lib/agentRun.js +77 -0
  28. package/harness/packages/server/src/lib/announcer.js +92 -0
  29. package/harness/packages/server/src/lib/approvals.js +25 -3
  30. package/harness/packages/server/src/lib/channels/telegram.js +85 -20
  31. package/harness/packages/server/src/lib/ffmpeg.js +48 -0
  32. package/harness/packages/server/src/lib/procs.js +3 -18
  33. package/harness/packages/server/src/lib/runManager.js +24 -2
  34. package/harness/packages/server/src/lib/sessionView.js +43 -0
  35. package/harness/packages/server/src/lib/tasks.js +4 -9
  36. package/harness/packages/server/src/lib/ttsSettings.js +42 -0
  37. package/harness/packages/server/src/lib/workspace.js +37 -1
  38. package/harness/packages/server/src/routes/chat.js +48 -47
  39. package/harness/packages/server/src/routes/events.js +35 -0
  40. package/harness/packages/server/src/routes/files.js +22 -26
  41. package/harness/packages/server/src/routes/folders.js +9 -17
  42. package/harness/packages/server/src/routes/git.js +6 -14
  43. package/harness/packages/server/src/routes/procs.js +14 -16
  44. package/harness/packages/server/src/routes/sessions.js +12 -19
  45. package/harness/packages/server/src/routes/shell.js +5 -9
  46. package/harness/packages/server/src/routes/state.js +32 -0
  47. package/harness/packages/server/src/routes/tts.js +82 -0
  48. package/harness/packages/server/src/server.js +21 -1
  49. package/harness/packages/server/test/announcer.test.js +51 -0
  50. package/harness/packages/server/test/reconcile.test.js +146 -0
  51. package/harness/packages/server/test/relay.test.js +219 -0
  52. package/harness/packages/server/test/runStream.test.js +245 -0
  53. package/harness/packages/server/test/security.test.js +252 -0
  54. package/harness/packages/server/test/server.test.js +511 -1
  55. package/harness/packages/server/test/telegram-voz.test.js +156 -0
  56. package/harness/packages/server/test/tts.test.js +133 -0
  57. package/package.json +1 -1
@@ -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
+ });
@@ -157,3 +157,15 @@ test('bash runs inside the workspace root', async () => {
157
157
  assert.match(out, /hola/);
158
158
  assert.match(out, /\[exit 0\]/);
159
159
  });
160
+
161
+ test('la tool speak valida el texto y la recorta', async () => {
162
+ const root = await makeWorkspace();
163
+ const reg = createToolRegistry({ root });
164
+ assert.equal(evaluatePermission('speak', {}, {}), 'allow');
165
+ assert.match(await reg.execute('speak', {}), /falta "texto"/);
166
+ assert.match(await reg.execute('speak', { texto: 'Hola mundo' }), /Hola mundo/);
167
+ const largo = await reg.execute('speak', { texto: 'a'.repeat(2000) });
168
+ assert.ok(largo.length < 2000, 'el texto largo se recorta');
169
+ const { definitions } = createTools({ root: await makeWorkspace() });
170
+ assert.ok(definitions.some((d) => d.function.name === 'speak'));
171
+ });
@@ -0,0 +1,41 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { generarSecMsGec, generarSsml, sintetizar, MAX_TEXTO, VOZ_POR_DEFECTO } from '../src/index.js';
4
+
5
+ const GEC_1700000000000 = '42301B335578FEFDAE2637DED1ABD614505D432559EC08032B82048483726AFF';
6
+
7
+ test('generarSecMsGec es estable dentro de la ventana de 5 minutos', () => {
8
+ assert.equal(generarSecMsGec(1700000000000), GEC_1700000000000);
9
+ assert.equal(generarSecMsGec(1700000000000 + 59999), GEC_1700000000000);
10
+ assert.notEqual(generarSecMsGec(1700000000000 + 600000), GEC_1700000000000);
11
+ assert.match(generarSecMsGec(), /^[0-9A-F]{64}$/);
12
+ });
13
+
14
+ test('generarSsml escapa el texto y deduce el locale de la voz', () => {
15
+ const ssml = generarSsml({ voz: VOZ_POR_DEFECTO, rate: '+10%', pitch: '+0Hz', volume: '+0%', texto: 'hola <>&"\' mundo' });
16
+ assert.ok(ssml.includes('xml:lang="es-AR"'));
17
+ assert.ok(ssml.includes('<voice name="es-AR-ElenaNeural">'));
18
+ assert.ok(ssml.includes('rate="+10%"'));
19
+ assert.ok(!ssml.includes('hola <'));
20
+ assert.ok(ssml.includes('&lt;&gt;&amp;&quot;&apos;'));
21
+ });
22
+
23
+ test('generarSsml rechaza voces sin locale deducible', () => {
24
+ assert.throws(() => generarSsml({ voz: 'Elena', rate: '+0%', pitch: '+0Hz', volume: '+0%', texto: 'x' }), /locale/);
25
+ });
26
+
27
+ test('sintetizar rechaza texto vacio y señales ya abortadas sin abrir socket', () => {
28
+ assert.throws(() => sintetizar(' '), /vac/);
29
+ const controller = new AbortController();
30
+ controller.abort();
31
+ assert.throws(() => sintetizar('hola', { signal: controller.signal }), /cancel/);
32
+ });
33
+
34
+ test('sintetizar recorta el texto al tope del servicio', () => {
35
+ // El recorte ocurre antes de abrir el socket: con señal ya abortada el error
36
+ // de cancelacion llega antes que cualquier intento de conexion.
37
+ const controller = new AbortController();
38
+ controller.abort();
39
+ const largo = 'x'.repeat(MAX_TEXTO + 100);
40
+ assert.throws(() => sintetizar(largo, { signal: controller.signal }), /cancel/);
41
+ });