@danieltmn/tamnora 0.8.0 → 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.
- package/CHANGELOG.md +83 -0
- package/harness/packages/cli/src/cli.js +1 -1
- package/harness/packages/cli/src/commands/bridge.js +180 -119
- package/harness/packages/cli/src/commands/server.js +26 -50
- package/harness/packages/cli/src/lib/daemon.js +92 -40
- package/harness/packages/cli/test/release.test.js +9 -0
- package/harness/packages/core/src/agent/modes.js +1 -1
- package/harness/packages/core/src/index.js +14 -1
- package/harness/packages/core/src/open.js +43 -0
- package/harness/packages/core/src/pid.js +35 -0
- package/harness/packages/core/src/tools/executors.js +16 -0
- package/harness/packages/core/src/tools/labels.js +2 -0
- package/harness/packages/core/src/tools/permissions.js +1 -0
- package/harness/packages/core/src/tools/schema.js +17 -0
- package/harness/packages/core/src/tts/edge.js +232 -0
- package/harness/packages/core/src/tunnel.js +180 -19
- package/harness/packages/core/test/open.test.js +19 -0
- package/harness/packages/core/test/tools.test.js +12 -0
- package/harness/packages/core/test/tts.test.js +41 -0
- package/harness/packages/server/public/app.js +4715 -3975
- package/harness/packages/server/public/index.html +63 -5
- package/harness/packages/server/public/reconcile.js +121 -0
- package/harness/packages/server/public/relayClient.js +170 -0
- package/harness/packages/server/public/runStream.js +207 -0
- package/harness/packages/server/public/styles.css +142 -5
- package/harness/packages/server/src/auth.js +29 -5
- package/harness/packages/server/src/lib/agentRun.js +77 -0
- package/harness/packages/server/src/lib/announcer.js +92 -0
- package/harness/packages/server/src/lib/approvals.js +25 -3
- package/harness/packages/server/src/lib/channels/telegram.js +85 -20
- package/harness/packages/server/src/lib/ffmpeg.js +48 -0
- package/harness/packages/server/src/lib/procs.js +3 -18
- package/harness/packages/server/src/lib/runManager.js +24 -2
- package/harness/packages/server/src/lib/sessionView.js +43 -0
- package/harness/packages/server/src/lib/tasks.js +4 -9
- package/harness/packages/server/src/lib/ttsSettings.js +42 -0
- package/harness/packages/server/src/lib/workspace.js +37 -1
- package/harness/packages/server/src/routes/chat.js +48 -47
- package/harness/packages/server/src/routes/events.js +35 -0
- package/harness/packages/server/src/routes/files.js +22 -26
- package/harness/packages/server/src/routes/folders.js +9 -17
- package/harness/packages/server/src/routes/git.js +6 -14
- package/harness/packages/server/src/routes/procs.js +14 -16
- package/harness/packages/server/src/routes/sessions.js +12 -19
- package/harness/packages/server/src/routes/shell.js +5 -9
- package/harness/packages/server/src/routes/state.js +32 -0
- package/harness/packages/server/src/routes/tts.js +82 -0
- package/harness/packages/server/src/server.js +21 -1
- package/harness/packages/server/test/announcer.test.js +51 -0
- package/harness/packages/server/test/reconcile.test.js +146 -0
- package/harness/packages/server/test/relay.test.js +219 -0
- package/harness/packages/server/test/runStream.test.js +245 -0
- package/harness/packages/server/test/security.test.js +252 -0
- package/harness/packages/server/test/server.test.js +511 -1
- package/harness/packages/server/test/telegram-voz.test.js +156 -0
- package/harness/packages/server/test/tts.test.js +133 -0
- 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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
/*
|
|
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('<>&"''));
|
|
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
|
+
});
|