chocolatito-code 1.6.14 → 1.6.15

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.
@@ -36,6 +36,11 @@ export interface OpcionesServidor {
36
36
  enRed?: boolean;
37
37
  /** Para pruebas: token fijo en vez de uno aleatorio. */
38
38
  token?: string;
39
+ /**
40
+ * Un nombre de dominio mas que se acepta como Host, para entrar por un tunel
41
+ * (cloudflared, ngrok). Uno concreto, no cualquiera.
42
+ */
43
+ anfitrionAutorizado?: string;
39
44
  /**
40
45
  * La carpeta de trabajo, leida en cada peticion y no copiada al arrancar.
41
46
  * Con /cd el agente se cambia de carpeta a mitad de sesion, y lo que se sube
@@ -58,9 +63,72 @@ export interface ServidorWeb {
58
63
  * Host valido: localhost o una IP literal. Ver el punto 2 de la cabecera.
59
64
  * Se le quita el puerto y los corchetes de IPv6 antes de mirarlo.
60
65
  */
61
- export declare function anfitrionAceptable(host: string | undefined): boolean;
66
+ /**
67
+ * Host valido: localhost, una IP literal, o EL NOMBRE QUE SE HAYA AUTORIZADO.
68
+ *
69
+ * Lo tercero existe por un caso real: el README dice desde el primer dia que
70
+ * para entrar desde fuera de casa se use Tailscale o cloudflared... y con
71
+ * cloudflared esto devolvia 401. Tailscale funciona porque reparte IPs, que
72
+ * pasan la comprobacion; un tunel llega siempre con un nombre de dominio.
73
+ * O sea que el programa prometia algo que el mismo rechazaba.
74
+ *
75
+ * Se arregla SIN quitar la cerradura. No es "acepta cualquier dominio": es
76
+ * "acepta exactamente este, el que me acabas de decir". Contra el DNS rebinding
77
+ * -una web cualquiera que apunta su dominio a tu 127.0.0.1 y usa el navegador de
78
+ * la victima de puente- la defensa sigue entera, porque ese ataque llega con el
79
+ * dominio del atacante y no con el que tu autorizaste.
80
+ */
81
+ export declare function anfitrionAceptable(host: string | undefined, autorizado?: string): boolean;
82
+ /**
83
+ * De lo que pegue el usuario, saca el nombre.
84
+ *
85
+ * Nadie copia "abc-def.trycloudflare.com": se copia el enlace entero con su
86
+ * https:// delante, y a veces con la barra final. Exigir el nombre pelado seria
87
+ * un 401 que nadie sabria explicar.
88
+ */
89
+ export declare function nombreDeAnfitrion(entrada: string | undefined): string;
62
90
  /** Origin, si viene, tiene que ser el mismo sitio. Ver el punto 3. */
63
- export declare function origenAceptable(origin: string | undefined, host: string | undefined): boolean;
91
+ /**
92
+ * Origin, si viene, tiene que ser el mismo sitio. Ver el punto 3.
93
+ *
94
+ * CON UN TUNEL DELANTE, "EL MISMO SITIO" NO ES EL MISMO TEXTO
95
+ *
96
+ * Se vio en la primera prueba de verdad desde un movil con datos: la pagina
97
+ * cargaba y el WebSocket rebotaba con 401. El motivo es que cloudflared
98
+ * REESCRIBE la cabecera Host con la del destino -127.0.0.1:4700-, mientras que
99
+ * el navegador manda el Origin que el ve, que es el del tunel. Comparados a
100
+ * pelo no coinciden nunca, asi que la sesion se quedaba muda: el usuario
101
+ * escribia y no pasaba nada.
102
+ *
103
+ * Por eso el dominio autorizado vale tambien como Origin. Y sigue siendo una
104
+ * cerradura: se acepta EXACTAMENTE ese, el que se escribio en --tunel.
105
+ */
106
+ export declare function origenAceptable(origin: string | undefined, host: string | undefined, autorizado?: string): boolean;
107
+ /**
108
+ * EL TOKEN TIENE QUE SOBREVIVIR A UNA RECARGA, Y LA URL NO LO HACE
109
+ *
110
+ * DE DONDE SALE: primera prueba con un movil de verdad. El enlace se abre bien,
111
+ * la pagina quita el token de la barra de direcciones -para que no se quede en
112
+ * el historial ni salga en una captura- y a la primera recarga se acabo la
113
+ * sesion: la peticion de la propia pagina ya no lleva token, el servidor
114
+ * contesta 401 "No." y ahi no hay javascript que valga, porque la pagina ni
115
+ * llega a cargarse. En el terminal salieron trece rechazos seguidos.
116
+ *
117
+ * Y en un movil recargar no es raro: es tirar hacia abajo sin querer, y sobre
118
+ * todo es lo que hace Android solo cuando la pestana se queda de fondo. O sea,
119
+ * exactamente lo que hay que hacer para probar las notificaciones.
120
+ *
121
+ * Se arregla como se arregla en cualquier sitio: al entrar con el token bueno se
122
+ * deja una cookie, y a partir de ahi el navegador la manda sola en todo -la
123
+ * pagina, el socket, las imagenes-.
124
+ *
125
+ * - SameSite=Strict: una web ajena no puede hacer que tu navegador la mande.
126
+ * Es la misma defensa que el Origin, por otro camino.
127
+ * - HttpOnly: no la lee ningun javascript, ni el nuestro.
128
+ * - Sin Max-Age: se muere al cerrar el navegador, como la sesion.
129
+ */
130
+ export declare const COOKIE_DE_SESION = "chocolatito_web";
131
+ export declare function tokenDeLaCookie(cabecera: string | undefined): string;
64
132
  export declare function tokenDeLaPeticion(url: string | undefined): string;
65
133
  export declare function iniciarServidorWeb(opciones?: OpcionesServidor): Promise<ServidorWeb>;
66
134
  /** Las IP de la maquina en la red local, para abrirla desde el movil. */
@@ -7,7 +7,7 @@ import chalk from "chalk";
7
7
  import { WebSocketServer, WebSocket } from "ws";
8
8
  import { paginaHtml } from "./pagina.js";
9
9
  import { LIMITE_DE_SUBIDA, avisoDeSubida, guardarSubida, rutaServible, tipoDelArchivo, } from "./archivos.js";
10
- import { alEscribirWeb, anotarWeb, historialWeb } from "./captura.js";
10
+ import { alEscribirWeb, anotarWeb, cerrarLineaWeb, historialWeb } from "./captura.js";
11
11
  import { cancelarPermisosAbiertos, entregarEntrada, fijarAvisoDeTurno, fijarEmisorDePermiso, responderPermiso, } from "./puente.js";
12
12
  const PUERTO_POR_DEFECTO = 4700;
13
13
  /**
@@ -39,7 +39,22 @@ function mismoToken(a, b) {
39
39
  * Host valido: localhost o una IP literal. Ver el punto 2 de la cabecera.
40
40
  * Se le quita el puerto y los corchetes de IPv6 antes de mirarlo.
41
41
  */
42
- export function anfitrionAceptable(host) {
42
+ /**
43
+ * Host valido: localhost, una IP literal, o EL NOMBRE QUE SE HAYA AUTORIZADO.
44
+ *
45
+ * Lo tercero existe por un caso real: el README dice desde el primer dia que
46
+ * para entrar desde fuera de casa se use Tailscale o cloudflared... y con
47
+ * cloudflared esto devolvia 401. Tailscale funciona porque reparte IPs, que
48
+ * pasan la comprobacion; un tunel llega siempre con un nombre de dominio.
49
+ * O sea que el programa prometia algo que el mismo rechazaba.
50
+ *
51
+ * Se arregla SIN quitar la cerradura. No es "acepta cualquier dominio": es
52
+ * "acepta exactamente este, el que me acabas de decir". Contra el DNS rebinding
53
+ * -una web cualquiera que apunta su dominio a tu 127.0.0.1 y usa el navegador de
54
+ * la victima de puente- la defensa sigue entera, porque ese ataque llega con el
55
+ * dominio del atacante y no con el que tu autorizaste.
56
+ */
57
+ export function anfitrionAceptable(host, autorizado) {
43
58
  if (!host)
44
59
  return false;
45
60
  let nombre = host.trim();
@@ -56,19 +71,99 @@ export function anfitrionAceptable(host) {
56
71
  }
57
72
  if (nombre === "localhost")
58
73
  return true;
74
+ if (autorizado && nombre.toLowerCase() === autorizado.toLowerCase())
75
+ return true;
59
76
  return /^[0-9.]+$/.test(nombre) || /^[0-9a-fA-F:]+$/.test(nombre);
60
77
  }
78
+ /**
79
+ * De lo que pegue el usuario, saca el nombre.
80
+ *
81
+ * Nadie copia "abc-def.trycloudflare.com": se copia el enlace entero con su
82
+ * https:// delante, y a veces con la barra final. Exigir el nombre pelado seria
83
+ * un 401 que nadie sabria explicar.
84
+ */
85
+ export function nombreDeAnfitrion(entrada) {
86
+ if (!entrada)
87
+ return "";
88
+ const limpio = entrada.trim();
89
+ try {
90
+ if (/^[a-z]+:\/\//i.test(limpio))
91
+ return new URL(limpio).hostname;
92
+ }
93
+ catch { }
94
+ return limpio.replace(/^\/+|\/+$/g, "").split("/")[0].split(":")[0];
95
+ }
61
96
  /** Origin, si viene, tiene que ser el mismo sitio. Ver el punto 3. */
62
- export function origenAceptable(origin, host) {
97
+ /**
98
+ * Origin, si viene, tiene que ser el mismo sitio. Ver el punto 3.
99
+ *
100
+ * CON UN TUNEL DELANTE, "EL MISMO SITIO" NO ES EL MISMO TEXTO
101
+ *
102
+ * Se vio en la primera prueba de verdad desde un movil con datos: la pagina
103
+ * cargaba y el WebSocket rebotaba con 401. El motivo es que cloudflared
104
+ * REESCRIBE la cabecera Host con la del destino -127.0.0.1:4700-, mientras que
105
+ * el navegador manda el Origin que el ve, que es el del tunel. Comparados a
106
+ * pelo no coinciden nunca, asi que la sesion se quedaba muda: el usuario
107
+ * escribia y no pasaba nada.
108
+ *
109
+ * Por eso el dominio autorizado vale tambien como Origin. Y sigue siendo una
110
+ * cerradura: se acepta EXACTAMENTE ese, el que se escribio en --tunel.
111
+ */
112
+ export function origenAceptable(origin, host, autorizado) {
63
113
  if (!origin)
64
114
  return true;
65
115
  try {
66
- return new URL(origin).host === host;
116
+ const u = new URL(origin);
117
+ if (u.host === host)
118
+ return true;
119
+ return !!autorizado && u.hostname.toLowerCase() === autorizado.toLowerCase();
67
120
  }
68
121
  catch {
69
122
  return false;
70
123
  }
71
124
  }
125
+ /**
126
+ * EL TOKEN TIENE QUE SOBREVIVIR A UNA RECARGA, Y LA URL NO LO HACE
127
+ *
128
+ * DE DONDE SALE: primera prueba con un movil de verdad. El enlace se abre bien,
129
+ * la pagina quita el token de la barra de direcciones -para que no se quede en
130
+ * el historial ni salga en una captura- y a la primera recarga se acabo la
131
+ * sesion: la peticion de la propia pagina ya no lleva token, el servidor
132
+ * contesta 401 "No." y ahi no hay javascript que valga, porque la pagina ni
133
+ * llega a cargarse. En el terminal salieron trece rechazos seguidos.
134
+ *
135
+ * Y en un movil recargar no es raro: es tirar hacia abajo sin querer, y sobre
136
+ * todo es lo que hace Android solo cuando la pestana se queda de fondo. O sea,
137
+ * exactamente lo que hay que hacer para probar las notificaciones.
138
+ *
139
+ * Se arregla como se arregla en cualquier sitio: al entrar con el token bueno se
140
+ * deja una cookie, y a partir de ahi el navegador la manda sola en todo -la
141
+ * pagina, el socket, las imagenes-.
142
+ *
143
+ * - SameSite=Strict: una web ajena no puede hacer que tu navegador la mande.
144
+ * Es la misma defensa que el Origin, por otro camino.
145
+ * - HttpOnly: no la lee ningun javascript, ni el nuestro.
146
+ * - Sin Max-Age: se muere al cerrar el navegador, como la sesion.
147
+ */
148
+ export const COOKIE_DE_SESION = "chocolatito_web";
149
+ export function tokenDeLaCookie(cabecera) {
150
+ if (!cabecera)
151
+ return "";
152
+ for (const trozo of cabecera.split(";")) {
153
+ const igual = trozo.indexOf("=");
154
+ if (igual < 0)
155
+ continue;
156
+ if (trozo.slice(0, igual).trim() !== COOKIE_DE_SESION)
157
+ continue;
158
+ try {
159
+ return decodeURIComponent(trozo.slice(igual + 1).trim());
160
+ }
161
+ catch {
162
+ return "";
163
+ }
164
+ }
165
+ return "";
166
+ }
72
167
  export function tokenDeLaPeticion(url) {
73
168
  if (!url)
74
169
  return "";
@@ -89,9 +184,16 @@ export async function iniciarServidorWeb(opciones = {}) {
89
184
  const sesion = sesionNueva();
90
185
  const clientes = new Set();
91
186
  const wss = new WebSocketServer({ noServer: true });
92
- const autorizada = (req) => anfitrionAceptable(req.headers.host) &&
93
- origenAceptable(req.headers.origin, req.headers.host) &&
94
- mismoToken(tokenDeLaPeticion(req.url), token);
187
+ const tunel = nombreDeAnfitrion(opciones.anfitrionAutorizado);
188
+ const autorizada = (req) => {
189
+ if (!anfitrionAceptable(req.headers.host, tunel))
190
+ return false;
191
+ if (!origenAceptable(req.headers.origin, req.headers.host, tunel))
192
+ return false;
193
+ // El de la URL al entrar; el de la cookie en todo lo demas.
194
+ return (mismoToken(tokenDeLaPeticion(req.url), token) ||
195
+ mismoToken(tokenDeLaCookie(req.headers.cookie), token));
196
+ };
95
197
  const carpetaDeTrabajo = opciones.carpeta || (() => process.cwd());
96
198
  const servidor = http.createServer((req, res) => {
97
199
  const ruta = (req.url || "/").split("?")[0];
@@ -134,6 +236,8 @@ export async function iniciarServidorWeb(opciones = {}) {
134
236
  res.writeHead(200, {
135
237
  "content-type": "text/html; charset=utf-8",
136
238
  "cache-control": "no-store",
239
+ // Path=/ para que valga tambien en /ws, /archivo y /subir.
240
+ "set-cookie": `${COOKIE_DE_SESION}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Strict`,
137
241
  // La pagina no tiene que salir de aqui ni meterse en ningun marco ajeno.
138
242
  "x-frame-options": "DENY",
139
243
  "referrer-policy": "no-referrer",
@@ -142,7 +246,7 @@ export async function iniciarServidorWeb(opciones = {}) {
142
246
  // esta misma casa. blob: para la vista previa de lo que aun no se ha
143
247
  // subido. Nada de terceros: la pagina sigue sin pedirle nada a nadie.
144
248
  "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; " +
145
- "img-src 'self' blob: data:; media-src 'self' blob:; connect-src 'self' ws: wss:",
249
+ "img-src 'self' blob: data:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' ws: wss:",
146
250
  });
147
251
  res.end(paginaHtml());
148
252
  });
@@ -325,6 +429,11 @@ ${m.texto}` : aviso;
325
429
  fijarEmisorDePermiso((peticion) => aTodos({ t: "permiso", ...peticion }) > 0);
326
430
  // El agente se puso a esperar: para el movil, eso es "ya esta".
327
431
  fijarAvisoDeTurno(() => {
432
+ // Primero se suelta lo que quede a medias. La ultima linea de una respuesta
433
+ // casi nunca trae salto de linea -el modelo acaba con un punto-, asi que sin
434
+ // esto el final de CADA respuesta se quedaba esperando un salto que no llega
435
+ // nunca: justo la frase que el usuario esta esperando leer.
436
+ cerrarLineaWeb();
328
437
  aTodos({ t: "turno" });
329
438
  });
330
439
  const puerto = await new Promise((resolve, reject) => {
@@ -26,6 +26,7 @@
26
26
  export const SLASH_COMMANDS = [
27
27
  { name: "/mcp", description: "Ver servidores MCP conectados y sus herramientas" },
28
28
  { name: "/undo", description: "Deshacer las últimas modificaciones de archivos de la sesión" },
29
+ { name: "/rewind", description: "Volver a como estaba antes de un turno (sobrevive a cerrar la sesión)", args: "[n]" },
29
30
  { name: "/export", description: "Exportar bitácora de la sesión a archivo Markdown", args: "[nombre.md]" },
30
31
  { name: "/plan", description: "Alternar Modo Plan (solo lectura e investigación previa)" },
31
32
  { name: "/resume", description: "Reanudar una sesión anterior guardada", args: "[id]" },
@@ -2,7 +2,7 @@
2
2
  "manifest_version": 3,
3
3
  "minimum_chrome_version": "102",
4
4
  "name": "Chocolatito Code",
5
- "version": "1.6.14",
5
+ "version": "1.6.15",
6
6
  "description": "Deja que Chocolatito Code trabaje dentro de tu Chrome, en segundo plano y con tus sesiones ya iniciadas.",
7
7
  "permissions": [
8
8
  "tabs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chocolatito-code",
3
- "version": "1.6.14",
3
+ "version": "1.6.15",
4
4
  "description": "Agente autónomo de programación para la terminal, con control real del ordenador y del navegador. Desarrollado por Chocolatito.",
5
5
  "type": "module",
6
6
  "bin": {