chocolatito-code 1.6.13 → 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.
Files changed (49) hide show
  1. package/COMPUTER-USE.md +37 -0
  2. package/README.md +29 -3
  3. package/dist/agent/historialDeCambios.d.ts +77 -0
  4. package/dist/agent/historialDeCambios.js +220 -0
  5. package/dist/agent/salidaDelAgente.d.ts +9 -0
  6. package/dist/agent/salidaDelAgente.js +19 -0
  7. package/dist/agent/undoManager.d.ts +21 -0
  8. package/dist/agent/undoManager.js +85 -5
  9. package/dist/config/permissions.js +4 -0
  10. package/dist/index.js +216 -27
  11. package/dist/prompts/systemPrompt.js +15 -11
  12. package/dist/servidor/archivos.d.ts +69 -0
  13. package/dist/servidor/archivos.js +156 -0
  14. package/dist/servidor/captura.d.ts +9 -0
  15. package/dist/servidor/captura.js +48 -0
  16. package/dist/servidor/fondo.d.ts +85 -0
  17. package/dist/servidor/fondo.js +134 -0
  18. package/dist/servidor/marca.d.ts +14 -0
  19. package/dist/servidor/marca.js +14 -0
  20. package/dist/servidor/pagina.d.ts +0 -26
  21. package/dist/servidor/pagina.js +337 -10
  22. package/dist/servidor/puente.d.ts +13 -0
  23. package/dist/servidor/puente.js +11 -0
  24. package/dist/servidor/servidor.d.ts +76 -2
  25. package/dist/servidor/servidor.js +245 -13
  26. package/dist/tools/computerUse.d.ts +11 -1
  27. package/dist/tools/computerUse.js +376 -34
  28. package/dist/tools/toolDefsComputer.js +39 -14
  29. package/dist/tools/visionBridge.d.ts +4 -2
  30. package/dist/tools/visionBridge.js +40 -15
  31. package/dist/tools/win/hostScript.js +300 -29
  32. package/dist/ui/comandos.js +1 -0
  33. package/dist/ui/ink/App.js +23 -3
  34. package/dist/ui/ink/Prompt.d.ts +5 -1
  35. package/dist/ui/ink/Prompt.js +92 -4
  36. package/dist/ui/ink/control.d.ts +2 -0
  37. package/dist/ui/ink/control.js +15 -0
  38. package/dist/ui/ink/montarApp.d.ts +0 -19
  39. package/dist/ui/ink/montarApp.js +3 -0
  40. package/dist/ui/permissionPrompt.js +44 -9
  41. package/dist/ui/pieFijo.js +5 -0
  42. package/dist/ui/prompt.js +6 -0
  43. package/dist/ui/renderer.d.ts +1 -0
  44. package/dist/ui/renderer.js +27 -2
  45. package/dist/ui/selector.js +5 -0
  46. package/dist/ui/spinner.d.ts +1 -0
  47. package/dist/ui/spinner.js +6 -0
  48. package/extension/manifest.json +1 -1
  49. package/package.json +1 -1
@@ -1,11 +1,14 @@
1
1
  import http from "node:http";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
2
4
  import os from "node:os";
3
5
  import crypto from "node:crypto";
4
6
  import chalk from "chalk";
5
7
  import { WebSocketServer, WebSocket } from "ws";
6
8
  import { paginaHtml } from "./pagina.js";
7
- import { alEscribirWeb, anotarWeb, historialWeb } from "./captura.js";
8
- import { cancelarPermisosAbiertos, entregarEntrada, fijarEmisorDePermiso, responderPermiso, } from "./puente.js";
9
+ import { LIMITE_DE_SUBIDA, avisoDeSubida, guardarSubida, rutaServible, tipoDelArchivo, } from "./archivos.js";
10
+ import { alEscribirWeb, anotarWeb, cerrarLineaWeb, historialWeb } from "./captura.js";
11
+ import { cancelarPermisosAbiertos, entregarEntrada, fijarAvisoDeTurno, fijarEmisorDePermiso, responderPermiso, } from "./puente.js";
9
12
  const PUERTO_POR_DEFECTO = 4700;
10
13
  /**
11
14
  * Identifica ESTA sesion, y existe por un caso concreto.
@@ -36,7 +39,22 @@ function mismoToken(a, b) {
36
39
  * Host valido: localhost o una IP literal. Ver el punto 2 de la cabecera.
37
40
  * Se le quita el puerto y los corchetes de IPv6 antes de mirarlo.
38
41
  */
39
- 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) {
40
58
  if (!host)
41
59
  return false;
42
60
  let nombre = host.trim();
@@ -53,19 +71,99 @@ export function anfitrionAceptable(host) {
53
71
  }
54
72
  if (nombre === "localhost")
55
73
  return true;
74
+ if (autorizado && nombre.toLowerCase() === autorizado.toLowerCase())
75
+ return true;
56
76
  return /^[0-9.]+$/.test(nombre) || /^[0-9a-fA-F:]+$/.test(nombre);
57
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
+ }
58
96
  /** Origin, si viene, tiene que ser el mismo sitio. Ver el punto 3. */
59
- 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) {
60
113
  if (!origin)
61
114
  return true;
62
115
  try {
63
- 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();
64
120
  }
65
121
  catch {
66
122
  return false;
67
123
  }
68
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
+ }
69
167
  export function tokenDeLaPeticion(url) {
70
168
  if (!url)
71
169
  return "";
@@ -86,19 +184,40 @@ export async function iniciarServidorWeb(opciones = {}) {
86
184
  const sesion = sesionNueva();
87
185
  const clientes = new Set();
88
186
  const wss = new WebSocketServer({ noServer: true });
89
- const autorizada = (req) => anfitrionAceptable(req.headers.host) &&
90
- origenAceptable(req.headers.origin, req.headers.host) &&
91
- 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
+ };
197
+ const carpetaDeTrabajo = opciones.carpeta || (() => process.cwd());
92
198
  const servidor = http.createServer((req, res) => {
93
199
  const ruta = (req.url || "/").split("?")[0];
94
- if (req.method !== "GET") {
95
- res.writeHead(405).end();
200
+ const rutasConocidas = ["/", "/subir", "/archivo"];
201
+ if (!rutasConocidas.includes(ruta)) {
202
+ res.writeHead(404).end();
96
203
  return;
97
204
  }
98
- if (ruta !== "/") {
99
- res.writeHead(404).end();
205
+ const metodoValido = ruta === "/subir"
206
+ ? req.method === "POST"
207
+ : // HEAD solo en /archivo: la pagina pregunta "existe esto?" antes de
208
+ // ofrecer el enlace de guardar, y pedir el archivo entero para
209
+ // averiguarlo seria mandar un video de 40 MB para no ensenarlo.
210
+ req.method === "GET" || (ruta === "/archivo" && req.method === "HEAD");
211
+ if (!metodoValido) {
212
+ res.writeHead(405).end();
100
213
  return;
101
214
  }
215
+ // LA PUERTA ES UNA SOLA PARA LAS TRES RUTAS.
216
+ //
217
+ // Antes habia una ruta y por eso la comprobacion vivia pegada a ella. Con
218
+ // tres, lo que no puede pasar es que cada una traiga su copia: la cuarta se
219
+ // escribe un martes con prisa y se olvida, y detras de esta puerta hay
220
+ // ejecucion de comandos en la maquina de alguien.
102
221
  if (!autorizada(req)) {
103
222
  // Se dice en el terminal: si a alguien le tocan la puerta, tiene derecho a
104
223
  // enterarse en el momento y no al revisar registros que nadie revisa.
@@ -106,16 +225,107 @@ export async function iniciarServidorWeb(opciones = {}) {
106
225
  res.writeHead(401, { "content-type": "text/plain; charset=utf-8" }).end("No.");
107
226
  return;
108
227
  }
228
+ if (ruta === "/subir") {
229
+ recibirSubida(req, res);
230
+ return;
231
+ }
232
+ if (ruta === "/archivo") {
233
+ servirArchivo(req, res);
234
+ return;
235
+ }
109
236
  res.writeHead(200, {
110
237
  "content-type": "text/html; charset=utf-8",
111
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`,
112
241
  // La pagina no tiene que salir de aqui ni meterse en ningun marco ajeno.
113
242
  "x-frame-options": "DENY",
114
243
  "referrer-policy": "no-referrer",
115
- "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self' ws: wss:",
244
+ "content-security-policy":
245
+ // img/media 'self': las miniaturas y los videos salen de /archivo, que es
246
+ // esta misma casa. blob: para la vista previa de lo que aun no se ha
247
+ // subido. Nada de terceros: la pagina sigue sin pedirle nada a nadie.
248
+ "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; " +
249
+ "img-src 'self' blob: data:; font-src 'self' data:; media-src 'self' blob:; connect-src 'self' ws: wss:",
116
250
  });
117
251
  res.end(paginaHtml());
118
252
  });
253
+ /** Un archivo que llega del navegador: cuerpo crudo, nombre en la consulta. */
254
+ function recibirSubida(req, res) {
255
+ const consulta = new URL(req.url || "/", "http://x").searchParams;
256
+ const nombre = consulta.get("nombre") || "archivo";
257
+ // Se corta por tamano MIENTRAS llega, no despues: esperar a tener 500 MB en
258
+ // memoria para entonces decir que no es justo lo que no hay que hacer.
259
+ const trozos = [];
260
+ let total = 0;
261
+ let cortado = false;
262
+ req.on("data", (trozo) => {
263
+ if (cortado)
264
+ return;
265
+ total += trozo.length;
266
+ if (total > LIMITE_DE_SUBIDA) {
267
+ cortado = true;
268
+ res.writeHead(413, { "content-type": "application/json" }).end(JSON.stringify({ ok: false, error: "El archivo es demasiado grande." }));
269
+ req.destroy();
270
+ return;
271
+ }
272
+ trozos.push(trozo);
273
+ });
274
+ req.on("end", () => {
275
+ if (cortado)
276
+ return;
277
+ const guardado = guardarSubida(carpetaDeTrabajo(), nombre, Buffer.concat(trozos));
278
+ if (!guardado.ok) {
279
+ res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify(guardado));
280
+ return;
281
+ }
282
+ anotarWeb(chalk.gray(` ⇩ Llego del navegador: ${guardado.ruta}`));
283
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(guardado));
284
+ });
285
+ req.on("error", () => {
286
+ if (!cortado)
287
+ res.writeHead(400).end();
288
+ });
289
+ }
290
+ /** Un archivo de la carpeta de trabajo, para verlo en el navegador. */
291
+ function servirArchivo(req, res) {
292
+ const consulta = new URL(req.url || "/", "http://x").searchParams;
293
+ const real = rutaServible(carpetaDeTrabajo(), consulta.get("ruta") || "");
294
+ // Fuera de la carpeta es un 404, no un 403: decir "existe pero no te lo doy"
295
+ // ya cuenta algo del disco de alguien que no ha preguntado.
296
+ if (!real) {
297
+ res.writeHead(404).end();
298
+ return;
299
+ }
300
+ // "descargar" cambia una palabra de la cabecera y con eso el navegador deja
301
+ // de ensenar el archivo y lo guarda. Es la ultima milla del movil: el agente
302
+ // exporta la pieza, la ves, y te la llevas para mandarsela al cliente sin
303
+ // pasar por el ordenador.
304
+ const guardar = consulta.get("descargar") === "1";
305
+ const nombre = path.basename(real).replace(/"/g, "");
306
+ try {
307
+ const tam = fs.statSync(real).size;
308
+ res.writeHead(200, {
309
+ "content-type": tipoDelArchivo(real),
310
+ "content-length": String(tam),
311
+ "cache-control": "no-store",
312
+ // Que el navegador no se ponga a adivinar el tipo: un .txt con HTML
313
+ // dentro no se ejecuta como pagina.
314
+ "x-content-type-options": "nosniff",
315
+ "content-disposition": `${guardar ? "attachment" : "inline"}; filename="${nombre}"`,
316
+ });
317
+ // HEAD es la pregunta sin la respuesta: las cabeceras dicen que existe,
318
+ // que tamano tiene y de que tipo es, y no se mueve un solo byte del disco.
319
+ if (req.method === "HEAD") {
320
+ res.end();
321
+ return;
322
+ }
323
+ res.end(fs.readFileSync(real));
324
+ }
325
+ catch {
326
+ res.writeHead(404).end();
327
+ }
328
+ }
119
329
  servidor.on("upgrade", (req, socket, head) => {
120
330
  const ruta = (req.url || "/").split("?")[0];
121
331
  if (ruta !== "/ws" || !autorizada(req)) {
@@ -180,6 +390,19 @@ export async function iniciarServidorWeb(opciones = {}) {
180
390
  catch {
181
391
  return;
182
392
  }
393
+ if (m?.t === "entrada" && typeof m.texto === "string") {
394
+ // Los archivos que se acaban de subir van DELANTE de lo que escribio el
395
+ // usuario, y con la ruta entera. El aviso se compone aqui y no en la
396
+ // pagina para que la forma de decirlo viva en un solo sitio.
397
+ const subidos = Array.isArray(m.archivos)
398
+ ? m.archivos.filter((r) => typeof r === "string" && r.length > 0)
399
+ : [];
400
+ if (subidos.length > 0) {
401
+ const aviso = avisoDeSubida(subidos);
402
+ m.texto = m.texto.trim() ? `${aviso}
403
+ ${m.texto}` : aviso;
404
+ }
405
+ }
183
406
  if (m?.t === "entrada" && typeof m.texto === "string") {
184
407
  const texto = m.texto.slice(0, 20_000).trim();
185
408
  if (texto)
@@ -204,6 +427,15 @@ export async function iniciarServidorWeb(opciones = {}) {
204
427
  });
205
428
  });
206
429
  fijarEmisorDePermiso((peticion) => aTodos({ t: "permiso", ...peticion }) > 0);
430
+ // El agente se puso a esperar: para el movil, eso es "ya esta".
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();
437
+ aTodos({ t: "turno" });
438
+ });
207
439
  const puerto = await new Promise((resolve, reject) => {
208
440
  servidor.once("error", reject);
209
441
  servidor.listen(puertoPedido, anfitrion, () => {
@@ -23,7 +23,7 @@
23
23
  * "background": se actua sobre una ventana concreta sin traerla al frente y
24
24
  * sin mover el raton del usuario.
25
25
  */
26
- export type ComputerAction = "screenshot" | "ui_snapshot" | "ui_click" | "ui_type" | "ui_focus" | "find_element" | "left_click" | "click" | "double_click" | "triple_click" | "right_click" | "middle_click" | "mouse_move" | "move" | "left_click_drag" | "scroll" | "type" | "key" | "hotkey" | "wait" | "wait_change" | "sleep" | "cursor_position" | "list_windows" | "focus_window" | "get_active_window" | "open_app";
26
+ export type ComputerAction = "screenshot" | "ui_snapshot" | "ui_click" | "ui_type" | "ui_focus" | "find_element" | "left_click" | "click" | "mouse_click" | "double_click" | "triple_click" | "right_click" | "middle_click" | "mouse_move" | "move" | "left_click_drag" | "drag" | "scroll" | "type" | "key" | "hotkey" | "press" | "key_press" | "wait" | "wait_change" | "wait_visual_change" | "sequence" | "scroll_into_view" | "scroll_to" | "read_text" | "get_text" | "sleep" | "cursor_position" | "list_windows" | "focus_window" | "get_active_window" | "open_app";
27
27
  export interface ComputerUseParams {
28
28
  action: ComputerAction;
29
29
  /** Ventana objetivo por titulo o proceso (ej. "chrome", "Flow"). */
@@ -42,6 +42,8 @@ export interface ComputerUseParams {
42
42
  key?: string;
43
43
  query?: string;
44
44
  filter?: string;
45
+ target?: string | number;
46
+ maxAttempts?: number;
45
47
  direction?: "up" | "down" | "left" | "right";
46
48
  amount?: number;
47
49
  repeat?: number;
@@ -54,5 +56,13 @@ export interface ComputerUseParams {
54
56
  question?: string;
55
57
  /** Solo para screenshot: pone false para saltarse la vision y ahorrar tokens. */
56
58
  analyze?: boolean;
59
+ /** Solo para screenshot/find_element: dibuja una cuadrícula de coordenadas sobre la captura. */
60
+ grid?: boolean;
61
+ /** Región de interés [x1, y1, x2, y2] para capturar o esperar cambios en alta resolución. */
62
+ region?: [number, number, number, number];
63
+ /** Sub-acciones a ejecutar en ráfaga para action="sequence". */
64
+ steps?: ComputerUseParams[];
65
+ /** true si las coordenadas están en escala 0..1000 estilo OpenAI Operator / Astra. */
66
+ normalized?: boolean;
57
67
  }
58
68
  export declare function computerUse(params: ComputerUseParams, cwd?: string, apiKey?: string): Promise<string>;