@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.
- package/CHANGELOG.md +95 -0
- package/README.md +9 -0
- package/SECURITY.md +11 -6
- 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/run.js +1 -1
- package/harness/packages/cli/src/commands/server.js +26 -50
- package/harness/packages/cli/src/commands/tui.js +1 -1
- 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/agent/runAgent.js +3 -0
- package/harness/packages/core/src/git/index.js +177 -0
- package/harness/packages/core/src/index.js +16 -1
- package/harness/packages/core/src/open.js +43 -0
- package/harness/packages/core/src/pid.js +35 -0
- package/harness/packages/core/src/procs/kill.js +95 -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/procs.test.js +50 -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 -3669
- package/harness/packages/server/public/index.html +94 -5
- package/harness/packages/server/public/landing/index.html +5 -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 +196 -23
- 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 +51 -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 +35 -0
- package/harness/packages/server/src/routes/procs.js +25 -15
- package/harness/packages/server/src/routes/sessions.js +12 -19
- package/harness/packages/server/src/routes/shell.js +19 -0
- 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 +25 -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 +617 -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
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,101 @@ 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.9.0] - 2026-10-07
|
|
9
|
+
|
|
10
|
+
### Agregado
|
|
11
|
+
|
|
12
|
+
- **Voz (TTS)**: el agente **narra su progreso** con frases cortas armadas por
|
|
13
|
+
plantillas sobre los eventos del run (nada de leer la respuesta) y las adjunta
|
|
14
|
+
como `say`. La consola las reproduce y Telegram manda una **nota de voz** al
|
|
15
|
+
cerrar el turno. El cliente (`core/src/tts/edge.js`) usa el **WebSocket global
|
|
16
|
+
de Node** contra las voces de Edge Read Aloud: gratis, sin API key ni
|
|
17
|
+
dependencias. Ajustes en la pestaña Voz (`app.json` -> `tts`).
|
|
18
|
+
- **Tool `speak`**: el agente puede pedir leer un texto en voz alta (por ejemplo,
|
|
19
|
+
un resumen en audio cuando se lo pedís). La consola lo reproduce en la tarjeta
|
|
20
|
+
del tool y Telegram lo manda como nota de voz.
|
|
21
|
+
- **`GET /api/state`, la verdad del servidor**: un snapshot chico con
|
|
22
|
+
`running`/`awaiting` de cada sesion y los permisos pendientes, en un solo
|
|
23
|
+
request y **sin mensajes**. Antes la consola necesitaba dos o tres round trips
|
|
24
|
+
por ciclo, y sobre el tunel cada uno es caro y una oportunidad de fallar.
|
|
25
|
+
- **Reconciliador con la logica testeada** (`public/reconcile.js` +
|
|
26
|
+
`test/reconcile.test.js`): `diffState` es puro y decide enganchar, soltar,
|
|
27
|
+
recargar y que permisos son nuevos o resueltos. Los SSE pasaron a ser **atajos
|
|
28
|
+
de latencia**: si se caen se pierde inmediatez, no correctitud. Antes la misma
|
|
29
|
+
decision estaba repartida en el canal global, un sweep y el handler de
|
|
30
|
+
permisos, y se contradijian.
|
|
31
|
+
- **Cadencia adaptativa**: 5 s cuando hay un permiso esperando una decision tuya,
|
|
32
|
+
10 s con un run activo, 25 s idle.
|
|
33
|
+
- **Banner de permisos** (`#perm-banner`): no descartable, en el composer, con
|
|
34
|
+
`denegar` / `permitir una vez` / `autorizar todo`. Se puede decidir **desde
|
|
35
|
+
cualquier pestana o sesion**, sin estar enganchado al run.
|
|
36
|
+
- **Los argumentos de un permiso se leen en una linea**: `summarizeArgs` (puro,
|
|
37
|
+
en `reconcile.js`) muestra el comando de `bash`, la ruta y el tamano de `write`,
|
|
38
|
+
o la url de `fetch_url`. Antes se volcaba el JSON crudo y al pedir permiso para
|
|
39
|
+
escribir un archivo se veia medio archivo con los `\n` escapados.
|
|
40
|
+
- **Señales cuando la pestana esta en background**: `document.title` con `(⚠ n)`
|
|
41
|
+
y favicon con punto rojo. Con iOS mandando la app a background son las unicas
|
|
42
|
+
que quedan.
|
|
43
|
+
- **El server abre la consola en el navegador**: al quedar escuchando, `tamnora
|
|
44
|
+
server` abre la URL en el navegador predeterminado (tambien cuando ya estaba
|
|
45
|
+
corriendo). Se apaga con `--no-open`. New `core/src/open.js`; en Windows usa
|
|
46
|
+
`rundll32` para no pasar la URL por un shell.
|
|
47
|
+
- **SSE resumible** (`server/public/runStream.js`): el watchdog de silencio (45 s;
|
|
48
|
+
30 s para el canal global) detecta el stream colgado en el que
|
|
49
|
+
`reader.read()` espera para siempre —tipico en un corte de tunel— y reconecta
|
|
50
|
+
con backoff desde el ultimo cursor, sin repintar el pane.
|
|
51
|
+
- **Canal global de eventos** (`GET /api/events`): un SSE por pestana con
|
|
52
|
+
`run_start`/`run_end`, permisos y un `snapshot`. Sirve para enterarse al
|
|
53
|
+
instante de una tarea pedida desde otro dispositivo.
|
|
54
|
+
- **Varios clientes en la misma sesion**: al pedir una tarea mientras otro cliente
|
|
55
|
+
tiene el turno, el mensaje va a la cola visible en vez de desaparecer; un
|
|
56
|
+
cliente observador ahora dibuja el mensaje del usuario (se emitia antes de
|
|
57
|
+
persistirse).
|
|
58
|
+
|
|
59
|
+
### Corregido
|
|
60
|
+
|
|
61
|
+
- **Aprobar un permiso ya no lo hace reaparecer en bucle**: `decide()` se
|
|
62
|
+
tragaba el error del `POST` (`.catch(() => {})`). La UI se cerraba igual,
|
|
63
|
+
borraba el permiso local y el siguiente snapshot lo volvia a traer porque el
|
|
64
|
+
server seguia tenerlo pendiente: la tarjeta reaparecia una y otra vez sin
|
|
65
|
+
decir por que. Ahora el fallo **se avisa y se revierte**, y un 404 se
|
|
66
|
+
distingue como "ese permiso ya no aplicaba" en vez de dejarlo esperando.
|
|
67
|
+
- **Los permisos ya no se esconden**: eran estado invisible que solo existia
|
|
68
|
+
dentro del stream SSE de su sesion. Con el celular sin ese stream (o con el
|
|
69
|
+
tunel caido) la IA se quedaba esperando una decision que no aparecia en ningun
|
|
70
|
+
lado, y recargar la pagina dejaba el run bloqueado para siempre. Ahora son
|
|
71
|
+
estado de primera clase y se pueden resolver desde donde estes mirando.
|
|
72
|
+
- **Un permiso ya no desaparece al re-renderizar**: `renderPane` borra el pane
|
|
73
|
+
entero y los permisos no son mensajes, asi que la tarjeta se ve y al primer
|
|
74
|
+
re-render se perdia para siempre. Ahora se repintan.
|
|
75
|
+
- **Los fallos de red se ven**: cada camino era un `.catch(() => null)`, con lo
|
|
76
|
+
que un fallo se veía **exactamente igual** que "no hay nada pendiente" (y por eso
|
|
77
|
+
no se podia diagnosticar nada). Ahora hay backoff, `#sb-sync` con el motivo en
|
|
78
|
+
el tooltip y avisos flotantes.
|
|
79
|
+
- **Un error de la consola ya no se disfraza de corte de red**: si el handler de
|
|
80
|
+
un evento tiraba una excepcion, `streamSse` la propagaba como si fuera una
|
|
81
|
+
caida del transporte y se reintentaba en bucle — el sintoma era un
|
|
82
|
+
"reconectando..." perpetuo que no contaba nada. Ahora se propaga marcado como
|
|
83
|
+
error del handler, no se reintenta y se avisa con el mensaje real.
|
|
84
|
+
- **`tamnora server --stop` podia quedar colgado**: `close()` nunca destruia los
|
|
85
|
+
sockets SSE abiertos, asi que esperaba indefinidamente.
|
|
86
|
+
- **Cierre del server**: la base SQLite se cerraba antes de cortar las
|
|
87
|
+
conexiones, y un run abortado terminaba escribiendo sobre una base cerrada.
|
|
88
|
+
- Se mano `attachRun` sin reconexion: un corte de tunel dejaba el turno en
|
|
89
|
+
"pensando..." para siempre sin error que reintentar.
|
|
90
|
+
|
|
91
|
+
## [0.8.0] - 2026-10-06
|
|
92
|
+
|
|
93
|
+
### Documentacion
|
|
94
|
+
|
|
95
|
+
- **Revision de docs y sitio**: se audito cada afirmacion contra el codigo y se
|
|
96
|
+
corrigieron varias inexactas (las API keys **no** se cifran —van en texto plano
|
|
97
|
+
en SQLite—; `--max-steps` no tiene default `8`; "nada se sube a terceros" es
|
|
98
|
+
falso si se elige un proveedor remoto). Se sumaron al sitio y a la guia las
|
|
99
|
+
features de 0.7.1 (tablas markdown, saltos de scroll, agente autonomo y MCP
|
|
100
|
+
con entorno confinado/redaccion). Actualiza `README.md`, `document.md`,
|
|
101
|
+
`AGENTS.md`, `SECURITY.md`, `website/` y la landing.
|
|
102
|
+
|
|
8
103
|
## [0.7.1] - 2026-10-06
|
|
9
104
|
|
|
10
105
|
### Agregado
|
package/README.md
CHANGED
|
@@ -84,6 +84,10 @@ menu "mas" (los tres puntitos). En el chat:
|
|
|
84
84
|
- **Actividad colapsable**: las tools de lectura (`read`, `list`, `glob`, `grep`,
|
|
85
85
|
`fetch_url`, `git_*`) salen **plegadas** y al hacer click se ve el detalle
|
|
86
86
|
(args + resultado); el volcado completo se pide bajo demanda.
|
|
87
|
+
- **Markdown con tablas**: las respuestas renderizan tablas (`|`, con alineacion
|
|
88
|
+
y celdas saneadas) y botones flotantes para saltar al inicio/final del turno.
|
|
89
|
+
- **Acciones por mensaje**: copiar, editar, regenerar y borrar se agrupan en un
|
|
90
|
+
menu `⋮`; el indicador de "pensando/trabajando" vive en el pie (`#sb-activity`).
|
|
87
91
|
- **Runs durables**: cerrar el celular o cortar la red **no cancela** el turno; al
|
|
88
92
|
volver, la consola **reconecta** y reconstruye el progreso. Para cortarlo se usa
|
|
89
93
|
el boton de detener (o `POST /api/sessions/:id/stop`).
|
|
@@ -149,6 +153,11 @@ stdio. Las tools se exponen como `mcp__<servidor>__<tool>` y, si el servidor los
|
|
|
149
153
|
anuncia, tambien sus **recursos** y **prompts** (`list_resources`,
|
|
150
154
|
`read_resource`, `list_prompts`, `get_prompt`).
|
|
151
155
|
|
|
156
|
+
Los MCP stdio **no heredan `process.env`**: reciben un baseline minimo mas el
|
|
157
|
+
`env` declarado (`"KEY": "env:VAR"`), y sus resultados pasan por `redactSecrets`
|
|
158
|
+
(ver [`SECURITY.md`](SECURITY.md)). Para un servidor de confianza que necesite el
|
|
159
|
+
entorno completo, `"inheritEnv": true`.
|
|
160
|
+
|
|
152
161
|
## Tools
|
|
153
162
|
|
|
154
163
|
Tools locales confinadas al workspace (`read`, `list`, `glob`, `grep`, `write`,
|
package/SECURITY.md
CHANGED
|
@@ -15,12 +15,17 @@ asi que se lo debe tratar como codigo con acceso a tu usuario.
|
|
|
15
15
|
- **Local (loopback)**: quien ya tiene tu sesion de usuario ya puede correr
|
|
16
16
|
comandos; el server no agrega una barrera adicional mas alla del login.
|
|
17
17
|
- **Acceso remoto por tunel (tunnelmole)**: la URL publica es accesible desde
|
|
18
|
-
Internet.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
Internet. Configura un **usuario** (asi toda `/api/*` pide sesion) con una
|
|
19
|
+
**contrasena fuerte**; nunca expongas un server sin login. El tunnel reenvia
|
|
20
|
+
por loopback, de modo que una vez autenticado se alcanza toda la consola
|
|
21
|
+
(incluidos el explorador de archivos y el selector de carpetas): tratalo como
|
|
22
|
+
acceso completo a tu maquina. Cierra el tunnel (`tamnora bridge --stop`) cuando
|
|
23
|
+
no lo uses.
|
|
24
|
+
- **Credenciales**: las API keys de proveedores se guardan en la base SQLite de
|
|
25
|
+
la casa local (fuera del repo) **en texto plano**; protege el archivo con los
|
|
26
|
+
permisos del sistema. Para no persistirlas, usa `"apiKey": "env:VAR"` y
|
|
27
|
+
define la variable en el entorno. Nunca se commitean y se devuelven
|
|
28
|
+
enmascaradas al cliente (`apiKeyHint`).
|
|
24
29
|
- **Servidores MCP**: tratalos como codigo de terceros. Los MCP stdio **no heredan
|
|
25
30
|
`process.env`** por defecto: solo reciben un baseline minimo mas el `env` que
|
|
26
31
|
declares (`"env": { "TOKEN": "env:TOKEN" }`); `inheritEnv: true` es solo para
|
|
@@ -70,7 +70,7 @@ Uso:
|
|
|
70
70
|
tamnora run "<prompt>" Ejecutar una tarea headless
|
|
71
71
|
tamnora eval <dataset.jsonl> Correr un dataset de evaluación
|
|
72
72
|
tamnora mcp Listar servidores y herramientas MCP
|
|
73
|
-
tamnora server [--init|--start|--reload|--stop|--status|--logs] Server en segundo plano
|
|
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
75
|
tamnora bridge [--start|--reload|--stop|--status] Tunel publico (tunnelmole) + QR
|
|
76
76
|
tamnora autostart install|remove Arranque automatico del server
|
|
@@ -1,16 +1,23 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { readFileSync, writeFileSync, unlinkSync, appendFileSync } from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
1
|
import {
|
|
5
|
-
setHome,
|
|
6
2
|
homeDir,
|
|
7
3
|
ensureHome,
|
|
8
4
|
ensureApp,
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
writeApp,
|
|
6
|
+
readApp,
|
|
7
|
+
randomToken,
|
|
11
8
|
} from '../../../core/src/index.js';
|
|
12
9
|
import { parse, num } from '../lib/util.js';
|
|
13
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
applyHomeOverride,
|
|
12
|
+
killPid,
|
|
13
|
+
pidAlive,
|
|
14
|
+
bridgeState,
|
|
15
|
+
createFileLogger,
|
|
16
|
+
spawnDetached,
|
|
17
|
+
waitForState,
|
|
18
|
+
tailLines,
|
|
19
|
+
bridgeLogPath,
|
|
20
|
+
} from '../lib/daemon.js';
|
|
14
21
|
import { startTunnel } from '../lib/tunnel.js';
|
|
15
22
|
import { qrTerminal } from '../lib/qr.js';
|
|
16
23
|
|
|
@@ -21,6 +28,7 @@ const OPTIONS = {
|
|
|
21
28
|
status: { type: 'boolean' },
|
|
22
29
|
foreground: { type: 'boolean' },
|
|
23
30
|
stream: { type: 'boolean' },
|
|
31
|
+
insecure: { type: 'boolean' },
|
|
24
32
|
tunnel: { type: 'string' },
|
|
25
33
|
port: { type: 'string' },
|
|
26
34
|
home: { type: 'string' },
|
|
@@ -28,26 +36,9 @@ const OPTIONS = {
|
|
|
28
36
|
help: { type: 'boolean', short: 'h' },
|
|
29
37
|
};
|
|
30
38
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
} catch {
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function writeBridgeState(data) {
|
|
40
|
-
ensureHome();
|
|
41
|
-
writeFileSync(bridgeStatePath(), `${JSON.stringify(data, null, 2)}\n`);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function clearBridgeState() {
|
|
45
|
-
try {
|
|
46
|
-
unlinkSync(bridgeStatePath());
|
|
47
|
-
} catch {
|
|
48
|
-
/* nada */
|
|
49
|
-
}
|
|
50
|
-
}
|
|
39
|
+
const readBridgeState = () => bridgeState.read();
|
|
40
|
+
const writeBridgeState = (data) => bridgeState.write(data);
|
|
41
|
+
const clearBridgeState = () => bridgeState.clear();
|
|
51
42
|
|
|
52
43
|
export async function bridgeCommand(argv) {
|
|
53
44
|
const { values } = parse(argv, OPTIONS);
|
|
@@ -56,8 +47,7 @@ export async function bridgeCommand(argv) {
|
|
|
56
47
|
return 0;
|
|
57
48
|
}
|
|
58
49
|
if (values.home) {
|
|
59
|
-
|
|
60
|
-
process.env.TAMNORA_HOME = path.resolve(values.home);
|
|
50
|
+
applyHomeOverride(values);
|
|
61
51
|
}
|
|
62
52
|
|
|
63
53
|
if (values.stop) return cmdStop();
|
|
@@ -75,9 +65,25 @@ function resolve(values) {
|
|
|
75
65
|
return {
|
|
76
66
|
port: num(values.port, app.port || 4517),
|
|
77
67
|
provider: String(values.tunnel || app.tunnel?.provider || 'tunnelmole').toLowerCase(),
|
|
68
|
+
app,
|
|
78
69
|
};
|
|
79
70
|
}
|
|
80
71
|
|
|
72
|
+
/** Asegura la configuracion del relay (url + token) en app.json y la devuelve. */
|
|
73
|
+
function ensureRelayConfig(app) {
|
|
74
|
+
const tunnel = { ...app.tunnel };
|
|
75
|
+
if (!tunnel.relayUrl) {
|
|
76
|
+
tunnel.relayUrl = process.env.TAMNORA_RELAY_URL || 'https://harness.tamnora.com/index.php';
|
|
77
|
+
}
|
|
78
|
+
if (!tunnel.token) {
|
|
79
|
+
tunnel.token = process.env.TAMNORA_RELAY_TOKEN || randomToken(24);
|
|
80
|
+
const current = readApp();
|
|
81
|
+
writeApp({ ...current, tunnel: { ...current.tunnel, token: tunnel.token, relayUrl: tunnel.relayUrl } });
|
|
82
|
+
console.log(`Token de relay nuevo (guardado en app.json): ${tunnel.token}`);
|
|
83
|
+
}
|
|
84
|
+
return tunnel;
|
|
85
|
+
}
|
|
86
|
+
|
|
81
87
|
async function cmdStart(values) {
|
|
82
88
|
const { port, provider } = resolve(values);
|
|
83
89
|
if (provider === 'none') {
|
|
@@ -85,6 +91,20 @@ async function cmdStart(values) {
|
|
|
85
91
|
return 1;
|
|
86
92
|
}
|
|
87
93
|
|
|
94
|
+
// Exponer el server a Internet sin usuario configurado deja /api/* abierto
|
|
95
|
+
// (incluido /api/chat con tool bash): hay que pedirlo explicito.
|
|
96
|
+
const app = ensureApp();
|
|
97
|
+
if (!app.user?.password && !values.insecure) {
|
|
98
|
+
console.error('No hay usuario configurado: por el tunel cualquiera con la URL tendria acceso total.');
|
|
99
|
+
console.error('Crea uno con: tamnora user --name <usuario> --password <clave>');
|
|
100
|
+
console.error('Si igual queres abrirlo sin proteccion: tamnora bridge --insecure');
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
if (provider === 'tunnelmole' || provider === 'tmole') {
|
|
104
|
+
console.log('Aviso: tunnelmole gratuito admite una sola conexion; si abriste el');
|
|
105
|
+
console.log('preview de la consola, cerralo (o el bridge) antes de usar el otro.');
|
|
106
|
+
}
|
|
107
|
+
|
|
88
108
|
const current = readBridgeState();
|
|
89
109
|
if (current && pidAlive(current.pid)) {
|
|
90
110
|
console.log(`Bridge ya esta corriendo (pid ${current.pid}).`);
|
|
@@ -96,22 +116,17 @@ async function cmdStart(values) {
|
|
|
96
116
|
const args = [bin, 'bridge', '--foreground', '--port', String(port), '--tunnel', provider];
|
|
97
117
|
if (process.env.TAMNORA_HOME) args.push('--home', process.env.TAMNORA_HOME);
|
|
98
118
|
if (values.verbose) args.push('--verbose');
|
|
99
|
-
const child =
|
|
100
|
-
detached: true,
|
|
101
|
-
stdio: 'ignore',
|
|
102
|
-
windowsHide: true,
|
|
103
|
-
env: { ...process.env, TAMNORA_BRIDGE_CHILD: '1' },
|
|
104
|
-
});
|
|
105
|
-
child.unref();
|
|
106
|
-
let dead = false;
|
|
107
|
-
child.on('exit', () => {
|
|
108
|
-
dead = true;
|
|
109
|
-
});
|
|
119
|
+
const { child, isDead } = spawnDetached(args, { envFlag: 'TAMNORA_BRIDGE_CHILD' });
|
|
110
120
|
console.log(`Abriendo tunel (${provider}) en el puerto ${port}...`);
|
|
111
121
|
|
|
112
|
-
const state = await
|
|
122
|
+
const state = await waitForState(() => readBridgeState(), {
|
|
123
|
+
childPid: child.pid,
|
|
124
|
+
isDead,
|
|
125
|
+
timeoutMs: 90000,
|
|
126
|
+
tickMs: 500,
|
|
127
|
+
});
|
|
113
128
|
if (!state) {
|
|
114
|
-
const tail =
|
|
129
|
+
const tail = tailLines(bridgeLogPath(), 8);
|
|
115
130
|
if (tail) console.log(tail);
|
|
116
131
|
console.log('No pude abrir el tunel. Revisa: tamnora bridge --status');
|
|
117
132
|
return 1;
|
|
@@ -120,64 +135,129 @@ async function cmdStart(values) {
|
|
|
120
135
|
return 0;
|
|
121
136
|
}
|
|
122
137
|
|
|
138
|
+
/** URL publica para el celular: la consola agregada al relay con el token. */
|
|
139
|
+
function relayConsoleUrl(extra) {
|
|
140
|
+
const base = String(extra.relayUrl ?? '').replace(/\/+$/, '').replace(/index\.php$/, '').replace(/\/$/, '');
|
|
141
|
+
return `${base}/console/?t=${extra.token}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
123
144
|
async function runForeground(values) {
|
|
124
|
-
const { port, provider } = resolve(values);
|
|
125
|
-
const log = (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
145
|
+
const { port, provider, app } = resolve(values);
|
|
146
|
+
const log = createFileLogger(bridgeLogPath());
|
|
147
|
+
const extra = { provider };
|
|
148
|
+
if (provider === 'relay') {
|
|
149
|
+
const relay = ensureRelayConfig(app);
|
|
150
|
+
extra.relayUrl = relay.relayUrl;
|
|
151
|
+
extra.token = relay.token;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Senal de apagado: el supervisor de abajo la consulta entre intentos.
|
|
155
|
+
let interrupted = false;
|
|
156
|
+
let notifyInterrupt = null;
|
|
157
|
+
const interruptPromise = new Promise((r) => {
|
|
158
|
+
notifyInterrupt = r;
|
|
159
|
+
});
|
|
160
|
+
const shutdown = () => {
|
|
161
|
+
interrupted = true;
|
|
162
|
+
notifyInterrupt();
|
|
134
163
|
};
|
|
164
|
+
process.once('SIGINT', shutdown);
|
|
165
|
+
process.once('SIGTERM', shutdown);
|
|
166
|
+
const sleep = (ms) =>
|
|
167
|
+
new Promise((r) => {
|
|
168
|
+
const t = setTimeout(r, ms);
|
|
169
|
+
interruptPromise.then(() => {
|
|
170
|
+
clearTimeout(t);
|
|
171
|
+
r();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
135
174
|
|
|
175
|
+
// Supervision: si el proceso del tunel muere (el servicio gratuito lo hace a
|
|
176
|
+
// menudo), reconecta con backoff y reescribe el estado con la URL nueva.
|
|
177
|
+
let attempt = 0;
|
|
136
178
|
let tun;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
179
|
+
for (;;) {
|
|
180
|
+
try {
|
|
181
|
+
tun = await startTunnel(port, provider, { log, verbose: values.verbose, ...extra });
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (interrupted) break;
|
|
184
|
+
log(`ERROR: ${err.message}`);
|
|
185
|
+
return 1;
|
|
186
|
+
}
|
|
187
|
+
if (!tun.url) {
|
|
188
|
+
log('ERROR: el tunel no devolvio URL.');
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
147
191
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
192
|
+
writeBridgeState({
|
|
193
|
+
pid: process.pid,
|
|
194
|
+
url: tun.url,
|
|
195
|
+
publicUrl: provider === 'relay' ? relayConsoleUrl(extra) : undefined,
|
|
196
|
+
provider: tun.provider,
|
|
197
|
+
port,
|
|
198
|
+
childPid: tun.pid,
|
|
199
|
+
startedAt: new Date().toISOString(),
|
|
200
|
+
});
|
|
201
|
+
printStatus(readBridgeState(), true);
|
|
202
|
+
log(`Tunel publico: ${tun.url}`);
|
|
203
|
+
attempt = 0;
|
|
158
204
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
205
|
+
const exitCode = await new Promise((r) => {
|
|
206
|
+
// Health-check cada 60 s: si la URL publica deja de responder dos veces
|
|
207
|
+
// seguidas, matamos el tunel y el supervisor abre otro.
|
|
208
|
+
let fails = 0;
|
|
209
|
+
const probe = async () => {
|
|
210
|
+
const probeUrl = tun.probeUrl ?? `${tun.url}/api/health`;
|
|
211
|
+
try {
|
|
212
|
+
const res = await fetch(probeUrl, { signal: AbortSignal.timeout(8000) });
|
|
213
|
+
if (res.ok) fails = 0;
|
|
214
|
+
else fails += 1;
|
|
215
|
+
} catch {
|
|
216
|
+
fails += 1;
|
|
217
|
+
}
|
|
218
|
+
if (fails >= 2) {
|
|
219
|
+
log(`La URL publica no responde (${tun.url}); reiniciando el tunel...`);
|
|
220
|
+
tun.stop();
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
const health = setInterval(probe, 60000);
|
|
224
|
+
health.unref?.();
|
|
225
|
+
tun.child?.once('exit', (code) => {
|
|
226
|
+
clearInterval(health);
|
|
227
|
+
r(code ?? 1);
|
|
228
|
+
});
|
|
229
|
+
if (!tun.child) {
|
|
230
|
+
clearInterval(health);
|
|
231
|
+
r(0);
|
|
168
232
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
233
|
+
interruptPromise.then(() => {
|
|
234
|
+
clearInterval(health);
|
|
235
|
+
r('signal');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (interrupted || exitCode === 'signal') break;
|
|
240
|
+
attempt += 1;
|
|
241
|
+
const wait = Math.min(30000, 2000 * attempt);
|
|
242
|
+
log(`El tunel termino (codigo ${exitCode}); reconectando en ${Math.round(wait / 1000)} s...`);
|
|
243
|
+
await sleep(wait);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
tun?.stop?.();
|
|
248
|
+
} catch {
|
|
249
|
+
/* nada */
|
|
250
|
+
}
|
|
251
|
+
clearBridgeState();
|
|
252
|
+
return 0;
|
|
175
253
|
}
|
|
176
254
|
|
|
177
255
|
async function cmdReload(values) {
|
|
256
|
+
// Leer el estado ANTES de parar: clearBridgeState borra el archivo y despues
|
|
257
|
+
// no habria nada que esperar (el tunel viejo seguia muriendo).
|
|
258
|
+
const previous = readBridgeState();
|
|
178
259
|
await cmdStop();
|
|
179
|
-
|
|
180
|
-
for (let i = 0; i < 25 && current && pidAlive(current.pid); i++) {
|
|
260
|
+
for (let i = 0; i < 25 && previous?.pid && pidAlive(previous.pid); i++) {
|
|
181
261
|
await new Promise((r) => setTimeout(r, 200));
|
|
182
262
|
}
|
|
183
263
|
return cmdStart(values);
|
|
@@ -216,45 +296,21 @@ async function cmdStatus() {
|
|
|
216
296
|
}
|
|
217
297
|
|
|
218
298
|
function printStatus(state, withQr = false) {
|
|
299
|
+
const shown = state.publicUrl || state.url;
|
|
219
300
|
console.log(`Bridge corriendo (pid ${state.pid})`);
|
|
220
|
-
console.log(` url : ${
|
|
301
|
+
console.log(` url : ${shown}`);
|
|
221
302
|
console.log(` proveedor: ${state.provider} puerto local: ${state.port}`);
|
|
222
303
|
console.log(` casa : ${homeDir()}`);
|
|
223
304
|
if (withQr && state.url) {
|
|
224
|
-
const art = qrTerminal(
|
|
305
|
+
const art = qrTerminal(shown, 2);
|
|
225
306
|
if (art) {
|
|
226
307
|
console.log('');
|
|
227
308
|
console.log(art);
|
|
228
|
-
console.log(` escanea para abrir ${
|
|
309
|
+
console.log(` escanea para abrir ${shown}`);
|
|
229
310
|
}
|
|
230
311
|
}
|
|
231
312
|
}
|
|
232
313
|
|
|
233
|
-
function tailBridgeLog(n) {
|
|
234
|
-
try {
|
|
235
|
-
const lines = readFileSync(bridgeLogPath(), 'utf8')
|
|
236
|
-
.split(/\r?\n/)
|
|
237
|
-
.filter((l) => l.trim() !== '');
|
|
238
|
-
return lines.slice(-n).join('\n');
|
|
239
|
-
} catch {
|
|
240
|
-
return '';
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
function waitForBridge(childPid, isDead, timeoutMs) {
|
|
245
|
-
return new Promise((resolve) => {
|
|
246
|
-
const start = Date.now();
|
|
247
|
-
const tick = () => {
|
|
248
|
-
if (isDead()) return resolve(null);
|
|
249
|
-
const state = readBridgeState();
|
|
250
|
-
if (state && state.pid && pidAlive(state.pid) && (!childPid || state.pid === childPid)) return resolve(state);
|
|
251
|
-
if (Date.now() - start > timeoutMs) return resolve(null);
|
|
252
|
-
setTimeout(tick, 500);
|
|
253
|
-
};
|
|
254
|
-
tick();
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
|
|
258
314
|
function printHelp() {
|
|
259
315
|
console.log(`Uso: tamnora bridge [accion] [opciones]
|
|
260
316
|
|
|
@@ -268,7 +324,12 @@ Acciones:
|
|
|
268
324
|
--foreground Corre en primer plano (debug)
|
|
269
325
|
|
|
270
326
|
Opciones:
|
|
271
|
-
--tunnel <prov> Proveedor (default: el de app.json; hoy: tunnelmole)
|
|
327
|
+
--tunnel <prov> Proveedor (default: el de app.json; hoy: tunnelmole o relay)
|
|
272
328
|
--port <n> Puerto local a exponer (default: el del server)
|
|
273
|
-
--home <path> Casa de Tamnora (default: TAMNORA_HOME o datos del sistema)
|
|
329
|
+
--home <path> Casa de Tamnora (default: TAMNORA_HOME o datos del sistema)
|
|
330
|
+
--insecure Permitir el tunel sin usuario configurado (peligroso)
|
|
331
|
+
|
|
332
|
+
Relay propio (app.json -> tunnel: { provider: "relay", relayUrl, token }): URLs
|
|
333
|
+
estables via https://harness.tamnora.com; el QR abre la consola agregada con el
|
|
334
|
+
token ya puesto.`);
|
|
274
335
|
}
|
|
@@ -160,7 +160,7 @@ Opciones:
|
|
|
160
160
|
--session <id> Continuar una sesión existente
|
|
161
161
|
-c, --continue Continuar la última sesión
|
|
162
162
|
--system <texto> Instrucciones extra de sistema
|
|
163
|
-
--max-steps <n>
|
|
163
|
+
--max-steps <n> Techo de rondas de herramientas (0 = sin límite, default)
|
|
164
164
|
--tools <lista> Tools habilitadas (coma), ej. read,list,grep
|
|
165
165
|
--no-tools Deshabilitar todas las tools
|
|
166
166
|
-y, --yes Aprobar automáticamente los permisos "ask"
|