@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.
@@ -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('ocx_bridges');
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 = 'ocx_theme=' + encodeURIComponent(s) + ';path=/;max-age=' + (60 * 60 * 24 * 365) + ';SameSite=Lax';
267
- localStorage.setItem('ocx_theme', s);
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 ? 'ocx_catalog_' + id : 'ocx_catalog';
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('ocx_bridge') || ''; } catch (e) { return ''; }
356
+ try { return localStorage.getItem('ob_bridge') || ''; } catch (e) { return ''; }
331
357
  }
332
358
  function storeBridge(id) {
333
- try { localStorage.setItem('ocx_bridge', id || ''); } catch (e) {}
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('ocx_bridges', JSON.stringify(state.bridges)); } catch (e) {}
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('ocx_sessions', sessions);
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('ocx_openProjects') || 'null'); } catch (e) {}
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('ocx_openProjects', JSON.stringify(state.openProjects || {})); } catch (e) {}
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('ocx_sbHidden') === '1'; } catch (e) { return false; }
1159
+ try { return localStorage.getItem('ob_sbHidden') === '1'; } catch (e) { return false; }
1127
1160
  }
1128
1161
  function setSbHidden(hidden) {
1129
- try { localStorage.setItem('ocx_sbHidden', hidden ? '1' : '0'); } catch (e) {}
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('ocx_sbW'), 10);
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('ocx_sbW', String(w)); } catch (e) {}
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('ocx_sessions', data.sessions || []);
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('ocx_lastPort') || ''; } catch (e) {}
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
- + '<div class="tunnel-form">'
3401
- + '<input type="text" id="tunnelPort" inputmode="numeric" placeholder="puerto (ej: 8080)" value="' + esc(state.previewPort) + '" autocomplete="off">'
3402
- + '<button type="button" class="linkbtn" id="tunnelStart">abrir túnel</button>'
3403
- + '</div>'
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
- portInput.addEventListener('input', function () {
3408
- state.previewPort = portInput.value.replace(/[^0-9]/g, '');
3409
- portInput.value = state.previewPort;
3410
- });
3411
- portInput.addEventListener('keydown', function (e) {
3412
- if (e.key === 'Enter') { e.preventDefault(); startTunnel(); }
3413
- });
3414
- document.getElementById('tunnelStart').addEventListener('click', startTunnel);
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></div>'
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('ocx_lastPort', String(port)); } catch (e) {}
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
- + '<div class="pp-tunhead">compartir por túnel (tunnelmole, corre en tu PC)</div>'
3600
- + '<div class="pp-tunform"><input type="text" id="ppTunPort" inputmode="numeric" placeholder="puerto (ej: 3000)" autocomplete="off">'
3601
- + '<button type="button" class="linkbtn" id="ppTunStart">abrir túnel</button></div>'
3602
- + '<div id="ppTunList"></div>'
3603
- + '</div>'
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
- + '<button type="button" class="linkbtn" id="procStartBtn">iniciar</button></div>';
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.value = procState.input || '';
3611
- inp.addEventListener('input', function () { procState.input = inp.value; });
3612
- inp.addEventListener('keydown', function (e) {
3613
- if (e.key === 'Enter') { e.preventDefault(); startSessionProc(); }
3614
- });
3615
- document.getElementById('procStartBtn').addEventListener('click', startSessionProc);
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.value = procState.detectedPort || '';
3618
- tunPort.addEventListener('input', function () {
3619
- tunPort.value = tunPort.value.replace(/[^0-9]/g, '');
3620
- if (tunPort.value) state.previewPort = tunPort.value;
3621
- });
3622
- tunPort.addEventListener('keydown', function (e) {
3623
- if (e.key === 'Enter') { e.preventDefault(); panelStartTunnel(); }
3624
- });
3625
- document.getElementById('ppTunStart').addEventListener('click', panelStartTunnel);
3626
- buildProcChips();
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></div>'
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('ocx_lastPort', String(port)); } catch (e) {}
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('ocx_sessions');
4197
+ var cachedSessions = lsGet('ob_sessions');
4119
4198
  var painted = false;
4120
4199
  if (cachedCat && typeof cachedCat === 'object') {
4121
4200
  state.catalog = cachedCat;
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "name": "OpenBridge",
3
3
  "short_name": "OpenBridge",
4
+ "id": "/",
5
+ "lang": "es",
4
6
  "description": "Chat con tu IA en el PC (opencode)",
5
7
  "start_url": "./chat.php",
6
8
  "scope": "./",
@@ -6,7 +6,7 @@
6
6
  */
7
7
  'use strict';
8
8
 
9
- const CACHE = 'openbridge-v3';
9
+ const CACHE = 'openbridge-v4';
10
10
 
11
11
  self.addEventListener('install', () => self.skipWaiting());
12
12
  self.addEventListener('activate', (event) => {
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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[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 locked = auth.loginLockRemaining(req);
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
- if (config.verifyPassword(password, app.password)) {
102
- auth.loginClear(req);
103
- auth.startSession(app, req, res, app.username);
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('Contrasena incorrecta.');
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
- if (!auth.requireLogin(app, req, res)) return;
263
- if (!auth.requireCsrf(app, req, res)) return;
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
- if (!auth.requireLogin(app, req, res)) return;
343
- if (!auth.requireCsrf(app, req, res)) return;
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
- if (!auth.requireLogin(app, req, res)) return;
727
- if (!auth.requireCsrf(app, req, res)) return;
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++) {