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.
- package/README.md +20 -1
- package/dist/agent/historialDeCambios.d.ts +77 -0
- package/dist/agent/historialDeCambios.js +220 -0
- package/dist/agent/salidaDelAgente.d.ts +9 -0
- package/dist/agent/salidaDelAgente.js +19 -0
- package/dist/agent/undoManager.d.ts +21 -0
- package/dist/agent/undoManager.js +85 -5
- package/dist/index.js +87 -3
- package/dist/servidor/captura.d.ts +9 -0
- package/dist/servidor/captura.js +48 -0
- package/dist/servidor/marca.d.ts +14 -0
- package/dist/servidor/marca.js +14 -0
- package/dist/servidor/pagina.d.ts +0 -26
- package/dist/servidor/pagina.js +71 -8
- package/dist/servidor/servidor.d.ts +70 -2
- package/dist/servidor/servidor.js +117 -8
- package/dist/ui/comandos.js +1 -0
- package/extension/manifest.json +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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) => {
|
package/dist/ui/comandos.js
CHANGED
|
@@ -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]" },
|
package/extension/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"minimum_chrome_version": "102",
|
|
4
4
|
"name": "Chocolatito Code",
|
|
5
|
-
"version": "1.6.
|
|
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