@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/src/web/assets/app.js
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
// Migracion de claves viejas `ocx_*` a `ob_*` (una sola vez, antes de leer nada).
|
|
4
|
+
(function () {
|
|
5
|
+
try {
|
|
6
|
+
var keys = [];
|
|
7
|
+
for (var i = 0; i < localStorage.length; i++) {
|
|
8
|
+
var k = localStorage.key(i);
|
|
9
|
+
if (k && k.indexOf('ocx_') === 0) keys.push(k);
|
|
10
|
+
}
|
|
11
|
+
for (var j = 0; j < keys.length; j++) {
|
|
12
|
+
var nk = 'ob_' + keys[j].slice(4);
|
|
13
|
+
if (localStorage.getItem(nk) === null) localStorage.setItem(nk, localStorage.getItem(keys[j]));
|
|
14
|
+
localStorage.removeItem(keys[j]);
|
|
15
|
+
}
|
|
16
|
+
} catch (e) { /* almacenamiento no disponible */ }
|
|
17
|
+
})();
|
|
18
|
+
|
|
3
19
|
var CFG = window.APP_CONFIG || {};
|
|
4
20
|
var CSRF = CFG.csrf || '';
|
|
5
21
|
var INITIAL_SESSION = typeof CFG.initialSession === 'number' ? CFG.initialSession : null;
|
|
22
|
+
var IS_ADMIN = String(CFG.userRole || 'user') === 'admin';
|
|
23
|
+
function isAdmin() { return IS_ADMIN; }
|
|
6
24
|
|
|
7
25
|
var els = {
|
|
8
26
|
body: document.body,
|
|
@@ -48,6 +66,7 @@ var els = {
|
|
|
48
66
|
skinPanel: document.getElementById('skinPanel'),
|
|
49
67
|
viewProcs: document.getElementById('viewProcs'),
|
|
50
68
|
viewChanges: document.getElementById('viewChanges'),
|
|
69
|
+
viewMcp: document.getElementById('viewMcp'),
|
|
51
70
|
themeColor: document.getElementById('themeColor'),
|
|
52
71
|
themeLink: document.getElementById('themeStylesheet'),
|
|
53
72
|
statusbar: document.getElementById('statusbar'),
|
|
@@ -87,6 +106,12 @@ var els = {
|
|
|
87
106
|
btnCancel: document.getElementById('btnCancel'),
|
|
88
107
|
};
|
|
89
108
|
|
|
109
|
+
// Marca visual del usuario actual (admin en color de acento).
|
|
110
|
+
(function () {
|
|
111
|
+
var ub = document.getElementById('userBadge');
|
|
112
|
+
if (ub) ub.classList.toggle('admin', IS_ADMIN);
|
|
113
|
+
})();
|
|
114
|
+
|
|
90
115
|
var ONLINE_MAX_AGE_MS = 60000;
|
|
91
116
|
var PROC_TIMEOUT_MS = 45000;
|
|
92
117
|
var POLL_BASE_MS = 4000; // refresco pasivo (sin nada pendiente)
|
|
@@ -96,6 +121,7 @@ var CMDS = [
|
|
|
96
121
|
{ cmd: '/compact', desc: 'Liberar contexto' },
|
|
97
122
|
{ cmd: '/models', desc: 'Proveedores y búsqueda de modelos' },
|
|
98
123
|
{ cmd: '/agents', desc: 'Listar agentes disponibles' },
|
|
124
|
+
{ cmd: '/mcp', desc: 'Servidores MCP de opencode' },
|
|
99
125
|
{ cmd: '/folders', desc: 'Carpetas del workspace' },
|
|
100
126
|
{ cmd: '/workspace', desc: 'Mostrar el espacio de trabajo' },
|
|
101
127
|
{ cmd: '/status', desc: 'Estado del puente' },
|
|
@@ -159,7 +185,7 @@ var state = {
|
|
|
159
185
|
|
|
160
186
|
// Puente activo y lista conocida se restauran antes de pintar (caché local).
|
|
161
187
|
state.activeBridge = loadStoredBridge();
|
|
162
|
-
var _cachedBridges = lsGet('
|
|
188
|
+
var _cachedBridges = lsGet('ob_bridges');
|
|
163
189
|
if (Array.isArray(_cachedBridges) && _cachedBridges.length) state.bridges = _cachedBridges;
|
|
164
190
|
|
|
165
191
|
// Estado del panel "procesos" de la sesión activa (dev servers en la PC).
|
|
@@ -263,8 +289,8 @@ function applyTheme(slug, opts) {
|
|
|
263
289
|
} catch (e) {}
|
|
264
290
|
if (!opts || opts.persist !== false) {
|
|
265
291
|
try {
|
|
266
|
-
document.cookie = '
|
|
267
|
-
localStorage.setItem('
|
|
292
|
+
document.cookie = 'ob_theme=' + encodeURIComponent(s) + ';path=/;max-age=' + (60 * 60 * 24 * 365) + ';SameSite=Lax';
|
|
293
|
+
localStorage.setItem('ob_theme', s);
|
|
268
294
|
} catch (e) {}
|
|
269
295
|
}
|
|
270
296
|
renderSkinPanel();
|
|
@@ -321,16 +347,16 @@ function activeBridgeInfo() { return bridgeInfo(activeBridgeId()); }
|
|
|
321
347
|
// El catálogo/estado se guardan por puente activo (cada PC tiene el suyo).
|
|
322
348
|
function catalogKey() {
|
|
323
349
|
var id = activeBridgeId();
|
|
324
|
-
return id ? '
|
|
350
|
+
return id ? 'ob_catalog_' + id : 'ob_catalog';
|
|
325
351
|
}
|
|
326
352
|
function catalogVerKey() {
|
|
327
353
|
return catalogKey() + '_ver';
|
|
328
354
|
}
|
|
329
355
|
function loadStoredBridge() {
|
|
330
|
-
try { return localStorage.getItem('
|
|
356
|
+
try { return localStorage.getItem('ob_bridge') || ''; } catch (e) { return ''; }
|
|
331
357
|
}
|
|
332
358
|
function storeBridge(id) {
|
|
333
|
-
try { localStorage.setItem('
|
|
359
|
+
try { localStorage.setItem('ob_bridge', id || ''); } catch (e) {}
|
|
334
360
|
}
|
|
335
361
|
|
|
336
362
|
// Sesiones visibles: solo las del puente activo cuando hay varios puentes.
|
|
@@ -377,7 +403,7 @@ function renderBridgeBar() {
|
|
|
377
403
|
// applyBootPayload / switchActiveBridge para no mezclar catálogos).
|
|
378
404
|
function applyBridges(list) {
|
|
379
405
|
state.bridges = Array.isArray(list) ? list : [];
|
|
380
|
-
try { localStorage.setItem('
|
|
406
|
+
try { localStorage.setItem('ob_bridges', JSON.stringify(state.bridges)); } catch (e) {}
|
|
381
407
|
renderBridgeBar();
|
|
382
408
|
}
|
|
383
409
|
|
|
@@ -438,7 +464,7 @@ function applyBootPayload(data) {
|
|
|
438
464
|
state.catalog.last_online_ts = data.online_ts;
|
|
439
465
|
}
|
|
440
466
|
var sessions = data.sessions || [];
|
|
441
|
-
lsSet('
|
|
467
|
+
lsSet('ob_sessions', sessions);
|
|
442
468
|
renderHome(sessions);
|
|
443
469
|
applyOnlineUI();
|
|
444
470
|
}
|
|
@@ -710,13 +736,13 @@ function projectLabel(folderPath) {
|
|
|
710
736
|
function loadOpenProjects() {
|
|
711
737
|
if (state.openProjects) return state.openProjects;
|
|
712
738
|
var stored = null;
|
|
713
|
-
try { stored = JSON.parse(localStorage.getItem('
|
|
739
|
+
try { stored = JSON.parse(localStorage.getItem('ob_openProjects') || 'null'); } catch (e) {}
|
|
714
740
|
state.openProjects = (stored && typeof stored === 'object') ? stored : null;
|
|
715
741
|
return state.openProjects;
|
|
716
742
|
}
|
|
717
743
|
|
|
718
744
|
function saveOpenProjects() {
|
|
719
|
-
try { localStorage.setItem('
|
|
745
|
+
try { localStorage.setItem('ob_openProjects', JSON.stringify(state.openProjects || {})); } catch (e) {}
|
|
720
746
|
}
|
|
721
747
|
|
|
722
748
|
function isProjectOpen(folderPath, sessions) {
|
|
@@ -984,6 +1010,7 @@ function showView(name) {
|
|
|
984
1010
|
els.viewPreview.style.display = 'none';
|
|
985
1011
|
els.viewProcs.style.display = 'none';
|
|
986
1012
|
els.viewChanges.style.display = 'none';
|
|
1013
|
+
els.viewMcp.style.display = 'none';
|
|
987
1014
|
|
|
988
1015
|
if (name !== 'procs') stopProcView();
|
|
989
1016
|
|
|
@@ -1056,6 +1083,12 @@ function showView(name) {
|
|
|
1056
1083
|
els.sendForm.style.display = 'none';
|
|
1057
1084
|
els.btnCmds.style.display = 'none';
|
|
1058
1085
|
renderChangesView();
|
|
1086
|
+
} else if (name === 'mcp') {
|
|
1087
|
+
els.viewMcp.style.display = '';
|
|
1088
|
+
els.btnBack.style.display = 'none';
|
|
1089
|
+
els.sendForm.style.display = 'none';
|
|
1090
|
+
els.btnCmds.style.display = 'none';
|
|
1091
|
+
renderMcpView();
|
|
1059
1092
|
}
|
|
1060
1093
|
|
|
1061
1094
|
if (els.fabNew) els.fabNew.classList.toggle('show', name === 'home');
|
|
@@ -1123,21 +1156,21 @@ if (composeCloseEl) composeCloseEl.addEventListener('click', composeCloseSheet);
|
|
|
1123
1156
|
// Sidebar: logo → inicio, ajuste de ancho y ocultar (desktop) / cerrar drawer (móvil)
|
|
1124
1157
|
// ---------------------------------------------------------------------------
|
|
1125
1158
|
function sbHidden() {
|
|
1126
|
-
try { return localStorage.getItem('
|
|
1159
|
+
try { return localStorage.getItem('ob_sbHidden') === '1'; } catch (e) { return false; }
|
|
1127
1160
|
}
|
|
1128
1161
|
function setSbHidden(hidden) {
|
|
1129
|
-
try { localStorage.setItem('
|
|
1162
|
+
try { localStorage.setItem('ob_sbHidden', hidden ? '1' : '0'); } catch (e) {}
|
|
1130
1163
|
document.body.classList.toggle('sb-hide', hidden);
|
|
1131
1164
|
}
|
|
1132
1165
|
function sbWidth() {
|
|
1133
1166
|
try {
|
|
1134
|
-
var w = parseInt(localStorage.getItem('
|
|
1167
|
+
var w = parseInt(localStorage.getItem('ob_sbW'), 10);
|
|
1135
1168
|
if (w >= 200 && w <= 480) return w;
|
|
1136
1169
|
} catch (e) {}
|
|
1137
1170
|
return 264;
|
|
1138
1171
|
}
|
|
1139
1172
|
function setSbWidth(w) {
|
|
1140
|
-
try { localStorage.setItem('
|
|
1173
|
+
try { localStorage.setItem('ob_sbW', String(w)); } catch (e) {}
|
|
1141
1174
|
document.documentElement.style.setProperty('--sb-w', w + 'px');
|
|
1142
1175
|
}
|
|
1143
1176
|
|
|
@@ -1264,7 +1297,7 @@ function renderHome(sessions) {
|
|
|
1264
1297
|
+ '<div class="cmeta">' + busy + esc(s.model || 'sin modelo') + ' · ' + esc(s.agent || 'build') + '</div>'
|
|
1265
1298
|
+ (s.preview ? '<div class="cpreview">' + prev + '</div>' : '')
|
|
1266
1299
|
+ '</div>'
|
|
1267
|
-
+ '<button type="button" class="kebab" aria-label="Opciones" title="Opciones">⋮</button>'
|
|
1300
|
+
+ (isAdmin() ? '<button type="button" class="kebab" aria-label="Opciones" title="Opciones">⋮</button>' : '')
|
|
1268
1301
|
+ '</div>';
|
|
1269
1302
|
}
|
|
1270
1303
|
html += '</div>';
|
|
@@ -1313,7 +1346,7 @@ function renderHome(sessions) {
|
|
|
1313
1346
|
async function loadSessions() {
|
|
1314
1347
|
var data = await api('api.php?action=sessions');
|
|
1315
1348
|
if (data.ok) {
|
|
1316
|
-
lsSet('
|
|
1349
|
+
lsSet('ob_sessions', data.sessions || []);
|
|
1317
1350
|
renderHome(data.sessions || []);
|
|
1318
1351
|
} else if (!state.sessions.length) {
|
|
1319
1352
|
els.home.innerHTML = '<div class="empty">Sin conexión</div>';
|
|
@@ -1643,13 +1676,13 @@ function onMsgScroll() {
|
|
|
1643
1676
|
// reemplaza solo ese nodo; lo demás no se toca.
|
|
1644
1677
|
function msgSig(m) {
|
|
1645
1678
|
return (m.status || '') + '|' + (m.text || '').length + '|' + (m.reasoning || '').length
|
|
1646
|
-
+ '|' + (m.canceled ? 1 : 0) + '|' + (m.agent || '') + '|' + (m.img ? m.img.length : 0);
|
|
1679
|
+
+ '|' + (m.canceled ? 1 : 0) + '|' + (m.agent || '') + '|' + (m.author || '') + '|' + (m.img ? m.img.length : 0);
|
|
1647
1680
|
}
|
|
1648
1681
|
|
|
1649
1682
|
function msgNodeHtml(m, chatQuery) {
|
|
1650
1683
|
var cls = m.role === 'user' ? 'mine' : 'theirs';
|
|
1651
1684
|
if (m.status === 'canceled') cls += ' canceled';
|
|
1652
|
-
var who = m.role === 'user' ? '❯ vos' : '● Agente';
|
|
1685
|
+
var who = m.role === 'user' ? '❯ ' + esc(m.author || 'vos') : '● Agente';
|
|
1653
1686
|
if (m.agent === 'plan') cls += ' plan';
|
|
1654
1687
|
var agentBadge = m.agent ? ' <span class="abadge ' + esc(m.agent) + '">' + esc(m.agent) + '</span>' : '';
|
|
1655
1688
|
var stopBadge = m.canceled ? ' <span class="stopbadge">⏹ detenido</span>' : '';
|
|
@@ -2999,6 +3032,36 @@ function renderChangesView() {
|
|
|
2999
3032
|
loadChanges(folder);
|
|
3000
3033
|
}
|
|
3001
3034
|
|
|
3035
|
+
// ---------------------------------------------------------------------------
|
|
3036
|
+
// Vista MCP: estado de los servidores MCP de opencode (opencode mcp list).
|
|
3037
|
+
// ---------------------------------------------------------------------------
|
|
3038
|
+
function renderMcpView() {
|
|
3039
|
+
els.hTitle.textContent = 'mcp';
|
|
3040
|
+
els.hSub.textContent = 'servidores MCP de opencode';
|
|
3041
|
+
if (!state.online) {
|
|
3042
|
+
els.viewMcp.innerHTML = '<div class="placeholder">El puente está apagado. Iniciá “OpenBridge” en tu PC.</div>';
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
els.viewMcp.innerHTML = '<div class="view-head"><h2>servidores MCP</h2>'
|
|
3046
|
+
+ '<div class="view-sub">opencode mcp list</div></div>'
|
|
3047
|
+
+ '<div class="placeholder">consultando opencode…</div>';
|
|
3048
|
+
loadMcp();
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
async function loadMcp() {
|
|
3052
|
+
var res = await ocCommand('mcp_list', [], 15, 700);
|
|
3053
|
+
if (!els.viewMcp) return;
|
|
3054
|
+
var head = '<div class="view-head"><h2>servidores MCP</h2>'
|
|
3055
|
+
+ '<div class="view-sub">opencode mcp list</div></div>';
|
|
3056
|
+
if (!res.ok) {
|
|
3057
|
+
els.viewMcp.innerHTML = head + '<div class="placeholder">' + esc(res.error || 'no se pudo consultar') + '</div>';
|
|
3058
|
+
return;
|
|
3059
|
+
}
|
|
3060
|
+
var output = (res.data && res.data.output) || '';
|
|
3061
|
+
els.viewMcp.innerHTML = head + '<pre class="codeblock">'
|
|
3062
|
+
+ esc(output || 'No hay servidores MCP configurados.') + '</pre>';
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3002
3065
|
function chgStatusClass(st) {
|
|
3003
3066
|
if (st === '??' || st === 'A') return 'add';
|
|
3004
3067
|
if (st.indexOf('D') >= 0) return 'del';
|
|
@@ -3046,7 +3109,7 @@ async function loadChanges(folder) {
|
|
|
3046
3109
|
}
|
|
3047
3110
|
var list = '<div class="chg-files">';
|
|
3048
3111
|
for (var i = 0; i < files.length; i++) {
|
|
3049
|
-
var rev = files[i].status !== '??'
|
|
3112
|
+
var rev = (isAdmin() && files[i].status !== '??')
|
|
3050
3113
|
? '<button type="button" class="chg-rev" data-path="' + esc(files[i].path) + '" title="Revertir este archivo" aria-label="Revertir este archivo">↩</button>'
|
|
3051
3114
|
: '';
|
|
3052
3115
|
list += '<div class="chg-row"><span class="chg-st ' + chgStatusClass(files[i].status) + '">' + esc(files[i].status) + '</span>'
|
|
@@ -3054,7 +3117,7 @@ async function loadChanges(folder) {
|
|
|
3054
3117
|
}
|
|
3055
3118
|
list += '</div>';
|
|
3056
3119
|
var tracked = files.some(function (f) { return f.status !== '??'; });
|
|
3057
|
-
var toolbar = tracked
|
|
3120
|
+
var toolbar = (isAdmin() && tracked)
|
|
3058
3121
|
? '<div class="files-toolbar"><button type="button" class="linkbtn" id="chgRevert">↩ revertir cambios rastreados</button></div>'
|
|
3059
3122
|
: '';
|
|
3060
3123
|
els.viewChanges.innerHTML = changesHead(folder, status.branch) + toolbar + list + '<div class="placeholder">cargando diff…</div>';
|
|
@@ -3075,6 +3138,7 @@ async function loadChanges(folder) {
|
|
|
3075
3138
|
// Sirve para un archivo puntual (boton de la fila) o para todos.
|
|
3076
3139
|
if (els.viewChanges) {
|
|
3077
3140
|
els.viewChanges.addEventListener('click', function (e) {
|
|
3141
|
+
if (!isAdmin()) return;
|
|
3078
3142
|
var folder = changesFolder();
|
|
3079
3143
|
if (!folder) return;
|
|
3080
3144
|
var one = e.target.closest('.chg-rev');
|
|
@@ -3393,25 +3457,30 @@ function validPort(p) {
|
|
|
3393
3457
|
|
|
3394
3458
|
function renderPreviewView() {
|
|
3395
3459
|
if (!state.previewPort) {
|
|
3396
|
-
try { state.previewPort = localStorage.getItem('
|
|
3460
|
+
try { state.previewPort = localStorage.getItem('ob_lastPort') || ''; } catch (e) {}
|
|
3397
3461
|
}
|
|
3398
3462
|
els.viewPreview.innerHTML = '<div class="view-head"><h2>vista previa</h2>'
|
|
3399
3463
|
+ '<div class="view-sub">túneles del proyecto (TunnelMole, corren en tu PC)</div></div>'
|
|
3400
|
-
+
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3464
|
+
+ (isAdmin()
|
|
3465
|
+
? '<div class="tunnel-form">'
|
|
3466
|
+
+ '<input type="text" id="tunnelPort" inputmode="numeric" placeholder="puerto (ej: 8080)" value="' + esc(state.previewPort) + '" autocomplete="off">'
|
|
3467
|
+
+ '<button type="button" class="linkbtn" id="tunnelStart">abrir túnel</button>'
|
|
3468
|
+
+ '</div>'
|
|
3469
|
+
: '')
|
|
3404
3470
|
+ '<div class="tnote-sec" style="margin:0 0 12px">los túneles son públicos en internet y viven mientras el puente esté corriendo · cerrálos al terminar</div>'
|
|
3405
3471
|
+ '<div id="tunnelList"></div>';
|
|
3406
3472
|
var portInput = document.getElementById('tunnelPort');
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3473
|
+
if (portInput) {
|
|
3474
|
+
portInput.addEventListener('input', function () {
|
|
3475
|
+
state.previewPort = portInput.value.replace(/[^0-9]/g, '');
|
|
3476
|
+
portInput.value = state.previewPort;
|
|
3477
|
+
});
|
|
3478
|
+
portInput.addEventListener('keydown', function (e) {
|
|
3479
|
+
if (e.key === 'Enter') { e.preventDefault(); startTunnel(); }
|
|
3480
|
+
});
|
|
3481
|
+
var tunStartBtn = document.getElementById('tunnelStart');
|
|
3482
|
+
if (tunStartBtn) tunStartBtn.addEventListener('click', startTunnel);
|
|
3483
|
+
}
|
|
3415
3484
|
renderTunnels();
|
|
3416
3485
|
refreshTunnels();
|
|
3417
3486
|
}
|
|
@@ -3428,7 +3497,8 @@ function renderTunnels(busyMsg) {
|
|
|
3428
3497
|
+ '<div class="thead"><span class="dot"' + (ready ? '' : ' style="background:var(--muted)"') + '></span>'
|
|
3429
3498
|
+ '<b>puerto ' + esc(t.port) + '</b>'
|
|
3430
3499
|
+ '<span class="tnote">' + (ready ? 'activo' : 'levantando…') + '</span>'
|
|
3431
|
-
+ '<button type="button" class="linkbtn" data-tstop="' + esc(t.port) + '">cerrar túnel</button
|
|
3500
|
+
+ (isAdmin() ? '<button type="button" class="linkbtn" data-tstop="' + esc(t.port) + '">cerrar túnel</button>' : '')
|
|
3501
|
+
+ '</div>'
|
|
3432
3502
|
+ (t.https ? '<div class="turl"><a href="' + esc(t.https) + '" target="_blank" rel="noopener noreferrer">' + esc(t.https) + '</a>'
|
|
3433
3503
|
+ '<button type="button" class="copybtn" data-tcopy="' + esc(t.https) + '" title="Copiar URL">⧉</button></div>' : '')
|
|
3434
3504
|
+ (t.http && t.http !== t.https ? '<div class="turl"><a href="' + esc(t.http) + '" target="_blank" rel="noopener noreferrer">' + esc(t.http) + '</a>'
|
|
@@ -3459,7 +3529,7 @@ function renderTunnels(busyMsg) {
|
|
|
3459
3529
|
async function startTunnel() {
|
|
3460
3530
|
var port = validPort(state.previewPort);
|
|
3461
3531
|
if (!port) { toast('escribí un puerto válido (1–65535)', 'error'); return; }
|
|
3462
|
-
try { localStorage.setItem('
|
|
3532
|
+
try { localStorage.setItem('ob_lastPort', String(port)); } catch (e) {}
|
|
3463
3533
|
var already = (state.tunnels || []).some(function (t) { return parseInt(t.port, 10) === port && (t.https || t.http); });
|
|
3464
3534
|
if (already) { toast('ya hay un túnel para el puerto ' + port, 'ok'); return; }
|
|
3465
3535
|
renderTunnels('levantando túnel para el puerto ' + port + '… (la primera vez puede tardar si descarga tunnelmole)');
|
|
@@ -3588,6 +3658,7 @@ function enterProcsView() {
|
|
|
3588
3658
|
}
|
|
3589
3659
|
|
|
3590
3660
|
function renderProcView(folder) {
|
|
3661
|
+
var admin = isAdmin();
|
|
3591
3662
|
els.viewProcs.innerHTML =
|
|
3592
3663
|
'<div class="pp-head"><b>procesos</b><span class="pp-sub">' + esc(sessionProcLabel(folder)) + '</span>'
|
|
3593
3664
|
+ (state.currentId !== null ? '<button type="button" class="linkbtn pp-back" id="ppBack">← volver al chat</button>' : '')
|
|
@@ -3595,35 +3666,42 @@ function renderProcView(folder) {
|
|
|
3595
3666
|
+ '<div class="pp-status" id="procStatus"></div>'
|
|
3596
3667
|
+ '<div id="ppPortWarn"></div>'
|
|
3597
3668
|
+ '<pre id="procLog"><span class="pl-empty">sin salida todavía</span></pre>'
|
|
3598
|
-
+ '<div class="pp-tunnel">'
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
+ '<div class="pp-chips" id="procChips"></div>'
|
|
3605
|
-
+ '<div class="pp-form"><input type="text" id="procCmdInput" placeholder="npm run dev · node server.js …" autocomplete="off">'
|
|
3606
|
-
|
|
3669
|
+
+ (admin ? '<div class="pp-tunnel">'
|
|
3670
|
+
+ '<div class="pp-tunhead">compartir por túnel (tunnelmole, corre en tu PC)</div>'
|
|
3671
|
+
+ '<div class="pp-tunform"><input type="text" id="ppTunPort" inputmode="numeric" placeholder="puerto (ej: 3000)" autocomplete="off">'
|
|
3672
|
+
+ '<button type="button" class="linkbtn" id="ppTunStart">abrir túnel</button></div>'
|
|
3673
|
+
+ '<div id="ppTunList"></div>'
|
|
3674
|
+
+ '</div>' : '')
|
|
3675
|
+
+ (admin ? '<div class="pp-chips" id="procChips"></div>' : '')
|
|
3676
|
+
+ (admin ? '<div class="pp-form"><input type="text" id="procCmdInput" placeholder="npm run dev · node server.js …" autocomplete="off">'
|
|
3677
|
+
+ '<button type="button" class="linkbtn" id="procStartBtn">iniciar</button></div>' : '')
|
|
3678
|
+
+ (!admin ? '<div class="placeholder">solo un admin puede iniciar procesos o túneles.</div>' : '');
|
|
3607
3679
|
var back = document.getElementById('ppBack');
|
|
3608
3680
|
if (back) back.addEventListener('click', function () { showView('chat'); });
|
|
3609
3681
|
var inp = document.getElementById('procCmdInput');
|
|
3610
|
-
inp
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3682
|
+
if (inp) {
|
|
3683
|
+
inp.value = procState.input || '';
|
|
3684
|
+
inp.addEventListener('input', function () { procState.input = inp.value; });
|
|
3685
|
+
inp.addEventListener('keydown', function (e) {
|
|
3686
|
+
if (e.key === 'Enter') { e.preventDefault(); startSessionProc(); }
|
|
3687
|
+
});
|
|
3688
|
+
}
|
|
3689
|
+
var startBtn = document.getElementById('procStartBtn');
|
|
3690
|
+
if (startBtn) startBtn.addEventListener('click', startSessionProc);
|
|
3616
3691
|
var tunPort = document.getElementById('ppTunPort');
|
|
3617
|
-
tunPort
|
|
3618
|
-
|
|
3619
|
-
tunPort.
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3692
|
+
if (tunPort) {
|
|
3693
|
+
tunPort.value = procState.detectedPort || '';
|
|
3694
|
+
tunPort.addEventListener('input', function () {
|
|
3695
|
+
tunPort.value = tunPort.value.replace(/[^0-9]/g, '');
|
|
3696
|
+
if (tunPort.value) state.previewPort = tunPort.value;
|
|
3697
|
+
});
|
|
3698
|
+
tunPort.addEventListener('keydown', function (e) {
|
|
3699
|
+
if (e.key === 'Enter') { e.preventDefault(); panelStartTunnel(); }
|
|
3700
|
+
});
|
|
3701
|
+
}
|
|
3702
|
+
var tunStart = document.getElementById('ppTunStart');
|
|
3703
|
+
if (tunStart) tunStart.addEventListener('click', panelStartTunnel);
|
|
3704
|
+
if (admin) buildProcChips();
|
|
3627
3705
|
procLogBox = document.getElementById('procLog');
|
|
3628
3706
|
renderProcStatus();
|
|
3629
3707
|
renderPortWarn();
|
|
@@ -3908,7 +3986,8 @@ function renderProcTunnels(busyMsg) {
|
|
|
3908
3986
|
var ready = !!(t.https || t.http);
|
|
3909
3987
|
html += '<div class="tunnel"><div class="thead"><span class="dot"' + (ready ? '' : ' style="background:var(--muted)"') + '></span>'
|
|
3910
3988
|
+ '<b>puerto ' + esc(t.port) + '</b><span class="tnote">' + (ready ? 'activo' : 'levantando…') + '</span>'
|
|
3911
|
-
+ '<button type="button" class="linkbtn" data-ptstop="' + esc(t.port) + '">cerrar</button
|
|
3989
|
+
+ (isAdmin() ? '<button type="button" class="linkbtn" data-ptstop="' + esc(t.port) + '">cerrar</button>' : '')
|
|
3990
|
+
+ '</div>'
|
|
3912
3991
|
+ (t.https ? '<div class="turl"><a href="' + esc(t.https) + '" target="_blank" rel="noopener noreferrer">' + esc(t.https) + '</a>'
|
|
3913
3992
|
+ '<button type="button" class="copybtn" data-ptcopy="' + esc(t.https) + '" title="Copiar URL">⧉</button></div>' : '')
|
|
3914
3993
|
+ (t.http && t.http !== t.https ? '<div class="turl"><a href="' + esc(t.http) + '" target="_blank" rel="noopener noreferrer">' + esc(t.http) + '</a>'
|
|
@@ -3941,7 +4020,7 @@ async function panelStartTunnel() {
|
|
|
3941
4020
|
var inp = document.getElementById('ppTunPort');
|
|
3942
4021
|
var port = validPort(inp ? inp.value : '');
|
|
3943
4022
|
if (!port) { toast('escribí un puerto válido (1–65535)', 'error'); return; }
|
|
3944
|
-
try { localStorage.setItem('
|
|
4023
|
+
try { localStorage.setItem('ob_lastPort', String(port)); } catch (e) {}
|
|
3945
4024
|
var already = (state.tunnels || []).some(function (t) { return parseInt(t.port, 10) === port; });
|
|
3946
4025
|
if (already) { toast('ya hay un túnel para el puerto ' + port, 'ok'); return; }
|
|
3947
4026
|
renderProcTunnels('levantando túnel para el puerto ' + port + '… (la primera vez descarga tunnelmole)');
|
|
@@ -4115,7 +4194,7 @@ function refreshActiveView(reason) {
|
|
|
4115
4194
|
async function boot() {
|
|
4116
4195
|
var cachedCat = lsGet(catalogKey());
|
|
4117
4196
|
var cachedVer = lsGet(catalogVerKey());
|
|
4118
|
-
var cachedSessions = lsGet('
|
|
4197
|
+
var cachedSessions = lsGet('ob_sessions');
|
|
4119
4198
|
var painted = false;
|
|
4120
4199
|
if (cachedCat && typeof cachedCat === 'object') {
|
|
4121
4200
|
state.catalog = cachedCat;
|
package/src/web/assets/sw.js
CHANGED
package/src/web/routes.js
CHANGED
|
@@ -18,6 +18,15 @@ const jsonfile = require('../store/jsonfile');
|
|
|
18
18
|
|
|
19
19
|
const TEMPLATES = path.join(__dirname, 'templates');
|
|
20
20
|
|
|
21
|
+
// Hash dummy para igualar el costo de scrypt cuando el usuario no existe (evita
|
|
22
|
+
// filtrar si un nombre esta registrado por diferencias de tiempo).
|
|
23
|
+
const DUMMY_HASH = { algo: 'scrypt', salt: '00000000000000000000000000000000', hash: '00'.repeat(64), keylen: 64 };
|
|
24
|
+
|
|
25
|
+
// Acciones que solo puede hacer un admin.
|
|
26
|
+
const ADMIN_ONLY_ACTIONS = new Set(['session_delete']);
|
|
27
|
+
// Comandos del puente que mutan algo (procesos, tuneles, revertir).
|
|
28
|
+
const OC_MUTATING = new Set(['proc_start', 'proc_stop', 'tunnel_start', 'tunnel_stop', 'git_checkout']);
|
|
29
|
+
|
|
21
30
|
async function renderTpl(name, vars) {
|
|
22
31
|
const tpl = await fsp.readFile(path.join(TEMPLATES, name), 'utf8');
|
|
23
32
|
return tpl.replace(/\{\{([A-Z_]+)\}\}/g, (m, k) => (
|
|
@@ -28,7 +37,7 @@ async function renderTpl(name, vars) {
|
|
|
28
37
|
function pickTheme(req, query, known) {
|
|
29
38
|
const cookies = auth.parseCookies(req.headers.cookie);
|
|
30
39
|
const fromUrl = String(query.get('theme') || '').replace(/[^a-z-]/g, '').toLowerCase();
|
|
31
|
-
const fromCookie = String(cookies['ocx_theme'] || '').replace(/[^a-z-]/g, '').toLowerCase();
|
|
40
|
+
const fromCookie = String(cookies['ob_theme'] || cookies['ocx_theme'] || '').replace(/[^a-z-]/g, '').toLowerCase();
|
|
32
41
|
if (fromUrl && known.includes(fromUrl)) return fromUrl;
|
|
33
42
|
if (fromCookie && known.includes(fromCookie)) return fromCookie;
|
|
34
43
|
return 'terminal';
|
|
@@ -59,6 +68,12 @@ async function loginCsrf(req, res) {
|
|
|
59
68
|
return csrf;
|
|
60
69
|
}
|
|
61
70
|
|
|
71
|
+
// Ultimo usuario logueado (para prellenar el campo en el login).
|
|
72
|
+
function lastUser(req) {
|
|
73
|
+
const cookies = auth.parseCookies(req.headers.cookie);
|
|
74
|
+
return String(cookies['ob_lastuser'] || '').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 32);
|
|
75
|
+
}
|
|
76
|
+
|
|
62
77
|
async function handleLoginPage({ app, req, res, query }) {
|
|
63
78
|
if (auth.readSession(app, req)) {
|
|
64
79
|
res.writeHead(302, { Location: 'chat.php' });
|
|
@@ -71,6 +86,7 @@ async function handleLoginPage({ app, req, res, query }) {
|
|
|
71
86
|
THEMES_JSON: JSON.stringify(store.themesIndex()),
|
|
72
87
|
ERROR_BLOCK: '',
|
|
73
88
|
CSRF: csrf,
|
|
89
|
+
USER_PREFILL: lastUser(req),
|
|
74
90
|
});
|
|
75
91
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
76
92
|
res.end(html);
|
|
@@ -87,25 +103,34 @@ async function handleLogin({ app, req, res, query, form }) {
|
|
|
87
103
|
THEMES_JSON: JSON.stringify(store.themesIndex()),
|
|
88
104
|
ERROR_BLOCK: '<div class="error">\u26a0 ' + msg.replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c])) + '</div>',
|
|
89
105
|
CSRF: csrf,
|
|
106
|
+
USER_PREFILL: lastUser(req) || String(form.username || '').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 32),
|
|
90
107
|
});
|
|
91
108
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
92
109
|
res.end(html);
|
|
93
110
|
};
|
|
94
111
|
if (!csrfOk) return renderError('Sesion expirada, recarga la pagina.');
|
|
95
|
-
const
|
|
112
|
+
const username = String(form.username || '').trim();
|
|
113
|
+
const locked = auth.loginLockRemaining(req, username);
|
|
96
114
|
if (locked > 0) {
|
|
97
115
|
const mins = Math.max(1, Math.ceil(locked / 60));
|
|
98
116
|
return renderError('Demasiados intentos fallidos. Proba de nuevo en ' + mins + ' min.');
|
|
99
117
|
}
|
|
100
118
|
const password = String(form.password || '');
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
119
|
+
const user = config.findUser(app, username);
|
|
120
|
+
const passOk = config.verifyUserPassword(user, password);
|
|
121
|
+
if (!user) config.verifyPassword(password, DUMMY_HASH); // mismo costo si no existe
|
|
122
|
+
if (user && !user.disabled && passOk) {
|
|
123
|
+
auth.loginClear(req, username);
|
|
124
|
+
auth.startSession(app, req, res, user);
|
|
125
|
+
const prev = res.getHeader('Set-Cookie');
|
|
126
|
+
const jar = Array.isArray(prev) ? prev.slice() : (prev ? [prev] : []);
|
|
127
|
+
jar.push(auth.serializeCookie('ob_lastuser', user.name, req, 60 * 60 * 24 * 365));
|
|
128
|
+
res.setHeader('Set-Cookie', jar);
|
|
104
129
|
res.writeHead(302, { Location: 'chat.php' });
|
|
105
130
|
return res.end();
|
|
106
131
|
}
|
|
107
|
-
auth.loginRecordFailure(req);
|
|
108
|
-
return renderError('
|
|
132
|
+
auth.loginRecordFailure(req, username);
|
|
133
|
+
return renderError('Usuario o contrasena incorrectos.');
|
|
109
134
|
}
|
|
110
135
|
|
|
111
136
|
async function handleChat({ app, req, res, query }) {
|
|
@@ -122,6 +147,8 @@ async function handleChat({ app, req, res, query }) {
|
|
|
122
147
|
const html = await renderTpl('chat.html', {
|
|
123
148
|
THEME: pickTheme(req, query, known),
|
|
124
149
|
CSRF: session.c,
|
|
150
|
+
USER_NAME: session.name,
|
|
151
|
+
USER_ROLE: session.role,
|
|
125
152
|
INITIAL_SESSION: initial > 0 ? String(initial) : 'null',
|
|
126
153
|
THEMES_JSON: JSON.stringify(store.themesIndex()),
|
|
127
154
|
SSE_DISABLED: 'false',
|
|
@@ -169,6 +196,7 @@ const OC_ALLOWED = {
|
|
|
169
196
|
session_list: [],
|
|
170
197
|
session_info: ['arg'],
|
|
171
198
|
opencode_version: [],
|
|
199
|
+
mcp_list: [],
|
|
172
200
|
fs_list: ['path'],
|
|
173
201
|
fs_read: ['path'],
|
|
174
202
|
git_status: ['path'],
|
|
@@ -259,8 +287,9 @@ async function handleApi(ctx) {
|
|
|
259
287
|
return ok({ ok: true, session: await store.getSession(id) });
|
|
260
288
|
}
|
|
261
289
|
case 'session_delete': {
|
|
262
|
-
|
|
263
|
-
if (!
|
|
290
|
+
const s = auth.requireCsrf(app, req, res);
|
|
291
|
+
if (!s) return;
|
|
292
|
+
if (s.role !== 'admin') return ok({ ok: false, error: 'Permiso insuficiente' }, 403);
|
|
264
293
|
const id = parseInt(body.id, 10) || 0;
|
|
265
294
|
if (id <= 0) return ok({ ok: false, error: 'id invalido' }, 400);
|
|
266
295
|
await store.deleteSession(id);
|
|
@@ -339,8 +368,8 @@ async function handleApi(ctx) {
|
|
|
339
368
|
return ok({ ok: true, cancel: false });
|
|
340
369
|
}
|
|
341
370
|
case 'send': {
|
|
342
|
-
|
|
343
|
-
if (!
|
|
371
|
+
const s = auth.requireCsrf(app, req, res);
|
|
372
|
+
if (!s) return;
|
|
344
373
|
const sid = parseInt(body.session, 10) || 0;
|
|
345
374
|
const sess = sid > 0 ? await store.getSession(sid) : null;
|
|
346
375
|
if (!sess) return ok({ ok: false, error: 'Sesion no encontrada' }, 404);
|
|
@@ -352,7 +381,7 @@ async function handleApi(ctx) {
|
|
|
352
381
|
}
|
|
353
382
|
if (text === '' && img === '') return ok({ ok: false, error: 'Mensaje vacio' }, 400);
|
|
354
383
|
if (Array.from(text).length > 10000) return ok({ ok: false, error: 'Mensaje demasiado largo' }, 400);
|
|
355
|
-
const extra = { agent: String(sess.agent || 'build') };
|
|
384
|
+
const extra = { agent: String(sess.agent || 'build'), author: s.name };
|
|
356
385
|
if (img !== '') extra.img = img;
|
|
357
386
|
const id = await store.addMessage(sid, 'user', text, 'pending', extra);
|
|
358
387
|
if (store.sessionHasDefaultName(sess)) {
|
|
@@ -652,11 +681,13 @@ async function handleApi(ctx) {
|
|
|
652
681
|
let aid = null;
|
|
653
682
|
const found = await store.messagesUpdate(sid, (data) => {
|
|
654
683
|
let msgFound = false;
|
|
684
|
+
let author = '';
|
|
655
685
|
for (const msg of data.messages) {
|
|
656
686
|
if (parseInt(msg.id, 10) === userId) {
|
|
657
687
|
msg.status = 'done';
|
|
658
688
|
msg.answered_ts = store.nowIso();
|
|
659
689
|
delete msg.cancel_requested;
|
|
690
|
+
if (typeof msg.author === 'string') author = msg.author;
|
|
660
691
|
msgFound = true;
|
|
661
692
|
break;
|
|
662
693
|
}
|
|
@@ -677,6 +708,7 @@ async function handleApi(ctx) {
|
|
|
677
708
|
if (reasoning !== '') m.reasoning = reasoning; else delete m.reasoning;
|
|
678
709
|
if (canceled) m.canceled = true; else delete m.canceled;
|
|
679
710
|
if (!m.agent) m.agent = store.messageAgentOf(data, userId, (sess && sess.agent) || '');
|
|
711
|
+
if (author && !m.author) m.author = author;
|
|
680
712
|
delete m.draft_for;
|
|
681
713
|
} else {
|
|
682
714
|
aid = data.nextId;
|
|
@@ -684,6 +716,7 @@ async function handleApi(ctx) {
|
|
|
684
716
|
const nm = { id: aid, role: 'assistant', text, ts: store.nowIso(), status: 'done', agent: store.messageAgentOf(data, userId, (sess && sess.agent) || '') };
|
|
685
717
|
if (reasoning !== '') nm.reasoning = reasoning;
|
|
686
718
|
if (canceled) nm.canceled = true;
|
|
719
|
+
if (author) nm.author = author;
|
|
687
720
|
data.messages.push(nm);
|
|
688
721
|
}
|
|
689
722
|
});
|
|
@@ -723,13 +756,14 @@ async function handleApi(ctx) {
|
|
|
723
756
|
return ok({ ok: true, path: rel, size, mtime: Math.floor(st.mtimeMs / 1000), kind: 'text', content });
|
|
724
757
|
}
|
|
725
758
|
case 'run_oc': {
|
|
726
|
-
|
|
727
|
-
if (!
|
|
759
|
+
const s = auth.requireCsrf(app, req, res);
|
|
760
|
+
if (!s) return;
|
|
728
761
|
const bridge = bodyBridge(req, query, body) || await webBridge(req, query);
|
|
729
762
|
const file = paths.bridgeCatalogFile(bridge);
|
|
730
763
|
const cmd = String(body.cmd || '').toLowerCase().replace(/[^a-z_]/g, '');
|
|
731
764
|
const args = Array.isArray(body.args) ? body.args : [];
|
|
732
765
|
if (!Object.prototype.hasOwnProperty.call(OC_ALLOWED, cmd)) return ok({ ok: false, error: 'Comando no permitido' }, 400);
|
|
766
|
+
if (OC_MUTATING.has(cmd) && s.role !== 'admin') return ok({ ok: false, error: 'Permiso insuficiente' }, 403);
|
|
733
767
|
const spec = OC_ALLOWED[cmd];
|
|
734
768
|
const cleanArgs = [];
|
|
735
769
|
for (let i = 0; i < args.length; i++) {
|