chocolatito-code 1.6.7 → 1.6.9

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.
Files changed (55) hide show
  1. package/README.md +447 -341
  2. package/dist/agent/context.d.ts +6 -0
  3. package/dist/agent/context.js +69 -2
  4. package/dist/agent/loop.js +8 -0
  5. package/dist/agent/toolGate.js +13 -7
  6. package/dist/agent/verifier.js +2 -1
  7. package/dist/config/permissions.d.ts +17 -0
  8. package/dist/config/permissions.js +47 -7
  9. package/dist/config/plataforma.d.ts +62 -0
  10. package/dist/config/plataforma.js +109 -0
  11. package/dist/config/updater.js +4 -1
  12. package/dist/hooks/manager.js +2 -1
  13. package/dist/index.js +99 -6
  14. package/dist/memory/manager.d.ts +15 -5
  15. package/dist/memory/manager.js +146 -29
  16. package/dist/servidor/captura.d.ts +36 -0
  17. package/dist/servidor/captura.js +74 -0
  18. package/dist/servidor/pagina.d.ts +27 -0
  19. package/dist/servidor/pagina.js +268 -0
  20. package/dist/servidor/puente.d.ts +34 -0
  21. package/dist/servidor/puente.js +115 -0
  22. package/dist/servidor/servidor.d.ts +61 -0
  23. package/dist/servidor/servidor.js +249 -0
  24. package/dist/sessions/manager.d.ts +8 -0
  25. package/dist/sessions/manager.js +44 -0
  26. package/dist/sessions/resume.d.ts +12 -0
  27. package/dist/sessions/resume.js +10 -0
  28. package/dist/tools/backgroundTask.d.ts +64 -0
  29. package/dist/tools/backgroundTask.js +264 -0
  30. package/dist/tools/browserExtension.d.ts +2 -1
  31. package/dist/tools/browserExtension.js +48 -0
  32. package/dist/tools/computerUse.js +51 -9
  33. package/dist/tools/definitions.js +93 -1
  34. package/dist/tools/gitAudit.d.ts +68 -0
  35. package/dist/tools/gitAudit.js +374 -0
  36. package/dist/tools/runCommand.js +5 -2
  37. package/dist/tools/runner.js +36 -2
  38. package/dist/tools/safety.d.ts +1 -0
  39. package/dist/tools/safety.js +3 -0
  40. package/dist/tools/todoTool.d.ts +2 -0
  41. package/dist/tools/todoTool.js +29 -0
  42. package/dist/tools/toolDefsComputer.js +9 -0
  43. package/dist/tools/win/hostScript.js +7 -2
  44. package/dist/ui/comandos.js +2 -0
  45. package/dist/ui/marco.d.ts +1 -0
  46. package/dist/ui/marco.js +4 -2
  47. package/dist/ui/permissionPrompt.d.ts +9 -0
  48. package/dist/ui/permissionPrompt.js +30 -0
  49. package/dist/ui/pieFijo.d.ts +1 -1
  50. package/dist/ui/pieFijo.js +1 -1
  51. package/dist/ui/renderer.js +8 -0
  52. package/extension/background.js +80 -0
  53. package/extension/content.js +106 -0
  54. package/extension/manifest.json +61 -60
  55. package/package.json +3 -2
@@ -0,0 +1,268 @@
1
+ /**
2
+ * LA PAGINA, ENTERA Y SIN DEPENDENCIAS
3
+ *
4
+ * Nada de CDN: ni una fuente, ni una libreria. Tres razones y las tres pesan.
5
+ * Corre en tu red local, donde puede no haber salida a internet; un CDN veria la
6
+ * direccion desde la que entra cada sesion de cada cliente; y una pagina que
7
+ * depende de un tercero se rompe el dia que el tercero cambia algo.
8
+ *
9
+ * ESTA PENSADA PARA UN MOVIL, Y ESO SON DECISIONES CONCRETAS
10
+ *
11
+ * - La caja de escribir tiene font-size 16px. Por debajo de 16, Safari de iOS
12
+ * hace zoom solo al enfocar el campo y descoloca la pagina entera. Es la
13
+ * razon de ese numero y no otro.
14
+ * - `env(safe-area-inset-bottom)`: sin eso, en un iPhone la barra de escribir
15
+ * queda debajo de la raya del gesto de inicio.
16
+ * - `dvh` y no `vh`: con `vh`, al abrirse el teclado la pagina sigue midiendo
17
+ * la pantalla entera y el campo se va hacia abajo, fuera de la vista.
18
+ * - Se reconecta sola, con espera creciente. Un movil pierde la red a cada rato,
19
+ * y una sesion que hay que recargar a mano no se usa dos veces.
20
+ *
21
+ * EL COLOR SALE DEL ANSI DE VERDAD
22
+ *
23
+ * La transcripcion llega tal cual, con los codigos de chalk dentro. Se traducen
24
+ * aqui a `<span>` en vez de quitarlos: el diff en verde y rojo ES el producto, y
25
+ * un diff en gris no vende nada.
26
+ */
27
+ export function paginaHtml() {
28
+ return PAGINA;
29
+ }
30
+ const PAGINA = [
31
+ '<!doctype html>',
32
+ '<html lang="es">',
33
+ '<head>',
34
+ '<meta charset="utf-8">',
35
+ '<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">',
36
+ '<meta name="color-scheme" content="dark">',
37
+ '<meta name="robots" content="noindex, nofollow">',
38
+ '<title>Chocolatito Code</title>',
39
+ '<style>',
40
+ ':root { --fondo:#0b0b0d; --caja:#15151a; --borde:#2a2a33; --texto:#e6e6ea; --suave:#8b8b96; --zorro:#e8833a; }',
41
+ '* { box-sizing: border-box; }',
42
+ 'html, body { margin:0; height:100%; background:var(--fondo); color:var(--texto); }',
43
+ 'body { display:flex; flex-direction:column; height:100dvh; -webkit-text-size-adjust:100%;',
44
+ ' font:14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }',
45
+ 'header { display:flex; align-items:center; gap:8px; background:var(--caja);',
46
+ ' padding:calc(8px + env(safe-area-inset-top)) 12px 8px; border-bottom:1px solid var(--borde); }',
47
+ 'header b { color:var(--zorro); font-weight:600; }',
48
+ '#estado { margin-left:auto; font-size:12px; color:var(--suave); display:flex; align-items:center; gap:6px; }',
49
+ '#punto { width:8px; height:8px; border-radius:50%; background:#d14; transition:background .2s; }',
50
+ '#punto.ok { background:#2ea043; }',
51
+ '#salida { flex:1; overflow-y:auto; overflow-x:auto; padding:12px; white-space:pre; -webkit-overflow-scrolling:touch; }',
52
+ '#salida .fila { min-height:1.5em; }',
53
+ 'form { display:flex; gap:8px; background:var(--caja); border-top:1px solid var(--borde);',
54
+ ' padding:8px 8px calc(8px + env(safe-area-inset-bottom)); }',
55
+ 'textarea { flex:1; resize:none; min-height:44px; max-height:40dvh; padding:10px; border-radius:10px;',
56
+ ' border:1px solid var(--borde); background:var(--fondo); color:var(--texto);',
57
+ ' font:16px/1.4 ui-monospace, Menlo, Consolas, monospace; }',
58
+ 'button { border:0; border-radius:10px; padding:0 16px; min-width:56px; min-height:44px;',
59
+ ' background:var(--zorro); color:#14110e; font-weight:700; font-size:15px; }',
60
+ 'button:disabled { opacity:.45; }',
61
+ 'button.secundario { background:var(--borde); color:var(--texto); font-weight:500; }',
62
+ '#velo { position:fixed; inset:0; background:rgba(0,0,0,.72); display:none; align-items:flex-end;',
63
+ ' justify-content:center; padding:12px; }',
64
+ '#velo.abierto { display:flex; }',
65
+ '#permiso { width:100%; max-width:560px; background:var(--caja); border:1px solid var(--zorro);',
66
+ ' border-radius:14px; padding:14px; padding-bottom:calc(14px + env(safe-area-inset-bottom));',
67
+ ' max-height:85dvh; overflow-y:auto; }',
68
+ '#permiso h2 { margin:0 0 4px; font-size:15px; color:var(--zorro); }',
69
+ '#permisoDetalle { margin:0 0 12px; color:var(--suave); font-size:13px; white-space:pre-wrap; word-break:break-word; }',
70
+ '#permiso .opciones { display:flex; flex-direction:column; gap:8px; }',
71
+ '#permiso .opciones button { width:100%; text-align:left; padding:12px; }',
72
+ '#permiso small { display:block; font-weight:400; opacity:.75; }',
73
+ '#comentario { width:100%; margin-top:10px; display:none; }',
74
+ '.n{font-weight:700}.d{opacity:.6}.i{font-style:italic}.u{text-decoration:underline}',
75
+ '.f30{color:#5c6370}.f31{color:#e06c75}.f32{color:#98c379}.f33{color:#e5c07b}',
76
+ '.f34{color:#61afef}.f35{color:#c678dd}.f36{color:#56b6c2}.f37{color:#dcdfe4}',
77
+ '.f90{color:#7f848e}.f91{color:#ff7b86}.f92{color:#b5e890}.f93{color:#f0d399}',
78
+ '.f94{color:#82c0ff}.f95{color:#e0a3f0}.f96{color:#78d7de}.f97{color:#fff}',
79
+ '.b41{background:#4a1f22}.b42{background:#1f3a24}.b43{background:#463a1c}',
80
+ '.b44{background:#1f3048}.b45{background:#3a2440}.b46{background:#1d3a3c}.b47{background:#33333a}',
81
+ '</style>',
82
+ '</head>',
83
+ '<body>',
84
+ '<header><b>Chocolatito</b><span class="d" style="font-size:12px">Code</span>',
85
+ '<span id="estado"><span id="punto"></span><span id="estadoTexto">conectando</span></span></header>',
86
+ '<div id="salida"></div>',
87
+ '<form id="envio">',
88
+ '<textarea id="texto" rows="1" placeholder="Escribele lo que quieres que haga" autocapitalize="sentences" autocomplete="off"></textarea>',
89
+ '<button id="enviar" type="submit">Ir</button>',
90
+ '</form>',
91
+ '<div id="velo"><div id="permiso">',
92
+ '<h2 id="permisoTitulo">Pide permiso</h2>',
93
+ '<p id="permisoDetalle"></p>',
94
+ '<div class="opciones" id="permisoOpciones"></div>',
95
+ '<textarea id="comentario" rows="2" placeholder="Dile que prefieres que haga"></textarea>',
96
+ '</div></div>',
97
+ '<script>',
98
+ '(function () {',
99
+ ' var token = new URLSearchParams(location.search).get("t") || "";',
100
+ ' // Se quita de la barra de direcciones en cuanto se lee: el token no tiene',
101
+ ' // por que quedarse en el historial del navegador ni salir en una captura.',
102
+ ' if (token && history.replaceState) history.replaceState(null, "", location.pathname);',
103
+ ' var salida = document.getElementById("salida");',
104
+ ' var punto = document.getElementById("punto");',
105
+ ' var estadoTexto = document.getElementById("estadoTexto");',
106
+ ' var texto = document.getElementById("texto");',
107
+ ' var enviar = document.getElementById("enviar");',
108
+ ' var velo = document.getElementById("velo");',
109
+ ' var ws = null, ultimoId = 0, reintento = 800, peticionActual = null, terminada = false;',
110
+ ' var sesion = "";',
111
+ ' function alFinal() { return salida.scrollHeight - salida.scrollTop - salida.clientHeight < 60; }',
112
+ ' function escapar(s) { return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); }',
113
+ ' function quitar(l, fuera) { return l.filter(function (c) { return fuera.indexOf(c) < 0; }); }',
114
+ ' function sinPrefijo(l, p) { return l.filter(function (c) { return c.charAt(0) !== p; }); }',
115
+ ' // ANSI -> HTML. Solo SGR (las que ponen color): el resto de secuencias son',
116
+ ' // movimientos de cursor del terminal, que aqui no significan nada y se tiran.',
117
+ ' function ansi(linea) {',
118
+ ' var clases = [], html = "";',
119
+ ' var partes = linea.split(/\\x1b\\[([0-9;]*)m/);',
120
+ ' for (var i = 0; i < partes.length; i++) {',
121
+ ' if (i % 2 === 0) {',
122
+ ' var limpio = partes[i].replace(/\\x1b\\[[0-9;?]*[A-Za-z]/g, "").replace(/\\x1b\\][^\\x07]*\\x07/g, "");',
123
+ ' if (!limpio) continue;',
124
+ ' if (clases.length) html += "<span class=\\"" + clases.join(" ") + "\\">" + escapar(limpio) + "</span>";',
125
+ ' else html += escapar(limpio);',
126
+ ' } else {',
127
+ ' var codigos = (partes[i] || "0").split(";");',
128
+ ' for (var c = 0; c < codigos.length; c++) {',
129
+ ' var n = parseInt(codigos[c] || "0", 10);',
130
+ ' if (n === 0) clases = [];',
131
+ ' else if (n === 1) clases.push("n");',
132
+ ' else if (n === 2) clases.push("d");',
133
+ ' else if (n === 3) clases.push("i");',
134
+ ' else if (n === 4) clases.push("u");',
135
+ ' else if (n === 22) clases = quitar(clases, ["n", "d"]);',
136
+ ' else if (n === 23) clases = quitar(clases, ["i"]);',
137
+ ' else if (n === 24) clases = quitar(clases, ["u"]);',
138
+ ' else if (n === 39) clases = sinPrefijo(clases, "f");',
139
+ ' else if (n === 49) clases = sinPrefijo(clases, "b");',
140
+ ' else if ((n >= 30 && n <= 37) || (n >= 90 && n <= 97)) { clases = sinPrefijo(clases, "f"); clases.push("f" + n); }',
141
+ ' else if ((n >= 40 && n <= 47) || (n >= 100 && n <= 107)) { clases = sinPrefijo(clases, "b"); clases.push("b" + (n >= 100 ? n - 60 : n)); }',
142
+ ' }',
143
+ ' }',
144
+ ' }',
145
+ ' return html || "&nbsp;";',
146
+ ' }',
147
+ ' function pintar(lineas) {',
148
+ ' if (!lineas || !lineas.length) return;',
149
+ ' var pegado = alFinal();',
150
+ ' var frag = document.createDocumentFragment();',
151
+ ' for (var i = 0; i < lineas.length; i++) {',
152
+ ' var div = document.createElement("div");',
153
+ ' div.className = "fila";',
154
+ ' div.innerHTML = ansi(lineas[i].texto);',
155
+ ' frag.appendChild(div);',
156
+ ' if (lineas[i].id > ultimoId) ultimoId = lineas[i].id;',
157
+ ' }',
158
+ ' salida.appendChild(frag);',
159
+ ' while (salida.childNodes.length > 3000) salida.removeChild(salida.firstChild);',
160
+ ' if (pegado) salida.scrollTop = salida.scrollHeight;',
161
+ ' }',
162
+ ' function conectado(si, etiqueta) {',
163
+ ' punto.className = si ? "ok" : "";',
164
+ ' estadoTexto.textContent = etiqueta;',
165
+ ' enviar.disabled = !si;',
166
+ ' }',
167
+ ' function responder(indice, comentario) {',
168
+ ' if (!peticionActual || !ws || ws.readyState !== 1) return;',
169
+ ' ws.send(JSON.stringify({ t: "permiso", id: peticionActual.id, opcion: indice, comentario: comentario || "" }));',
170
+ ' velo.className = "";',
171
+ ' peticionActual = null;',
172
+ ' }',
173
+ ' function abrirPermiso(p) {',
174
+ ' peticionActual = p;',
175
+ ' document.getElementById("permisoTitulo").textContent = "Permiso: " + p.herramienta;',
176
+ ' document.getElementById("permisoDetalle").textContent = (p.detalle || []).join("\\n");',
177
+ ' var caja = document.getElementById("permisoOpciones");',
178
+ ' caja.innerHTML = "";',
179
+ ' var comentario = document.getElementById("comentario");',
180
+ ' comentario.style.display = "none";',
181
+ ' comentario.value = "";',
182
+ ' var ops = p.opciones || [];',
183
+ ' for (var i = 0; i < ops.length; i++) {',
184
+ ' (function (indice, op) {',
185
+ ' var b = document.createElement("button");',
186
+ ' b.type = "button";',
187
+ ' b.className = indice === 0 ? "" : "secundario";',
188
+ ' b.innerHTML = escapar(op.label) + "<small>" + escapar(op.hint) + "</small>";',
189
+ ' b.onclick = function () {',
190
+ ' // La ultima opcion es "prefiero decirte otra cosa": el primer toque',
191
+ ' // abre el campo y el segundo envia lo escrito.',
192
+ ' if (indice >= 2 && comentario.style.display === "none") {',
193
+ ' comentario.style.display = "block";',
194
+ ' comentario.focus();',
195
+ ' return;',
196
+ ' }',
197
+ ' responder(indice, comentario.value);',
198
+ ' };',
199
+ ' caja.appendChild(b);',
200
+ ' })(i, ops[i]);',
201
+ ' }',
202
+ ' velo.className = "abierto";',
203
+ ' }',
204
+ ' function conectar() {',
205
+ ' if (terminada) return;',
206
+ ' var proto = location.protocol === "https:" ? "wss://" : "ws://";',
207
+ ' var mas = "&desde=" + ultimoId + (sesion ? "&sesion=" + encodeURIComponent(sesion) : "");',
208
+ ' ws = new WebSocket(proto + location.host + "/ws?t=" + encodeURIComponent(token) + mas);',
209
+ ' ws.onopen = function () { reintento = 800; conectado(true, "conectado"); };',
210
+ ' ws.onmessage = function (ev) {',
211
+ ' var m;',
212
+ ' try { m = JSON.parse(ev.data); } catch (e) { return; }',
213
+ ' if (m.t === "lineas") {',
214
+ ' // Sesión distinta: lo que hay en pantalla es de una sesión que ya no',
215
+ ' // existe. Se tira, o quedarían pegadas dos conversaciones distintas.',
216
+ ' if (m.sesion && m.sesion !== sesion) {',
217
+ ' sesion = m.sesion;',
218
+ ' if (m.nueva) { salida.innerHTML = ""; ultimoId = 0; }',
219
+ ' }',
220
+ ' pintar(m.lineas);',
221
+ ' }',
222
+ ' else if (m.t === "permiso") abrirPermiso(m);',
223
+ ' else if (m.t === "cierre") { velo.className = ""; peticionActual = null; }',
224
+ ' else if (m.t === "fin") { terminada = true; conectado(false, "sesion terminada"); try { ws.close(); } catch (e) {} }',
225
+ ' };',
226
+ ' ws.onclose = function () {',
227
+ ' if (terminada) return;',
228
+ ' conectado(false, "reconectando");',
229
+ ' // Con tope: un movil en el bolsillo puede pasar horas sin red, y no tiene',
230
+ ' // sentido intentarlo cien veces por minuto.',
231
+ ' setTimeout(conectar, reintento);',
232
+ ' reintento = Math.min(reintento * 1.7, 15000);',
233
+ ' };',
234
+ ' ws.onerror = function () { try { ws.close(); } catch (e) {} };',
235
+ ' }',
236
+ ' document.getElementById("envio").onsubmit = function (ev) {',
237
+ ' ev.preventDefault();',
238
+ ' var valor = texto.value.trim();',
239
+ ' if (!valor || !ws || ws.readyState !== 1) return;',
240
+ ' ws.send(JSON.stringify({ t: "entrada", texto: valor }));',
241
+ ' texto.value = "";',
242
+ ' texto.style.height = "auto";',
243
+ ' salida.scrollTop = salida.scrollHeight;',
244
+ ' };',
245
+ ' // ENTER ENVIA, PERO SOLO CON TECLADO DE VERDAD.',
246
+ ' //',
247
+ ' // Se vio probando en un navegador: dentro de un <textarea> el Enter mete un',
248
+ ' // salto de linea y el formulario no se entera, asi que escribias la orden, le',
249
+ ' // dabas a Enter y no pasaba nada. En pantalla tactil se deja el salto de linea',
250
+ ' // y se envia con el boton: en un movil, un Enter que manda hace imposible',
251
+ ' // escribir dos renglones, y ahi el boton esta a un dedo de distancia.',
252
+ ' var tactil = window.matchMedia && window.matchMedia("(pointer: coarse)").matches;',
253
+ ' texto.addEventListener("keydown", function (ev) {',
254
+ ' if (ev.key !== "Enter" || ev.shiftKey || tactil) return;',
255
+ ' ev.preventDefault();',
256
+ ' document.getElementById("envio").requestSubmit();',
257
+ ' });',
258
+ ' texto.addEventListener("input", function () {',
259
+ ' texto.style.height = "auto";',
260
+ ' texto.style.height = Math.min(texto.scrollHeight, window.innerHeight * 0.4) + "px";',
261
+ ' });',
262
+ ' conectado(false, "conectando");',
263
+ ' conectar();',
264
+ '})();',
265
+ '</script>',
266
+ '</body>',
267
+ '</html>',
268
+ ].join("\n");
@@ -0,0 +1,34 @@
1
+ import type { PermissionPromptResult } from "../ui/permissionPrompt.js";
2
+ export declare function servirActivo(): boolean;
3
+ export declare function marcarServirActivo(valor: boolean): void;
4
+ /** Llega algo del navegador. */
5
+ export declare function entregarEntrada(texto: string): void;
6
+ export declare function esperarEntradaWeb(): Promise<string>;
7
+ export declare function hayEntradaEnCola(): boolean;
8
+ export interface PeticionDePermiso {
9
+ id: number;
10
+ herramienta: string;
11
+ /** Lineas de detalle ya listas para mostrar. Sin ANSI. */
12
+ detalle: string[];
13
+ opciones: Array<{
14
+ label: string;
15
+ hint: string;
16
+ }>;
17
+ }
18
+ type Emisor = (peticion: PeticionDePermiso) => boolean;
19
+ /**
20
+ * Lo instala el servidor. Devuelve false si no hay ningun cliente al que
21
+ * preguntar, y entonces el permiso se pide por el terminal como siempre.
22
+ */
23
+ export declare function fijarEmisorDePermiso(fn: Emisor | null): void;
24
+ export declare function pedirPermisoWeb(herramienta: string, detalle: string[], opciones: Array<{
25
+ label: string;
26
+ hint: string;
27
+ }>): Promise<PermissionPromptResult> | null;
28
+ export declare function responderPermiso(id: number, opcion: number, comentario?: string): void;
29
+ /** Un cliente se fue con preguntas abiertas: se cierran en negativa. */
30
+ export declare function cancelarPermisosAbiertos(motivo: string): void;
31
+ export declare function permisosAbiertos(): number;
32
+ /** Solo para pruebas. */
33
+ export declare function vaciarPuente(): void;
34
+ export {};
@@ -0,0 +1,115 @@
1
+ /**
2
+ * EL PUENTE ENTRE LA SESION Y EL NAVEGADOR
3
+ *
4
+ * La sesion es la de siempre: el mismo AgentLoop, el mismo bucle de index.ts, el
5
+ * mismo terminal escribiendo. Lo unico que cambia es de donde viene lo que se
6
+ * teclea y por donde se contesta un permiso.
7
+ *
8
+ * POR QUE LA ENTRADA NO SE COMPARTE CON EL TECLADO
9
+ *
10
+ * La tentacion es correr los dos a la vez y quedarse con el primero que
11
+ * conteste. No se hace, y no es por pereza: `askInteractivePrompt` se queda con
12
+ * el teclado y el terminal prestado (ui/ink/prestamo.ts) mientras espera, y no
13
+ * tiene forma de cancelarse a medias. Dos entradas peleando por el mismo TTY es
14
+ * exactamente el fallo que dejo la pantalla en negro dos veces.
15
+ *
16
+ * Asi que con `--servir` manda el navegador y el terminal es el monitor. Se dice
17
+ * en pantalla al arrancar, porque un prompt que no responde al teclado sin
18
+ * explicacion parece un cuelgue.
19
+ *
20
+ * Y LOS PERMISOS NO SE APRUEBAN SOLOS. NUNCA.
21
+ *
22
+ * Es lo mas peligroso de todo esto. Si no hay nadie conectado y el agente pide
23
+ * borrar algo, la salida facil es aprobarlo "porque no hay a quien preguntar":
24
+ * eso es un `rm -rf` sin supervision en la maquina de un cliente. Aqui, sin
25
+ * cliente conectado, la pregunta vuelve al dialogo del terminal, que es donde
26
+ * hay un humano. --yes sigue siendo la unica forma de aprobar sin preguntar, y
27
+ * hay que escribirlo a mano.
28
+ */
29
+ let activo = false;
30
+ export function servirActivo() {
31
+ return activo;
32
+ }
33
+ export function marcarServirActivo(valor) {
34
+ activo = valor;
35
+ }
36
+ // ===================================================== entrada
37
+ const cola = [];
38
+ let esperando = null;
39
+ /** Llega algo del navegador. */
40
+ export function entregarEntrada(texto) {
41
+ if (esperando) {
42
+ const cb = esperando;
43
+ esperando = null;
44
+ cb(texto);
45
+ return;
46
+ }
47
+ // Escribio mientras el agente trabajaba: se guarda y se atiende al acabar, en
48
+ // vez de perderse. Es lo que ya hace el type-ahead del terminal.
49
+ cola.push(texto);
50
+ }
51
+ export function esperarEntradaWeb() {
52
+ const pendiente = cola.shift();
53
+ if (pendiente !== undefined)
54
+ return Promise.resolve(pendiente);
55
+ return new Promise((resolve) => {
56
+ esperando = resolve;
57
+ });
58
+ }
59
+ export function hayEntradaEnCola() {
60
+ return cola.length > 0;
61
+ }
62
+ let emisor = null;
63
+ let siguientePeticion = 1;
64
+ const abiertas = new Map();
65
+ /**
66
+ * Lo instala el servidor. Devuelve false si no hay ningun cliente al que
67
+ * preguntar, y entonces el permiso se pide por el terminal como siempre.
68
+ */
69
+ export function fijarEmisorDePermiso(fn) {
70
+ emisor = fn;
71
+ }
72
+ export function pedirPermisoWeb(herramienta, detalle, opciones) {
73
+ if (!emisor)
74
+ return null;
75
+ const id = siguientePeticion++;
76
+ const enviado = emisor({ id, herramienta, detalle, opciones });
77
+ if (!enviado)
78
+ return null;
79
+ return new Promise((resolve) => {
80
+ abiertas.set(id, resolve);
81
+ });
82
+ }
83
+ export function responderPermiso(id, opcion, comentario) {
84
+ const resolver = abiertas.get(id);
85
+ if (!resolver)
86
+ return;
87
+ abiertas.delete(id);
88
+ // El indice es el de PERMISSION_OPTIONS y el orden es parte del contrato con
89
+ // la pagina: 0 pasa una vez, 1 pasa y lo recuerda, cualquier otra cosa NO pasa.
90
+ // Un indice que no se reconoce se trata como negativa, no como aprobacion.
91
+ if (opcion === 0)
92
+ return resolver({ approved: true });
93
+ if (opcion === 1)
94
+ return resolver({ approved: true, rememberPattern: true });
95
+ resolver({ approved: false, userFeedback: comentario?.trim() || undefined });
96
+ }
97
+ /** Un cliente se fue con preguntas abiertas: se cierran en negativa. */
98
+ export function cancelarPermisosAbiertos(motivo) {
99
+ for (const [id, resolver] of abiertas) {
100
+ abiertas.delete(id);
101
+ resolver({ approved: false, userFeedback: motivo });
102
+ }
103
+ }
104
+ export function permisosAbiertos() {
105
+ return abiertas.size;
106
+ }
107
+ /** Solo para pruebas. */
108
+ export function vaciarPuente() {
109
+ cola.length = 0;
110
+ esperando = null;
111
+ emisor = null;
112
+ abiertas.clear();
113
+ siguientePeticion = 1;
114
+ activo = false;
115
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * EL SERVIDOR DE `--servir`
3
+ *
4
+ * Lo que hay detras de esta puerta es ejecucion de codigo en la maquina de
5
+ * alguien. No es "una web bonita para el movil": es la sesion entera, con sus
6
+ * permisos, accesible por red. Asi que aqui la parte interesante no son las
7
+ * funciones, son las cuatro comprobaciones, y ninguna sobra.
8
+ *
9
+ * 1. TOKEN EN TODO, INCLUIDO EL UPGRADE DEL WEBSOCKET. Es el fallo clasico:
10
+ * se protege la pagina y se deja el socket abierto, que es justo el que
11
+ * manda ordenes. Se compara con timingSafeEqual, no con ===.
12
+ *
13
+ * 2. LA CABECERA HOST TIENE QUE SER UNA IP O localhost. Esto para el ataque
14
+ * de DNS rebinding, que es la unica forma real de atacar un servidor que
15
+ * solo escucha en 127.0.0.1: una web cualquiera pone un dominio suyo a
16
+ * resolver a 127.0.0.1 y el navegador de la victima -que si puede llegar-
17
+ * hace las peticiones por ella. Un ataque asi llega SIEMPRE con un nombre
18
+ * de dominio en Host; exigiendo IP o localhost se cae solo.
19
+ *
20
+ * 3. ORIGIN, SI VIENE, TIENE QUE COINCIDIR CON HOST. Mismo patron que ya usa
21
+ * tools/browserExtension.ts:109 para la extension. Se permite que falte
22
+ * porque un cliente que no sea un navegador no la manda.
23
+ *
24
+ * 4. 127.0.0.1 POR DEFECTO. Salir a la red es --servir-red, escrito a mano,
25
+ * y avisando en pantalla. Lo contrario -abrir a la red por comodidad- es
26
+ * como se filtran las cosas: nadie lee lo que no pidio.
27
+ *
28
+ * Lo que esto NO hace, a proposito: no hay HTTPS ni tunel propio. Para salir de
29
+ * tu red se usa Tailscale o cloudflared, que ya existen, estan auditados y no
30
+ * hay que mantenerlos. Escribir un tunel propio seria la parte facil de una
31
+ * responsabilidad muy grande.
32
+ */
33
+ export interface OpcionesServidor {
34
+ puerto?: number;
35
+ /** true escucha en todas las interfaces. Solo desde --servir-red. */
36
+ enRed?: boolean;
37
+ /** Para pruebas: token fijo en vez de uno aleatorio. */
38
+ token?: string;
39
+ }
40
+ export interface ServidorWeb {
41
+ puerto: number;
42
+ token: string;
43
+ /** La direccion que hay que abrir en el movil, con el token puesto. */
44
+ url: string;
45
+ /** Las direcciones de red local, cuando se abrio con --servir-red. */
46
+ urlsDeRed: string[];
47
+ clientes: () => number;
48
+ /** Avisa al navegador de que la sesion se acabo, y cierra. */
49
+ cerrar: () => Promise<void>;
50
+ }
51
+ /**
52
+ * Host valido: localhost o una IP literal. Ver el punto 2 de la cabecera.
53
+ * Se le quita el puerto y los corchetes de IPv6 antes de mirarlo.
54
+ */
55
+ export declare function anfitrionAceptable(host: string | undefined): boolean;
56
+ /** Origin, si viene, tiene que ser el mismo sitio. Ver el punto 3. */
57
+ export declare function origenAceptable(origin: string | undefined, host: string | undefined): boolean;
58
+ export declare function tokenDeLaPeticion(url: string | undefined): string;
59
+ export declare function iniciarServidorWeb(opciones?: OpcionesServidor): Promise<ServidorWeb>;
60
+ /** Las IP de la maquina en la red local, para abrirla desde el movil. */
61
+ export declare function direccionesDeRed(puerto: number, token: string): string[];