@danieltmn/openbridge 0.3.0 → 0.5.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 +46 -0
- package/README.md +116 -32
- package/docs/ARCHITECTURE.md +56 -11
- package/package.json +2 -1
- package/src/auth.js +44 -23
- package/src/bridge/bridge.js +44 -5
- package/src/cli.js +194 -9
- package/src/config.js +74 -4
- package/src/web/assets/app.js +142 -63
- package/src/web/assets/manifest.webmanifest +2 -0
- package/src/web/assets/sw.js +1 -1
- package/src/web/routes.js +48 -14
- package/src/web/server.js +7 -0
- package/src/web/templates/chat.html +14 -2
- package/src/web/templates/login.html +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,52 @@ Todos los cambios relevantes de OpenBridge. Formato basado en
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/) y
|
|
5
5
|
[Versionado Semantico](https://semver.org/lang/es/).
|
|
6
6
|
|
|
7
|
+
## [0.5.0] - 2026-09-14
|
|
8
|
+
|
|
9
|
+
### Agregado
|
|
10
|
+
|
|
11
|
+
- Comando `openbridge update` (compara con npm; `--yes` actualiza la instalacion
|
|
12
|
+
global).
|
|
13
|
+
- Comando `openbridge join <url>` para sumar una PC como puente de un hub:
|
|
14
|
+
persiste `apiUrl`/`apiToken`/`bridgeId` y arranca el puente (`--no-start` solo
|
|
15
|
+
guarda).
|
|
16
|
+
- `npm run lint` (`scripts/lint.mjs`): `node --check` sobre todo el JS; corre en
|
|
17
|
+
CI.
|
|
18
|
+
- Test end-to-end del puente con `opencode` mockeado (`test/bridge.test.js`) y
|
|
19
|
+
tests del CLI (`test/cli.test.js`).
|
|
20
|
+
- **MCP**: vista en la web y comando de chat `/mcp` que muestran los servidores
|
|
21
|
+
MCP de opencode y su estado (`opencode mcp list`); comando de puente
|
|
22
|
+
`mcp_list`.
|
|
23
|
+
- `CONTRIBUTING.md`.
|
|
24
|
+
|
|
25
|
+
### Cambiado
|
|
26
|
+
|
|
27
|
+
- Claves internas `ocx_*` renombradas a `ob_*` con migracion automatica
|
|
28
|
+
(localStorage y cookie de tema). Se mantienen como compatibilidad el env
|
|
29
|
+
`OPENCONEX_HOME` y el bin `openconex` (deprecados).
|
|
30
|
+
- `config.command` puede apuntar a un script de Node (`.js`/`.mjs`/`.cjs`): se
|
|
31
|
+
ejecuta con el `node` actual (wrappers propios y pruebas con CLI simulado).
|
|
32
|
+
|
|
33
|
+
## [0.4.0] - 2026-09-14
|
|
34
|
+
|
|
35
|
+
### Agregado
|
|
36
|
+
|
|
37
|
+
- **Multiusuario** con roles `admin`/`user`: login con usuario + contrasena,
|
|
38
|
+
sesion ligada al usuario y a la version de contrasena, rate limit por
|
|
39
|
+
IP+usuario, y comando CLI `users`
|
|
40
|
+
(`list`/`add`/`remove`/`passwd`/`role`/`disable`/`enable`); `passwd --user`.
|
|
41
|
+
- Gate server-side: `session_delete` y los comandos mutantes del puente
|
|
42
|
+
(procesos, tuneles, revertir) solo para `admin`; la web oculta esos controles.
|
|
43
|
+
- **Autor** en cada mensaje (auditoria) y muestra del usuario en el sidebar.
|
|
44
|
+
- **Content-Security-Policy** y `Secure` de cookie solo desde loopback
|
|
45
|
+
(`trust proxy` explicito).
|
|
46
|
+
|
|
47
|
+
### Cambiado
|
|
48
|
+
|
|
49
|
+
- La sesion se invalida al cambiar la contrasena (`pv`); los usuarios
|
|
50
|
+
deshabilitados no pueden entrar.
|
|
51
|
+
- `app.json` migra el `username`/`password` legado a `users[]`.
|
|
52
|
+
|
|
7
53
|
## [0.3.0] - 2026-09-13
|
|
8
54
|
|
|
9
55
|
### Cambios
|
package/README.md
CHANGED
|
@@ -44,9 +44,9 @@ openbridge server
|
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
Al arrancar (queda en segundo plano) imprime el **estado con la URL pública**
|
|
47
|
-
(`https://….tunnelmole.net/chat.php`): abríla desde el celular y logueate con
|
|
48
|
-
usuario
|
|
49
|
-
|
|
47
|
+
(`https://….tunnelmole.net/chat.php`): abríla desde el celular y logueate con tu
|
|
48
|
+
usuario y contraseña. Para correrlo en primer plano usá
|
|
49
|
+
`openbridge server --stream`; para detenerlo, `openbridge stop`.
|
|
50
50
|
|
|
51
51
|
### Opciones de `init`
|
|
52
52
|
|
|
@@ -55,7 +55,8 @@ primer plano usá `openbridge server --stream`; para detenerlo, `openbridge stop
|
|
|
55
55
|
--name "PC 1" nombre visible de esta computadora
|
|
56
56
|
--id pc1 identificador corto (default: derivado del nombre)
|
|
57
57
|
--port 8799 puerto local
|
|
58
|
-
--password <clave> contraseña
|
|
58
|
+
--password <clave> contraseña del admin inicial
|
|
59
|
+
--user <nombre> nombre del admin inicial (default: admin)
|
|
59
60
|
--tunnel <prov> tunnelmole | ngrok | cloudflare | none
|
|
60
61
|
--domain <host> dominio fijo del túnel (ngrok)
|
|
61
62
|
--yes sin preguntas (usa defaults)
|
|
@@ -63,12 +64,25 @@ primer plano usá `openbridge server --stream`; para detenerlo, `openbridge stop
|
|
|
63
64
|
--dir <ruta> casa portable (default: directorio actual)
|
|
64
65
|
```
|
|
65
66
|
|
|
67
|
+
## Actualizar
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm i -g @danieltmn/openbridge@latest # o: npx @danieltmn/openbridge@latest …
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
La configuración y los datos en `.openbridge/` se **migran solos**: el layout
|
|
74
|
+
viejo (archivos sueltos) pasa a `.openbridge/`, y el `username`/`password` de una
|
|
75
|
+
instalación de un solo usuario se convierte en el **primer admin** de `users[]`
|
|
76
|
+
(con la misma contraseña). Por el cambio de formato de sesión, **hay que volver a
|
|
77
|
+
iniciar sesión** una vez; los chats, carpetas, túnel y push se conservan.
|
|
78
|
+
|
|
66
79
|
## Comandos
|
|
67
80
|
|
|
68
81
|
| Comando | Qué hace |
|
|
69
82
|
|---|---|
|
|
70
83
|
| `openbridge init` | Configura la casa (workspace, contraseña, túnel, puerto) |
|
|
71
|
-
| `openbridge passwd` | Cambia la contraseña de acceso (detiene el server si corre) |
|
|
84
|
+
| `openbridge passwd` | Cambia la contraseña de acceso (`--user <nombre>`; detiene el server si corre) |
|
|
85
|
+
| `openbridge users` | Usuarios y roles: `list`, `add`, `remove`, `passwd`, `role`, `disable`, `enable` |
|
|
72
86
|
| `openbridge server` | Arranca app + puente + túnel en **segundo plano** y muestra el estado (`--stream` = primer plano) |
|
|
73
87
|
| `openbridge stop` | Detiene el server y su árbol de procesos |
|
|
74
88
|
| `openbridge status` | Estado, URL, puente en línea y chats |
|
|
@@ -76,10 +90,12 @@ primer plano usá `openbridge server --stream`; para detenerlo, `openbridge stop
|
|
|
76
90
|
| `openbridge tunnel` | Muestra o cambia el proveedor de túnel y su dominio fijo (`--domain`) |
|
|
77
91
|
| `openbridge logs` | Logs (`--follow`, `--server`, `--bridge`) |
|
|
78
92
|
| `openbridge bridge` | Corre **solo** el puente (`--api --token --id --name`) |
|
|
93
|
+
| `openbridge join` | Vincula esta PC como puente de un hub (`<url> --token --id --name`) |
|
|
79
94
|
| `openbridge import` | Trae `data/` de OpenConex (`<data-dir> [--force]`) |
|
|
80
95
|
| `openbridge reset` | Borra chats/datos (`--session <id>`, `--yes`) |
|
|
81
96
|
| `openbridge autostart` | Arranque automático (`install`/`remove`) |
|
|
82
97
|
| `openbridge doctor` | Verifica Node, opencode, configuración y puerto |
|
|
98
|
+
| `openbridge update` | Busca una versión nueva en npm (`--yes` para actualizar) |
|
|
83
99
|
|
|
84
100
|
## Casa portable
|
|
85
101
|
|
|
@@ -89,7 +105,7 @@ Todo vive dentro de **`.openbridge/`** en el directorio donde corrés `init`
|
|
|
89
105
|
```
|
|
90
106
|
.openbridge/
|
|
91
107
|
config.json config del puente (apiUrl, token, workspace, bridgeId…)
|
|
92
|
-
app.json config de la app (
|
|
108
|
+
app.json config de la app (usuarios, token, VAPID, puerto, túnel)
|
|
93
109
|
folders.json lista blanca de carpetas que se ven desde el celular
|
|
94
110
|
data/ sesiones, mensajes, catálogo, registro de puentes, push
|
|
95
111
|
logs/ server.log y bridge.log
|
|
@@ -99,47 +115,115 @@ Movés la carpeta a donde quieras y sigue funcionando (es portable). Si venías
|
|
|
99
115
|
de una versión anterior con los archivos sueltos, se **migran solos** a
|
|
100
116
|
`.openbridge/` la primera vez que corras un comando.
|
|
101
117
|
|
|
102
|
-
## Varias computadoras
|
|
118
|
+
## Varias computadoras (hub + remotas)
|
|
119
|
+
|
|
120
|
+
La PC **hub** corre la app y el túnel; las demás PCs corren solo el **puente** y
|
|
121
|
+
apuntan al hub. En el sidebar de la web elegís qué PC usar y ves sus proyectos y
|
|
122
|
+
chats.
|
|
123
|
+
|
|
124
|
+
Ejemplo concreto:
|
|
125
|
+
|
|
126
|
+
1. En el **hub** (tiene la app y la URL pública):
|
|
127
|
+
```bash
|
|
128
|
+
openbridge init
|
|
129
|
+
openbridge server # muestra estado, URL y QR
|
|
130
|
+
openbridge status # volvés a ver la URL cuando quieras
|
|
131
|
+
```
|
|
132
|
+
2. Copiá el **token del puente** del hub: `.openbridge/app.json` → `bridgeToken`.
|
|
133
|
+
3. En la **PC 2** (remota):
|
|
134
|
+
```bash
|
|
135
|
+
openbridge join https://tu-url-publica --token <bridgeToken> --id pc2 --name "PC 2"
|
|
136
|
+
# guarda la config y arranca el puente; con --no-start solo guarda
|
|
137
|
+
```
|
|
138
|
+
(equivale a `openbridge bridge --api … --token … --id … --name …`, pero
|
|
139
|
+
persiste la config para no repetir flags)
|
|
140
|
+
4. Abrí la URL del hub desde el celular: en el sidebar aparecen **PC 1** y
|
|
141
|
+
**PC 2** para alternar.
|
|
142
|
+
|
|
143
|
+
El puente remoto solo necesita salida a internet hacia el hub; no abre puertos ni
|
|
144
|
+
túnel propio. Para que arranque solo en cada PC: `openbridge autostart install`.
|
|
103
145
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
- En la PC "hub": `openbridge init` + `openbridge server` (queda la app + túnel).
|
|
107
|
-
- En otra PC: `openbridge init` y luego `openbridge bridge --api <url-del-hub>
|
|
108
|
-
--token <token> --id pc2 --name "PC 2"` (sin editar `config.json` a mano).
|
|
146
|
+
## Túnel y URL estable
|
|
109
147
|
|
|
110
|
-
|
|
148
|
+
Proveedores (`--tunnel` o `openbridge tunnel <prov>`): `tunnelmole` (gratis, sin
|
|
149
|
+
cuenta), `ngrok` (cuenta + authtoken; admite **dominio fijo**) y `cloudflare`
|
|
150
|
+
(quick tunnel). Ver/cambiar sin reconfigurar todo:
|
|
111
151
|
|
|
112
|
-
|
|
152
|
+
```bash
|
|
153
|
+
openbridge tunnel # estado actual
|
|
154
|
+
openbridge tunnel ngrok --domain mi-pc.ngrok.app # URL fija (recomendado)
|
|
155
|
+
openbridge stop && openbridge server # reiniciar para aplicar
|
|
156
|
+
```
|
|
113
157
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
atado al origen). Para PWA/push estables conviene ngrok con dominio fijo o
|
|
119
|
-
Cloudflare con dominio propio.
|
|
158
|
+
Con URL **aleatoria** la PWA y el push no persisten entre reinicios (están atados
|
|
159
|
+
al origen). Para una URL estable usá ngrok con dominio fijo (o un named tunnel de
|
|
160
|
+
Cloudflare). Escaneá el QR de `openbridge status`/`openbridge qr` para abrirla en
|
|
161
|
+
el celular.
|
|
120
162
|
|
|
121
163
|
## Avisos push
|
|
122
164
|
|
|
123
|
-
Las claves VAPID se generan solas en `init`. Con URL de túnel
|
|
124
|
-
suscripción
|
|
125
|
-
|
|
165
|
+
Las claves VAPID se generan solas en `init`. Con URL de túnel aleatoria la
|
|
166
|
+
suscripción no persiste entre reinicios (el push está atado al origen); la app la
|
|
167
|
+
vuelve a registrar cuando la abrís en la URL nueva. Para que sea estable, usá una
|
|
168
|
+
URL fija (ngrok con `--domain`).
|
|
169
|
+
|
|
170
|
+
## MCP
|
|
171
|
+
|
|
172
|
+
La web tiene una vista **mcp** y el chat acepta `/mcp`: muestran los servidores
|
|
173
|
+
MCP de opencode y su estado (`opencode mcp list`). El comando corre en la PC del
|
|
174
|
+
puente, así que refleja la configuración de opencode de esa computadora.
|
|
126
175
|
|
|
127
176
|
## Seguridad
|
|
128
177
|
|
|
129
|
-
- La app escucha solo en `127.0.0.1
|
|
130
|
-
- Login con contraseña (scrypt)
|
|
131
|
-
|
|
178
|
+
- La app escucha **solo en `127.0.0.1`**; el túnel la expone a internet.
|
|
179
|
+
- Login con **usuario y contraseña** (scrypt), cookie firmada `HttpOnly` +
|
|
180
|
+
`SameSite=Lax`, **CSRF** y **rate limit** (5 intentos / 15 min por IP+usuario);
|
|
181
|
+
token del puente autogenerado.
|
|
182
|
+
- La cookie usa `Secure` cuando el pedido llega por HTTPS **desde loopback** (el
|
|
183
|
+
túnel); no se confía en `X-Forwarded-Proto` de otros orígenes.
|
|
184
|
+
- Respuestas con `Content-Security-Policy` (orígenes externos, frames y objetos
|
|
185
|
+
bloqueados), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` y
|
|
186
|
+
`Referrer-Policy: no-referrer`.
|
|
132
187
|
- El túnel es **público mientras corre**: detenelo (`openbridge stop`) cuando no
|
|
133
|
-
lo uses.
|
|
188
|
+
lo uses y mantené la contraseña fuerte.
|
|
189
|
+
|
|
190
|
+
## Usuarios y roles
|
|
191
|
+
|
|
192
|
+
Cada persona entra con **usuario y contraseña**. Hay dos roles:
|
|
193
|
+
|
|
194
|
+
- **admin**: todo (crear/borrar chats, revertir cambios, procesos, túneles y
|
|
195
|
+
administrar usuarios).
|
|
196
|
+
- **user**: chat, archivos, cambios y búsqueda; no puede borrar chats, revertir
|
|
197
|
+
cambios ni correr procesos/túneles.
|
|
198
|
+
|
|
199
|
+
Se administran desde la PC con la CLI (los cambios requieren reiniciar el server):
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
openbridge users list
|
|
203
|
+
openbridge users add ana --role user --password <clave>
|
|
204
|
+
openbridge users passwd ana
|
|
205
|
+
openbridge users role ana admin
|
|
206
|
+
openbridge users disable ana # enable para reactivar
|
|
207
|
+
openbridge users remove ana
|
|
208
|
+
openbridge passwd --user admin # cambia la clave de un usuario
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`init --user <nombre>` crea el admin inicial. La contraseña se guarda con scrypt,
|
|
212
|
+
y cambiar una clave **invalida las sesiones abiertas** de ese usuario. El último
|
|
213
|
+
admin no se puede borrar, degradar ni deshabilitar. Cada mensaje guarda **quién
|
|
214
|
+
lo envió** (se ve en el chat).
|
|
134
215
|
|
|
135
216
|
## Estado
|
|
136
217
|
|
|
137
218
|
Proyecto en desarrollo. Ya funciona: `init`, `server` (segundo plano; `--stream`
|
|
138
|
-
en primer plano), `stop`, `status`, `logs` (`--follow`), `bridge`
|
|
139
|
-
`passwd`, `import`, `reset`, `autostart`, `doctor
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
219
|
+
en primer plano), `stop`, `status`, `qr`, `tunnel`, `logs` (`--follow`), `bridge`
|
|
220
|
+
(con flags), `passwd`, `users`, `import`, `reset`, `autostart`, `doctor`; login
|
|
221
|
+
**multiusuario** con roles `admin`/`user` y rate limit; API completa y SSE;
|
|
222
|
+
catálogo por PC; Web Push; túnel (TunnelMole/ngrok/cloudflare); y en la web: chat
|
|
223
|
+
con streaming, adjuntar imagen, dictado por voz, plantillas de prompts,
|
|
224
|
+
tokens/contexto y **costo** por sesión, vista de archivos, vista de **cambios**
|
|
225
|
+
(git status/diff) con **revertir**, búsqueda global, sesiones de opencode y
|
|
226
|
+
**autor** en cada mensaje. Tests en `npm test` (28).
|
|
143
227
|
|
|
144
228
|
## Licencia
|
|
145
229
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -19,18 +19,20 @@ el estado); `--stream` lo deja en primer plano.
|
|
|
19
19
|
| Ruta | Responsabilidad |
|
|
20
20
|
|---|---|
|
|
21
21
|
| `bin/openbridge.js` | Entrada CLI + guard de Node. |
|
|
22
|
-
| `src/cli.js` | Comandos (`init`, `passwd`, `server`, `stop`, `status`, `logs`, `bridge`, `import`, `reset`, `autostart`, `doctor`). |
|
|
22
|
+
| `src/cli.js` | Comandos (`init`, `passwd`, `users`, `server`, `stop`, `status`, `qr`, `tunnel`, `logs`, `bridge`, `join`, `import`, `reset`, `autostart`, `doctor`, `update`). |
|
|
23
23
|
| `src/paths.js` | Casa portable: ubicacion de `config.json`, `app.json`, `data/`, `logs/`. |
|
|
24
|
-
| `src/config.js` | Lectura/escritura de config, hash scrypt, VAPID. |
|
|
25
|
-
| `src/auth.js` | Cookie de sesion firmada, CSRF, remember-me, rate limit, token del puente. |
|
|
24
|
+
| `src/config.js` | Lectura/escritura de config, hash scrypt, **usuarios/roles**, VAPID. |
|
|
25
|
+
| `src/auth.js` | Cookie de sesion firmada, CSRF, remember-me, **roles**, rate limit, token del puente. |
|
|
26
26
|
| `src/log.js` | Log a consola + `logs/server.log`. |
|
|
27
27
|
| `src/push.js` | Web Push (VAPID + `web-push`). |
|
|
28
|
+
| `src/qr.js` | Generador QR propio (terminal/SVG), sin dependencias. |
|
|
28
29
|
| `src/tunnel/` | Proveedores de tunel enchufables. |
|
|
29
|
-
| `src/web/server.js` | Servidor HTTP, estaticos, seguridad. |
|
|
30
|
-
| `src/web/routes.js` | Router de paginas y API (`?action=...`), compatible con `app.js`/`bridge.js`. |
|
|
30
|
+
| `src/web/server.js` | Servidor HTTP, estaticos, cabeceras de seguridad (CSP). |
|
|
31
|
+
| `src/web/routes.js` | Router de paginas y API (`?action=...`), gate de roles, compatible con `app.js`/`bridge.js`. |
|
|
31
32
|
| `src/web/assets/` | Frontend (`app.js`, temas, iconos, service worker, manifest). |
|
|
32
33
|
| `src/store/` | Store JSON con mutex por archivo y escritura atomica. |
|
|
33
|
-
| `src/bridge/bridge.js` | Puente: sincroniza catalogo, hace poll y ejecuta opencode. |
|
|
34
|
+
| `src/bridge/bridge.js` | Puente: sincroniza catalogo, hace poll y ejecuta opencode; comandos fs/proc/tunel/git. |
|
|
35
|
+
| `scripts/release.mjs` | Release local (fuera del paquete npm): version, changelog, tag, push y publish. |
|
|
34
36
|
|
|
35
37
|
## Casa portable
|
|
36
38
|
|
|
@@ -53,7 +55,8 @@ OpenBridge. La CLI pasa la **base** (`--dir`/cwd) al puente via
|
|
|
53
55
|
|
|
54
56
|
## Flujo de un mensaje
|
|
55
57
|
|
|
56
|
-
1. El celular envia `?action=send` (con CSRF) -> `src/web/routes.js
|
|
58
|
+
1. El celular envia `?action=send` (con CSRF) -> `src/web/routes.js`; el mensaje
|
|
59
|
+
se guarda con el **autor** de la sesion.
|
|
57
60
|
2. El mensaje queda `pending` en `data/messages-<id>.json`.
|
|
58
61
|
3. El puente hace `poll`, reclama el mensaje y corre
|
|
59
62
|
`opencode run --model <m> [--session <id>]`.
|
|
@@ -62,16 +65,58 @@ OpenBridge. La CLI pasa la **base** (`--dir`/cwd) al puente via
|
|
|
62
65
|
|
|
63
66
|
## Multi-PC
|
|
64
67
|
|
|
65
|
-
El hub corre la app; cada PC corre `openbridge bridge`
|
|
66
|
-
|
|
67
|
-
(`data/catalog-<id>.json`) y el sidebar permite elegir que PC usar.
|
|
68
|
+
El hub corre la app; cada PC corre `openbridge bridge` (o `openbridge join <url>`,
|
|
69
|
+
que persiste `apiUrl` + `apiToken` + `bridgeId`) apuntando al hub. El catalogo se
|
|
70
|
+
guarda por PC (`data/catalog-<id>.json`) y el sidebar permite elegir que PC usar.
|
|
71
|
+
|
|
72
|
+
`config.command` es el CLI a ejecutar (default `opencode`). Si apunta a un script
|
|
73
|
+
de Node (`.js`/`.mjs`/`.cjs`) el puente lo corre con el `node` actual, lo que
|
|
74
|
+
permite wrappers propios y las pruebas con un opencode simulado.
|
|
75
|
+
|
|
76
|
+
## Usuarios y roles
|
|
77
|
+
|
|
78
|
+
`app.json` guarda `users[]` (`id`, `name`, `role`, `password` scrypt, `pv`,
|
|
79
|
+
`disabled`). El modelo viejo de un `username`/`password` se migra solo a
|
|
80
|
+
`users[0]` (admin).
|
|
81
|
+
|
|
82
|
+
- La sesion (cookie firmada) guarda el `id` del usuario y su `pv`; el **rol se lee
|
|
83
|
+
siempre del server** en cada request. Cambiar la contrasena sube `pv` e invalida
|
|
84
|
+
las sesiones abiertas; un usuario `disabled` no entra.
|
|
85
|
+
- Roles: `admin` (todo) y `user` (chat/lectura). El gate es **server-side** en
|
|
86
|
+
`routes.js`; la UI solo oculta los controles.
|
|
87
|
+
- Rate limit de login por IP+usuario y error generico (no filtra si el usuario
|
|
88
|
+
existe; corre scrypt igual contra un hash dummy).
|
|
89
|
+
- Cada mensaje guarda `author` (quien lo envio); la respuesta hereda el autor del
|
|
90
|
+
pedido.
|
|
91
|
+
|
|
92
|
+
## Comandos del puente
|
|
93
|
+
|
|
94
|
+
La web encola comandos (`?action=run_oc`) que el puente reclama por el poll y
|
|
95
|
+
resuelve (los resultados vuelven por `command_done`/`fs_result`). Sirve tanto en
|
|
96
|
+
local como en remoto:
|
|
97
|
+
|
|
98
|
+
- Lectura: `models`, `session_list`, `session_info`, `opencode_version`,
|
|
99
|
+
`fs_list`, `fs_read`, `proc_list`, `proc_log`, `tunnel_list`, `git_status`,
|
|
100
|
+
`git_diff`.
|
|
101
|
+
- Mutantes (solo `admin`): `proc_start`, `proc_stop`, `tunnel_start`,
|
|
102
|
+
`tunnel_stop`, `git_checkout`.
|
|
103
|
+
|
|
104
|
+
Las rutas se validan contra el workspace (`procResolveFolder`/`resolveInWorkspace`)
|
|
105
|
+
y los argumentos con una whitelist (`OC_ALLOWED`).
|
|
68
106
|
|
|
69
107
|
## Decisiones
|
|
70
108
|
|
|
71
109
|
- **Stateless sessions**: la cookie se firma con HMAC; no hay store de sesiones.
|
|
110
|
+
El `id`+`pv` del usuario van en el payload y el rol se resuelve en cada request.
|
|
111
|
+
- **Multiusuario con roles**: `admin`/`user`, gate server-side; el ultimo admin no
|
|
112
|
+
se puede borrar, degradar ni deshabilitar.
|
|
72
113
|
- **Escritura atomica + mutex**: `tmp` + `rename` y una cola por archivo evitan
|
|
73
114
|
corrupciones con polls concurrentes.
|
|
74
115
|
- **Sin dependencias de framework**: HTTP nativo de Node; la unica dependencia
|
|
75
|
-
es `web-push`.
|
|
116
|
+
es `web-push`. El QR se genera con codigo propio (`src/qr.js`).
|
|
117
|
+
- **Cabeceras de seguridad**: CSP restrictiva; permite scripts/estilos inline
|
|
118
|
+
porque los templates los usan.
|
|
119
|
+
- **Cola de comandos del puente**: un solo canal (poll) para fs, procesos,
|
|
120
|
+
tuneles y git, valido igual en local y remoto.
|
|
76
121
|
- **Proveedores de tunel detras de una interfaz**: `startTunnel(port, provider)`
|
|
77
122
|
devuelve `{ url, provider, pid, stop }`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danieltmn/openbridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Tu opencode en el celular, sin hosting: corre la app, el puente y un tunel publico desde tu PC.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"author": "tamnora",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"start": "node bin/openbridge.js server",
|
|
45
45
|
"test": "node --test",
|
|
46
|
+
"lint": "node scripts/lint.mjs",
|
|
46
47
|
"release": "node scripts/release.mjs",
|
|
47
48
|
"release:dry": "node scripts/release.mjs --dry-run",
|
|
48
49
|
"prepublishOnly": "npm test"
|
package/src/auth.js
CHANGED
|
@@ -38,8 +38,16 @@ function parseCookies(header) {
|
|
|
38
38
|
return out;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Detras del tunel el server recibe los pedidos desde loopback (el tunel corre
|
|
42
|
+
// en la misma maquina y reenvia a 127.0.0.1). Solo en ese caso confiamos en
|
|
43
|
+
// X-Forwarded-Proto: un cliente que llegara directo por la red no puede forzar
|
|
44
|
+
// el flag Secure de la cookie. Sin info de socket (tests), se confia.
|
|
41
45
|
function isSecure(req) {
|
|
42
|
-
|
|
46
|
+
if (req.socket && req.socket.encrypted) return true;
|
|
47
|
+
const remote = req.socket && req.socket.remoteAddress;
|
|
48
|
+
const loopback = !remote || remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
|
|
49
|
+
if (!loopback) return false;
|
|
50
|
+
const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
|
|
43
51
|
return proto === 'https';
|
|
44
52
|
}
|
|
45
53
|
|
|
@@ -51,10 +59,10 @@ function serializeCookie(name, value, req, maxAge) {
|
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
// ---------------------------------------------------------------------------
|
|
54
|
-
// Sesion
|
|
62
|
+
// Sesion (ligada al id del usuario; el rol se lee siempre del server)
|
|
55
63
|
// ---------------------------------------------------------------------------
|
|
56
|
-
function makeSession(app,
|
|
57
|
-
const payload = b64url(JSON.stringify({ u:
|
|
64
|
+
function makeSession(app, user, csrf, exp) {
|
|
65
|
+
const payload = b64url(JSON.stringify({ u: user.id, pv: parseInt(user.pv, 10) || 1, c: csrf, e: exp }));
|
|
58
66
|
return payload + '.' + sign(payload, app.csrfSecret);
|
|
59
67
|
}
|
|
60
68
|
function readSession(app, req) {
|
|
@@ -68,8 +76,11 @@ function readSession(app, req) {
|
|
|
68
76
|
if (!safeEqual(sign(payload, app.csrfSecret), sig)) return null;
|
|
69
77
|
let data;
|
|
70
78
|
try { data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch (e) { return null; }
|
|
71
|
-
if (!data ||
|
|
72
|
-
|
|
79
|
+
if (!data || typeof data.e !== 'number' || data.e < Math.floor(Date.now() / 1000)) return null;
|
|
80
|
+
const user = config.userById(app, data.u);
|
|
81
|
+
if (!user || user.disabled) return null;
|
|
82
|
+
if ((parseInt(user.pv, 10) || 1) !== (parseInt(data.pv, 10) || 1)) return null;
|
|
83
|
+
return { id: user.id, name: user.name, role: user.role, u: user.id, c: data.c, e: data.e };
|
|
73
84
|
}
|
|
74
85
|
function currentCsrf(app, req) {
|
|
75
86
|
const s = readSession(app, req);
|
|
@@ -80,6 +91,14 @@ function requireLogin(app, req, res) {
|
|
|
80
91
|
if (!s) { json(res, 401, { ok: false, error: 'No autorizado' }); return null; }
|
|
81
92
|
return s;
|
|
82
93
|
}
|
|
94
|
+
// Exige que el usuario tenga uno de los roles indicados.
|
|
95
|
+
function requireRole(app, req, res, roles) {
|
|
96
|
+
const s = readSession(app, req);
|
|
97
|
+
if (!s) { json(res, 401, { ok: false, error: 'No autorizado' }); return null; }
|
|
98
|
+
const allowed = Array.isArray(roles) ? roles : [roles];
|
|
99
|
+
if (!allowed.includes(s.role)) { json(res, 403, { ok: false, error: 'Permiso insuficiente' }); return null; }
|
|
100
|
+
return s;
|
|
101
|
+
}
|
|
83
102
|
function requireCsrf(app, req, res) {
|
|
84
103
|
const s = readSession(app, req);
|
|
85
104
|
const given = String(req.headers['x-csrf'] || '').trim();
|
|
@@ -90,13 +109,13 @@ function requireCsrf(app, req, res) {
|
|
|
90
109
|
return s;
|
|
91
110
|
}
|
|
92
111
|
|
|
93
|
-
function startSession(app, req, res,
|
|
112
|
+
function startSession(app, req, res, user) {
|
|
94
113
|
const csrf = crypto.randomBytes(16).toString('hex');
|
|
95
114
|
const exp = Math.floor(Date.now() / 1000) + REMEMBER_DAYS * 86400;
|
|
96
|
-
const token = makeSession(app,
|
|
115
|
+
const token = makeSession(app, user, csrf, exp);
|
|
97
116
|
res.setHeader('Set-Cookie', [
|
|
98
117
|
serializeCookie(SESSION_COOKIE, token, req, REMEMBER_DAYS * 86400),
|
|
99
|
-
serializeCookie(REMEMBER_COOKIE, makeRemember(app,
|
|
118
|
+
serializeCookie(REMEMBER_COOKIE, makeRemember(app, user), req, REMEMBER_DAYS * 86400),
|
|
100
119
|
]);
|
|
101
120
|
return csrf;
|
|
102
121
|
}
|
|
@@ -107,9 +126,9 @@ function endSession(req, res) {
|
|
|
107
126
|
]);
|
|
108
127
|
}
|
|
109
128
|
|
|
110
|
-
function makeRemember(app,
|
|
129
|
+
function makeRemember(app, user) {
|
|
111
130
|
const exp = Math.floor(Date.now() / 1000) + REMEMBER_DAYS * 86400;
|
|
112
|
-
const payload =
|
|
131
|
+
const payload = user.id + '|' + exp + '|' + (parseInt(user.pv, 10) || 1);
|
|
113
132
|
return b64url(payload) + '.' + sign(payload, app.csrfSecret);
|
|
114
133
|
}
|
|
115
134
|
// Re-autentica desde la cookie remember si la sesion se perdio.
|
|
@@ -125,10 +144,12 @@ function rememberAutoLogin(app, req, res) {
|
|
|
125
144
|
let plain;
|
|
126
145
|
try { plain = Buffer.from(payload, 'base64url').toString('utf8'); } catch (e) { return false; }
|
|
127
146
|
const parts = plain.split('|');
|
|
128
|
-
if (parts.length !==
|
|
129
|
-
const [
|
|
130
|
-
if (
|
|
147
|
+
if (parts.length !== 3) return false;
|
|
148
|
+
const [uid, exp, pv] = parts;
|
|
149
|
+
if (!/^\d+$/.test(exp) || parseInt(exp, 10) < Math.floor(Date.now() / 1000)) return false;
|
|
131
150
|
if (!safeEqual(sign(payload, app.csrfSecret), sig)) return false;
|
|
151
|
+
const user = config.userById(app, uid);
|
|
152
|
+
if (!user || user.disabled || (parseInt(user.pv, 10) || 1) !== (parseInt(pv, 10) || 1)) return false;
|
|
132
153
|
startSession(app, req, res, user);
|
|
133
154
|
return true;
|
|
134
155
|
}
|
|
@@ -141,10 +162,10 @@ const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
|
|
141
162
|
const LOGIN_LOCK_MS = 15 * 60 * 1000;
|
|
142
163
|
const loginAttempts = new Map();
|
|
143
164
|
|
|
144
|
-
function clientKey(req) {
|
|
165
|
+
function clientKey(req, user) {
|
|
145
166
|
const xff = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
|
|
146
167
|
const ip = xff || (req.socket && req.socket.remoteAddress) || 'desconocido';
|
|
147
|
-
return String(ip);
|
|
168
|
+
return String(ip) + '|' + String(user || '').trim().toLowerCase();
|
|
148
169
|
}
|
|
149
170
|
function pruneLoginAttempts(now) {
|
|
150
171
|
for (const [key, st] of loginAttempts) {
|
|
@@ -152,25 +173,25 @@ function pruneLoginAttempts(now) {
|
|
|
152
173
|
}
|
|
153
174
|
}
|
|
154
175
|
// Segundos restantes de bloqueo (0 = puede intentar).
|
|
155
|
-
function loginLockRemaining(req) {
|
|
176
|
+
function loginLockRemaining(req, user) {
|
|
156
177
|
const now = Date.now();
|
|
157
178
|
pruneLoginAttempts(now);
|
|
158
|
-
const st = loginAttempts.get(clientKey(req));
|
|
179
|
+
const st = loginAttempts.get(clientKey(req, user));
|
|
159
180
|
if (!st || !st.lockUntil) return 0;
|
|
160
181
|
const left = st.lockUntil - now;
|
|
161
182
|
return left > 0 ? Math.ceil(left / 1000) : 0;
|
|
162
183
|
}
|
|
163
|
-
function loginRecordFailure(req) {
|
|
184
|
+
function loginRecordFailure(req, user) {
|
|
164
185
|
const now = Date.now();
|
|
165
|
-
const key = clientKey(req);
|
|
186
|
+
const key = clientKey(req, user);
|
|
166
187
|
let st = loginAttempts.get(key);
|
|
167
188
|
if (!st || (!st.lockUntil && now - st.first > LOGIN_WINDOW_MS)) st = { count: 0, first: now, lockUntil: 0 };
|
|
168
189
|
st.count++;
|
|
169
190
|
if (st.count >= LOGIN_MAX_ATTEMPTS) st.lockUntil = now + LOGIN_LOCK_MS;
|
|
170
191
|
loginAttempts.set(key, st);
|
|
171
192
|
}
|
|
172
|
-
function loginClear(req) {
|
|
173
|
-
loginAttempts.delete(clientKey(req));
|
|
193
|
+
function loginClear(req, user) {
|
|
194
|
+
loginAttempts.delete(clientKey(req, user));
|
|
174
195
|
}
|
|
175
196
|
|
|
176
197
|
// ---------------------------------------------------------------------------
|
|
@@ -197,7 +218,7 @@ function json(res, code, data) {
|
|
|
197
218
|
module.exports = {
|
|
198
219
|
SESSION_COOKIE, REMEMBER_COOKIE,
|
|
199
220
|
parseCookies, serializeCookie, isSecure,
|
|
200
|
-
makeSession, readSession, currentCsrf, requireLogin, requireCsrf,
|
|
221
|
+
makeSession, readSession, currentCsrf, requireLogin, requireRole, requireCsrf,
|
|
201
222
|
startSession, endSession, rememberAutoLogin,
|
|
202
223
|
loginLockRemaining, loginRecordFailure, loginClear,
|
|
203
224
|
checkBridgeToken, safeEqual, json,
|
package/src/bridge/bridge.js
CHANGED
|
@@ -412,6 +412,21 @@ function parseShimExe(shimPath, shimDir) {
|
|
|
412
412
|
return exe;
|
|
413
413
|
}
|
|
414
414
|
|
|
415
|
+
// Si `command` apunta a un script de Node (.js/.mjs/.cjs) lo ejecutamos con el
|
|
416
|
+
// node actual. Permite wrappers propios (p. ej. un runner que filtra logs) y
|
|
417
|
+
// que las pruebas usen un opencode simulado sin depender del PATH.
|
|
418
|
+
function isNodeScriptCommand(cmd) {
|
|
419
|
+
return /\.(c|m)?js$/i.test(String(cmd || '').trim());
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function commandSpec(args) {
|
|
423
|
+
const cmd = config.command;
|
|
424
|
+
if (isNodeScriptCommand(cmd)) {
|
|
425
|
+
return { cmd: process.execPath, argv: [path.resolve(cmd)].concat(args) };
|
|
426
|
+
}
|
|
427
|
+
return { cmd: resolveCommand(), argv: args };
|
|
428
|
+
}
|
|
429
|
+
|
|
415
430
|
// ---------------------------------------------------------------------------
|
|
416
431
|
// Ejecutar comandos auxiliares de opencode
|
|
417
432
|
// ---------------------------------------------------------------------------
|
|
@@ -457,9 +472,9 @@ function runCmd(bin, args, opts) {
|
|
|
457
472
|
|
|
458
473
|
function runCli(args, opts) {
|
|
459
474
|
return new Promise((resolve) => {
|
|
460
|
-
const
|
|
475
|
+
const spec = commandSpec(args);
|
|
461
476
|
const timeoutMs = Math.max((opts && opts.timeout) || 0, 0);
|
|
462
|
-
const child = spawn(cmd,
|
|
477
|
+
const child = spawn(spec.cmd, spec.argv, {
|
|
463
478
|
cwd: (opts && opts.cwd) || undefined,
|
|
464
479
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
465
480
|
env: process.env,
|
|
@@ -511,9 +526,9 @@ function runCli(args, opts) {
|
|
|
511
526
|
// Resolución: { ok, code, text, reasoning, killed, canceled, sessionID, errorText }.
|
|
512
527
|
function streamCli(args, opts, onPartial) {
|
|
513
528
|
return new Promise((resolve) => {
|
|
514
|
-
const
|
|
529
|
+
const spec = commandSpec(args);
|
|
515
530
|
const timeoutMs = Math.max((opts && opts.timeout) || 0, 0);
|
|
516
|
-
const child = spawn(cmd,
|
|
531
|
+
const child = spawn(spec.cmd, spec.argv, {
|
|
517
532
|
cwd: (opts && opts.cwd) || undefined,
|
|
518
533
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
519
534
|
env: process.env,
|
|
@@ -834,7 +849,7 @@ async function runOpencode(msg, onPartial) {
|
|
|
834
849
|
try {
|
|
835
850
|
const mime = msg.img.slice(5, msg.img.indexOf(';'));
|
|
836
851
|
const ext = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' }[mime] || '.png';
|
|
837
|
-
imgPath = path.join(os.tmpdir(), '
|
|
852
|
+
imgPath = path.join(os.tmpdir(), 'ob-img-' + msg.session_id + '-' + msg.id + ext);
|
|
838
853
|
fs.writeFileSync(imgPath, Buffer.from(msg.img.slice(msg.img.indexOf(',') + 1), 'base64'));
|
|
839
854
|
log('imagen adjunta: ' + Math.round(fs.statSync(imgPath).size / 1024) + ' KB para el mensaje #' + msg.id);
|
|
840
855
|
} catch (e) {
|
|
@@ -975,6 +990,16 @@ async function runMessage(msg) {
|
|
|
975
990
|
return { text: '', opencodeSession: null, done: true };
|
|
976
991
|
}
|
|
977
992
|
|
|
993
|
+
if (name === 'mcp') {
|
|
994
|
+
log('comando /mcp');
|
|
995
|
+
const r = await runCli(['mcp', 'list'], { timeout: 30000 });
|
|
996
|
+
const text = r.ok
|
|
997
|
+
? (r.text || 'No hay servidores MCP configurados.')
|
|
998
|
+
: 'No se pudieron listar los MCP: ' + (r.text || ('exit ' + r.code));
|
|
999
|
+
await respondSimple(msg, text, false);
|
|
1000
|
+
return { text: '', opencodeSession: null, done: true };
|
|
1001
|
+
}
|
|
1002
|
+
|
|
978
1003
|
if (name === 'workspace') {
|
|
979
1004
|
log('comando /workspace');
|
|
980
1005
|
const ws = config.workspace ? path.resolve(config.workspace) : '';
|
|
@@ -1014,6 +1039,7 @@ async function runMessage(msg) {
|
|
|
1014
1039
|
+ '• /compact, /resumen, /summarize — liberar contexto\n'
|
|
1015
1040
|
+ '• /models [filtro] — proveedores disponibles o búsqueda de modelos\n'
|
|
1016
1041
|
+ '• /agents — listar agentes disponibles\n'
|
|
1042
|
+
+ '• /mcp — servidores MCP de opencode y su estado\n'
|
|
1017
1043
|
+ '• /folders, /carpetas, /dirs — listar carpetas del workspace\n'
|
|
1018
1044
|
+ '• /workspace — mostrar el espacio de trabajo\n'
|
|
1019
1045
|
+ '• /status, /estado — estado del puente\n'
|
|
@@ -2115,6 +2141,19 @@ async function handleCommand(cmd) {
|
|
|
2115
2141
|
await handleFsCommand(cmd);
|
|
2116
2142
|
return;
|
|
2117
2143
|
}
|
|
2144
|
+
// Servidores MCP de opencode: se devuelve JSON con la salida cruda para que
|
|
2145
|
+
// la web la muestre en el panel.
|
|
2146
|
+
if (cmd.name === 'mcp_list') {
|
|
2147
|
+
const r = await runCli(['mcp', 'list'], { timeout: 30000 });
|
|
2148
|
+
await api('command_done', {
|
|
2149
|
+
id: cmd.id,
|
|
2150
|
+
ok: r.ok,
|
|
2151
|
+
text: JSON.stringify({ output: r.text || '', ok: r.ok }),
|
|
2152
|
+
error: r.ok ? '' : ('exit ' + r.code),
|
|
2153
|
+
}, cmd._t);
|
|
2154
|
+
log('comando #' + cmd.id + ' mcp_list (ok=' + r.ok + ')');
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2118
2157
|
// Cambios git del proyecto (status/diff/revertir).
|
|
2119
2158
|
if (cmd.name === 'git_status' || cmd.name === 'git_diff' || cmd.name === 'git_checkout') {
|
|
2120
2159
|
await handleGitCommand(cmd);
|