ratacode 0.2.5

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 (58) hide show
  1. package/CREDITS.md +80 -0
  2. package/LICENSE +21 -0
  3. package/README.md +289 -0
  4. package/apreton/README.md +54 -0
  5. package/apreton/handshake.md +33 -0
  6. package/apreton/headless.md +58 -0
  7. package/apreton/mcp.md +221 -0
  8. package/apreton/navegador.md +58 -0
  9. package/bin/instalacion.js +148 -0
  10. package/bin/ratacode.js +1251 -0
  11. package/fabrica/settings.yaml +247 -0
  12. package/mcp/README.md +253 -0
  13. package/mcp/bin/ratacode-mcp.js +215 -0
  14. package/mcp/lib/actividad.js +49 -0
  15. package/mcp/lib/casa.js +126 -0
  16. package/mcp/lib/claves.js +148 -0
  17. package/mcp/lib/espacios.js +117 -0
  18. package/mcp/lib/http.js +184 -0
  19. package/mcp/lib/lectura.js +210 -0
  20. package/mcp/lib/modelos.js +235 -0
  21. package/mcp/lib/nucleo.js +332 -0
  22. package/mcp/lib/registro.js +21 -0
  23. package/mcp/lib/seguridad.js +283 -0
  24. package/mcp/lib/servidor.js +397 -0
  25. package/mcp/lib/tareas.js +406 -0
  26. package/mcp/package.json +22 -0
  27. package/mcp/tunel.mjs +209 -0
  28. package/modos/arquitecto/agent.cordis.yml +120 -0
  29. package/modos/arquitecto/preset.yml +3 -0
  30. package/modos/capataz/agent.cordis.yml +120 -0
  31. package/modos/capataz/preset.yml +3 -0
  32. package/modos/faro/agent.cordis.yml +120 -0
  33. package/modos/faro/preset.yml +3 -0
  34. package/modos/gepeto/agent.cordis.yml +191 -0
  35. package/modos/gepeto/preset.yml +3 -0
  36. package/modos/hero/agent.cordis.yml +198 -0
  37. package/modos/hero/preset.yml +3 -0
  38. package/modos/modo-rata/agent.cordis.yml +198 -0
  39. package/modos/modo-rata/preset.yml +3 -0
  40. package/modos/nex/agent.cordis.yml +213 -0
  41. package/modos/nex/preset.yml +3 -0
  42. package/modos/nex/skills/cordis-plugin-development/SKILL.md +420 -0
  43. package/modos/nex/skills/editing-cordis-compositions/SKILL.md +165 -0
  44. package/modos/pix/agent.cordis.yml +207 -0
  45. package/modos/pix/preset.yml +3 -0
  46. package/modos/tirita/agent.cordis.yml +130 -0
  47. package/modos/tirita/preset.yml +3 -0
  48. package/package.json +49 -0
  49. package/piel/activos/ratacode-emblema.svg +14 -0
  50. package/piel/activos/ratacode-es.js +1331 -0
  51. package/piel/activos/ratacode-identidad.css +50 -0
  52. package/piel/activos/ratacode-piel.css +217 -0
  53. package/piel/activos/ratacode-piel.js +487 -0
  54. package/piel/activos/ratacode-vida.js +424 -0
  55. package/piel/cordis.patch.yml +6 -0
  56. package/piel/lib/cliente.js +1020 -0
  57. package/piel/lib/index.js +1230 -0
  58. package/piel/package.json +36 -0
@@ -0,0 +1,332 @@
1
+ /**
2
+ * nucleo — el puente al core de DSH. Aquí NO hay lógica de modelos.
3
+ *
4
+ * Lo que hace: lanza UN hijo `dsh --profile sdk` por tarea, le habla el
5
+ * protocolo JSON-RPC por stdio que el propio DSH publica
6
+ * (`dsh-sdk-protocol`), y traduce lo que pasa dentro a un resultado.
7
+ *
8
+ * Por qué así, y no dentro del proceso de la web:
9
+ * · el aislamiento: la tarea no comparte proceso con tu sesión de trabajo;
10
+ * · cancelar es matar: inmediato y sin efectos raros en la web;
11
+ * · el sandbox se fija por tarea con un parche, y no depende del entorno;
12
+ * · y el core es EL MISMO: mismo binario, misma casa, mismos proveedores,
13
+ * mismas claves. No se duplica nada.
14
+ *
15
+ * Medido antes de escribir esto (sonda del 24-sep): el perfil `sdk` se
16
+ * autocrea, resuelve sus bundles desde la instalación, contesta el handshake
17
+ * `initialize` y acepta `session/prompt`, avisando por notificaciones
18
+ * `session.event` y `session.status`.
19
+ */
20
+ import { spawn } from 'node:child_process';
21
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { entornoDelMotorSinClaves } from './claves.js';
24
+ import { copiarCerco, parcheDePolitica } from './seguridad.js';
25
+
26
+ /** Cuánto se espera a que un hijo termine de irse antes de matarlo. */
27
+ const GRACIA_MS = 2500;
28
+ /** Cuántos bytes de stderr guardamos para poder explicar un fallo. */
29
+ const TOPE_STDERR = 4096;
30
+
31
+ /** Las variables que describen a ESTA sesión y que el hijo no debe heredar. */
32
+ const NO_HEREDAR = ['DSH_SESSION_ID', 'DSH_SHELL', 'DSH_WEB_URL'];
33
+
34
+ /**
35
+ * Lanzar una tarea. Devuelve el mando (para cancelar) y la promesa del resultado.
36
+ * @param {object} opciones - casa, motor, espacio, ruta de modelo, prompt, límites.
37
+ * @returns {{promesa: Promise<object>, cancelar: (motivo?: string) => void, pid: number|undefined}}
38
+ */
39
+ export function lanzarTarea({
40
+ id,
41
+ casa,
42
+ dshBin,
43
+ espacio,
44
+ raices,
45
+ provider,
46
+ model,
47
+ prompt,
48
+ maxTokens,
49
+ timeoutMs,
50
+ modo,
51
+ alEvento,
52
+ }) {
53
+ const carpetaTemporal = join(casa, 'mcp', 'tmp');
54
+ mkdirSync(carpetaTemporal, { recursive: true });
55
+ // El cerco de lectura: el plugin (`lectura.js`) tiene que estar EN la carpeta
56
+ // del parche, porque el motor lo busca por su nombre relativo desde ahí. Se
57
+ // copia ANTES de arrancar el motor: si no está, el cerco no se monta.
58
+ copiarCerco(casa);
59
+ const rutaParche = join(carpetaTemporal, 'politica-' + id + '.yml');
60
+ writeFileSync(rutaParche, parcheDePolitica({ modo, espacio, raices: raices ?? [espacio] }));
61
+
62
+ // El hijo arranca SIN las variables de claves (ni las del cliente MCP ni las
63
+ // de Windows): la única fuente de claves es el almacén de la casa, que es lo
64
+ // que escribe Ajustes › Models. Si las heredara, el motor las daría por
65
+ // puestas y una clave vieja del entorno ganaría a la buena.
66
+ const entorno = entornoDelMotorSinClaves(casa, { ...process.env, DSH_HOME: casa, DSH_PERMISSION_MODE: modo });
67
+ for (const nombre of NO_HEREDAR) delete entorno[nombre];
68
+
69
+ const hijo = spawn(process.execPath, [dshBin, '--profile', 'sdk', '--patch', rutaParche], {
70
+ cwd: espacio,
71
+ env: entorno,
72
+ stdio: ['pipe', 'pipe', 'pipe'],
73
+ windowsHide: true,
74
+ });
75
+
76
+ const sessionId = 'mcp-' + id;
77
+ let buffer = '';
78
+ let stderr = '';
79
+ let terminado = false;
80
+ let cancelada = false;
81
+ let motivoCancelacion = null;
82
+ let promptAceptado = false;
83
+ let texto = '';
84
+ let pasos = 0;
85
+ let ultimoMotivo = null;
86
+ const errores = [];
87
+ const uso = { input: 0, output: 0, total: 0, cache_read: 0, cache_write: 0, reasoning: 0, informado: false };
88
+ const empezado = Date.now();
89
+
90
+ let resolver;
91
+ let temporizador;
92
+ const promesa = new Promise((listo) => { resolver = listo; });
93
+
94
+ /** Cerrar una vez, con lo que haya. */
95
+ const terminar = (extra = {}) => {
96
+ if (terminado) return;
97
+ terminado = true;
98
+ if (temporizador !== undefined) clearTimeout(temporizador);
99
+ const resultado = {
100
+ ok: extra.ok ?? (errores.length === 0 && !cancelada),
101
+ texto,
102
+ pasos,
103
+ provider,
104
+ model,
105
+ session_id: sessionId,
106
+ duracion_ms: Date.now() - empezado,
107
+ tokens: { ...uso },
108
+ motivo: ultimoMotivo,
109
+ errores,
110
+ cancelada,
111
+ motivo_cancelacion: motivoCancelacion,
112
+ codigo_salida: extra.codigoSalida ?? null,
113
+ ...extra.extra,
114
+ };
115
+ // El hijo se va por las buenas; si no se va, se le mata.
116
+ cerrar()
117
+ .catch(() => { /* da igual: abajo se le mata si sigue vivo */ })
118
+ .finally(() => {
119
+ try { rmSync(rutaParche, { force: true }); } catch { /* da igual */ }
120
+ resolver(resultado);
121
+ });
122
+ };
123
+
124
+ const enviar = (idPeticion, method, params) => {
125
+ if (hijo.stdin.destroyed) return;
126
+ const frame = { jsonrpc: '2.0', id: idPeticion, method };
127
+ if (params !== undefined) frame.params = params;
128
+ try {
129
+ hijo.stdin.write(JSON.stringify(frame) + '\n');
130
+ } catch {
131
+ // El hijo se fue entre medias: quien tenga que enterarse, se enterará por `exit`.
132
+ }
133
+ };
134
+
135
+ const anotarUso = (u) => {
136
+ if (u === null || typeof u !== 'object') return;
137
+ uso.informado = true;
138
+ uso.input += numeroSeguro(u.inputTokens);
139
+ uso.output += numeroSeguro(u.outputTokens);
140
+ uso.total += numeroSeguro(u.totalTokens);
141
+ uso.cache_read += numeroSeguro(u.cacheReadTokens);
142
+ uso.cache_write += numeroSeguro(u.cacheWriteTokens);
143
+ uso.reasoning += numeroSeguro(u.reasoningTokens);
144
+ };
145
+
146
+ const alEventoDeSesion = (evento) => {
147
+ if (evento === null || typeof evento !== 'object') return;
148
+ if (alEvento !== undefined) alEvento(evento);
149
+ if (evento.type === 'assistant/message') {
150
+ pasos += 1;
151
+ const trozos = textoDeMensaje(evento.data?.message);
152
+ if (trozos !== '') texto = trozos;
153
+ anotarUso(evento.data?.usage);
154
+ return;
155
+ }
156
+ if (evento.type === 'turn/end') {
157
+ const motivo = evento.data?.reason;
158
+ if (motivo !== null && typeof motivo === 'object') {
159
+ ultimoMotivo = motivo.kind ?? null;
160
+ if (motivo.kind === 'error') {
161
+ errores.push(enEspanol(motivo.error?.message ?? 'la tarea falló sin mensaje'));
162
+ }
163
+ }
164
+ }
165
+ };
166
+
167
+ const manejarFrame = (frame) => {
168
+ if (frame.method === 'session.event') {
169
+ alEventoDeSesion(frame.params?.event);
170
+ return;
171
+ }
172
+ if (frame.method === 'session.status') {
173
+ if (frame.params?.status === 'idle' && promptAceptado) terminar();
174
+ return;
175
+ }
176
+ if (frame.id === 1) {
177
+ if (frame.error !== undefined) {
178
+ errores.push('el motor rechazó el arranque: ' + (frame.error.message ?? JSON.stringify(frame.error)));
179
+ terminar({ ok: false });
180
+ return;
181
+ }
182
+ enviar(2, 'session/prompt', {
183
+ sessionId,
184
+ contentBlocks: [{ type: 'text', text: prompt }],
185
+ });
186
+ return;
187
+ }
188
+ if (frame.id === 2) {
189
+ if (frame.error !== undefined) {
190
+ errores.push('el motor rechazó la tarea: ' + enEspanol(frame.error.message ?? JSON.stringify(frame.error)));
191
+ terminar({ ok: false });
192
+ return;
193
+ }
194
+ promptAceptado = true;
195
+ return;
196
+ }
197
+ if (frame.id === 3) {
198
+ terminar();
199
+ }
200
+ };
201
+
202
+ hijo.stdout.setEncoding('utf8');
203
+ hijo.stdout.on('data', (trozo) => {
204
+ buffer += trozo;
205
+ let corte;
206
+ while ((corte = buffer.indexOf('\n')) >= 0) {
207
+ const linea = buffer.slice(0, corte).trim();
208
+ buffer = buffer.slice(corte + 1);
209
+ if (linea === '') continue;
210
+ let frame;
211
+ try {
212
+ frame = JSON.parse(linea);
213
+ } catch {
214
+ errores.push('el motor escribió algo que no es protocolo por stdout: ' + linea.slice(0, 200));
215
+ continue;
216
+ }
217
+ try {
218
+ manejarFrame(frame);
219
+ } catch (e) {
220
+ errores.push('error manejando un frame del motor: ' + (e instanceof Error ? e.message : String(e)));
221
+ }
222
+ }
223
+ });
224
+
225
+ hijo.stderr.setEncoding('utf8');
226
+ hijo.stderr.on('data', (trozo) => {
227
+ stderr = (stderr + trozo).slice(-TOPE_STDERR);
228
+ });
229
+
230
+ hijo.on('error', (e) => {
231
+ errores.push('no pude arrancar el motor: ' + e.message);
232
+ terminar({ ok: false });
233
+ });
234
+
235
+ hijo.on('exit', (codigo) => {
236
+ if (terminado) return;
237
+ if (cancelada) {
238
+ terminar({ ok: false, codigoSalida: codigo });
239
+ return;
240
+ }
241
+ if (errores.length === 0) {
242
+ errores.push('el motor se cerró antes de terminar (código ' + codigo + ')'
243
+ + (stderr.trim() === '' ? '' : ': ' + enEspanol(ultimaLinea(stderr))));
244
+ }
245
+ terminar({ ok: false, codigoSalida: codigo });
246
+ });
247
+
248
+ /** Pedirle al hijo que se vaya por las buenas. */
249
+ const cerrar = async () => {
250
+ // Cancelar es inmediato: el árbol ya está muerto y no se le espera cortesías.
251
+ if (cancelada) return;
252
+ if (hijo.exitCode !== null || hijo.signalCode !== null) return;
253
+ enviar(3, 'shutdown');
254
+ await esperar(GRACIA_MS);
255
+ if (hijo.exitCode === null && hijo.signalCode === null) matarArbol(hijo);
256
+ };
257
+
258
+ /** Cancelar: matar el árbol del hijo. Inmediato y sin efectos en la web. */
259
+ const cancelar = (motivo = 'cancelada por el cliente') => {
260
+ if (terminado) return;
261
+ cancelada = true;
262
+ motivoCancelacion = motivo;
263
+ matarArbol(hijo);
264
+ terminar({ ok: false, extra: { motivo_cancelacion: motivo } });
265
+ };
266
+
267
+ if (typeof timeoutMs === 'number' && timeoutMs > 0) {
268
+ temporizador = setTimeout(() => cancelar('se agotó el tiempo (' + timeoutMs + ' ms)'), timeoutMs);
269
+ }
270
+
271
+ enviar(1, 'initialize', {
272
+ cwd: espacio,
273
+ provider,
274
+ model,
275
+ ...(typeof maxTokens === 'number' && maxTokens > 0 ? { maxTokens } : {}),
276
+ });
277
+
278
+ return { promesa, cancelar, pid: hijo.pid };
279
+ }
280
+
281
+ /** Un número seguro (los contadores que falten cuentan como 0). */
282
+ function numeroSeguro(valor) {
283
+ return typeof valor === 'number' && Number.isFinite(valor) ? valor : 0;
284
+ }
285
+
286
+ /** El texto de un mensaje del asistente, sin las partes que no son texto. */
287
+ function textoDeMensaje(mensaje) {
288
+ const contenido = mensaje?.content;
289
+ if (!Array.isArray(contenido)) return '';
290
+ return contenido
291
+ .filter((bloque) => bloque !== null && typeof bloque === 'object' && bloque.type === 'text' && typeof bloque.text === 'string')
292
+ .map((bloque) => bloque.text)
293
+ .join('\n')
294
+ .trim();
295
+ }
296
+
297
+ /** La última línea con algo de un texto. */
298
+ function ultimaLinea(texto) {
299
+ const lineas = texto.trim().split(/\r?\n/).filter((l) => l.trim() !== '');
300
+ return lineas.length === 0 ? '' : lineas[lineas.length - 1];
301
+ }
302
+
303
+ /**
304
+ * Traducir los fallos del motor que el humano va a leer. Sólo los que ya tienen
305
+ * traducción: el mensaje original del motor se conserva detrás, para poder
306
+ * buscarlo. Si no hay traducción, se devuelve tal cual (no se inventa nada).
307
+ */
308
+ function enEspanol(mensaje) {
309
+ const texto = String(mensaje);
310
+ if (/MISSING_CREDENTIAL|no credential for provider route/i.test(texto)) {
311
+ return 'el motor no encontró la clave de ese proveedor en la casa.'
312
+ + ' Pégala en RATACODE › Ajustes › Models (queda en ' + '<casa>/.credentials.yaml' + '). [motor] ' + texto;
313
+ }
314
+ return texto;
315
+ }
316
+
317
+ /** Esperar, sin más. */
318
+ function esperar(ms) {
319
+ return new Promise((listo) => setTimeout(listo, ms));
320
+ }
321
+
322
+ /** Matar el árbol entero del hijo (en Windows, `taskkill /T`). */
323
+ function matarArbol(hijo) {
324
+ if (hijo === undefined || hijo === null || hijo.pid === undefined) return;
325
+ try {
326
+ if (process.platform === 'win32') {
327
+ spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', 'taskkill /pid ' + hijo.pid + ' /T /F'], { windowsHide: true, stdio: 'ignore' });
328
+ } else {
329
+ hijo.kill('SIGTERM');
330
+ }
331
+ } catch { /* ya se fue */ }
332
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * registro — hablar sin romper el protocolo.
3
+ *
4
+ * El transporte stdio de MCP es DUEÑO de stdout: un solo `console.log` nuestro
5
+ * dentro de stdout y el cliente deja de entender los frames. Por eso aquí todo
6
+ * lo que contamos va a stderr, y stdout sólo lo toca el SDK.
7
+ *
8
+ * Regla de la casa, además: aquí NUNCA se escribe el valor de una clave. Sólo
9
+ * su nombre y de dónde sale.
10
+ */
11
+
12
+ /** Un aviso para el humano (stderr, nunca stdout). */
13
+ export function aviso(...trozos) {
14
+ process.stderr.write('RATACODE-MCP · ' + trozos.join(' ') + '\n');
15
+ }
16
+
17
+ /** Un error con pila, también por stderr. */
18
+ export function fallo(que, error) {
19
+ const texto = error instanceof Error ? (error.stack ?? error.message) : String(error);
20
+ process.stderr.write('RATACODE-MCP · ' + que + ': ' + texto + '\n');
21
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * seguridad — el espacio de trabajo manda, y lo peligroso se pide.
3
+ *
4
+ * Tres ideas, y ninguna es adorno:
5
+ *
6
+ * 1 · ESPACIO CERRADO. Una tarea sólo puede trabajar dentro de las raíces
7
+ * autorizadas (`mcp.workspaces` en los ajustes de la casa). Si no hay
8
+ * ninguna declarada, la única raíz es el espacio por defecto o la carpeta
9
+ * desde la que arrancó el servidor. Todo lo demás se rechaza con un error
10
+ * que dice qué hacer. Esto es lo que evita que un agente se ponga a
11
+ * trabajar por todo el disco.
12
+ *
13
+ * 2 · EL SANDBOX LO IMPONE EL CORE, no nosotros. El modo por defecto es
14
+ * `workspace-write` y el cwd de la sesión es su frontera de escritura
15
+ * (medido en `dsh-sandbox-policy/lib/types/index.d.ts:40-55`). Nosotros no
16
+ * reimplementamos nada: le pasamos al hijo un parche que FIJA el modo, para
17
+ * que ni un `DSH_PERMISSION_MODE` heredado del entorno pueda aflojarlo.
18
+ *
19
+ * OJO, y es lo que se midió en R25: fijar `sandbox-policy` NO bastaba. La
20
+ * casa de fábrica trae `permission.defaultPreset: danger-full-access`
21
+ * (`fabrica/settings.yaml`) y ese ajuste se aplica AL CREAR la sesión
22
+ * (`dsh-permission-presets/README.md:64`), así que ganaba al parche. Por eso
23
+ * el parche APAGA la fila `permission` en el hijo del MCP: sin ese servicio,
24
+ * el modo que manda es el de `sandbox-policy`, que es el nuestro. El panel
25
+ * del usuario no se toca: sigue con el preset que él elija.
26
+ *
27
+ * 3 · SIN VÍAS DE ESCAPE. Un modo de escritura no sirve de nada si el agente
28
+ * puede abrir una terminal y escribir por ella. El parche apaga las filas
29
+ * de las herramientas que ejecutan, navegan o delegan (abajo, una por una).
30
+ *
31
+ * 4 · LA LECTURA, CON GANCHO. El motor no sabe acotar la lectura (ni un modo,
32
+ * ni un ajuste, ni un plugin: mira `lib/lectura.js`). Se acota con el gancho
33
+ * `tools/pre-execute` de `@deepseek-ai/dsh-hooks-claude-code`, que recibe
34
+ * cada llamada antes de ejecutarse y la puede DENEGAR. El parche monta ese
35
+ * gancho con las raíces autorizadas, y el guion es `lib/lectura.js`.
36
+ *
37
+ * 5 · LO PELIGROSO SE PIDE DOS VECES. `allow_dangerous` en la llamada no basta:
38
+ * hace falta que el humano haya encendido `mcp.permitir_peligroso` en la
39
+ * casa. Si no, se deniega y se explica. Nunca se queda esperando una
40
+ * aprobación que no existe: en un servidor MCP no hay a quién preguntar.
41
+ */
42
+ import { copyFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
43
+ import { homedir } from 'node:os';
44
+ import { dirname, join, resolve, sep } from 'node:path';
45
+ import { fileURLToPath } from 'node:url';
46
+ import { ajustesMcp } from './casa.js';
47
+ import { canonica, comparable, estaDentro, normalizarRuta } from './lectura.js';
48
+
49
+ /** El fichero del cerco de lectura (el plugin que se copia junto al parche). */
50
+ const GUION_DEL_CERCO = fileURLToPath(new URL('./lectura.js', import.meta.url));
51
+
52
+ /**
53
+ * Las herramientas del motor que SE APAGAN en toda tarea del MCP, con el motivo
54
+ * de cada una (ids reales de la composición `sdk`, volcada con `--dump-config`).
55
+ *
56
+ * · `tool-pwsh` y `tool-bash` — una terminal escribe fuera del cerco y lee
57
+ * fuera del cerco: es la vía de escape entera.
58
+ * · `tool-jobs` — `job_kill` mata procesos que la tarea no arrancó y
59
+ * `job_output` lee lo que dejaron: control de procesos fuera del cerco.
60
+ * · `tool-web` — `web_search`/`web_fetch` sacan a la red lo que la tarea lea
61
+ * (y traen de fuera lo que sea, saltándose el cerco de ficheros).
62
+ * · `tool-subagent`, `tool-subagent-fork`, `tool-subagent-control`,
63
+ * `tool-subagent-list-agents` — delegan en otro agente: otra puerta con
64
+ * herramientas que no son de esta tarea.
65
+ * · `tool-workflow` — ejecuta un guion de JavaScript que orquesta subagentes.
66
+ * · `tool-ralph` — el bucle «Ralph» arranca agentes nuevos (subagentes).
67
+ */
68
+ export const HERRAMIENTAS_QUE_SE_APAGAN = [
69
+ ['tool-pwsh', 'una terminal ejecuta y lee fuera del cerco'],
70
+ ['tool-bash', 'una terminal ejecuta y lee fuera del cerco'],
71
+ ['tool-jobs', 'job_kill mata procesos ajenos; job_output lee lo que dejaron'],
72
+ ['tool-web', 'la red saca de la máquina lo que la tarea lea'],
73
+ ['tool-subagent', 'delega en otro agente, con sus propias herramientas'],
74
+ ['tool-subagent-fork', 'delega en otro agente heredando esta conversación'],
75
+ ['tool-subagent-control', 'habla con agentes ya lanzados y los interrumpe'],
76
+ ['tool-subagent-list-agents', 'enumera y alcanza agentes de otras sesiones'],
77
+ ['tool-workflow', 'un guion de JavaScript orquesta subagentes a escala'],
78
+ ['tool-ralph', 'el bucle Ralph arranca agentes nuevos'],
79
+ ];
80
+
81
+ /**
82
+ * ¿Es la raíz de un disco (`C:\`, `/`)? Un espacio de trabajo así es todo el
83
+ * disco, y en modo HTTP eso no se admite.
84
+ * @param {string} ruta - ruta ya normalizada.
85
+ * @returns {boolean}
86
+ */
87
+ export function esRaizDeDisco(ruta) {
88
+ const limpia = ruta.endsWith(sep) && ruta.length > 1 ? ruta.slice(0, -1) : ruta;
89
+ return /^[a-z]:$/i.test(limpia) || limpia === '' || limpia === '/';
90
+ }
91
+
92
+ /**
93
+ * ¿Es la carpeta del usuario (o la que los contiene a todos)? En modo HTTP no se
94
+ * admite como espacio de trabajo: con la URL en la mano sería el PC entero a un
95
+ * `working_directory` de distancia.
96
+ * @param {string} ruta - ruta ya normalizada.
97
+ * @returns {boolean}
98
+ */
99
+ export function esCarpetaDeUsuario(ruta) {
100
+ const a = comparable(ruta);
101
+ return a === comparable(normalizarRuta(homedir())) || a === comparable(normalizarRuta(dirname(homedir())));
102
+ }
103
+
104
+ /**
105
+ * Resolver el espacio de una tarea, o negarse con un motivo útil.
106
+ * @param {{casa: string, pedido?: string, cwdPorDefecto: string, http?: boolean}} opciones
107
+ * @returns {{espacio: string, raiz: string, raices: string[], avisos: string[]}}
108
+ */
109
+ export function resolverEspacio({ casa, pedido, cwdPorDefecto, http = false }) {
110
+ const ajustes = ajustesMcp(casa);
111
+ const avisos = [...ajustes.avisos];
112
+
113
+ // En modo HTTP (el del túnel) el espacio se aprieta: sin `mcp.workspaces`
114
+ // declarados no se trabaja. Si no, la única raíz sería la carpeta desde la que
115
+ // arrancó el servidor —que puede ser la carpeta de usuario entera— y con la
116
+ // URL en la mano eso es el disco ajeno.
117
+ if (http && ajustes.workspaces.length === 0) {
118
+ throw new Error(
119
+ 'en modo HTTP hacen falta espacios declarados: pon `mcp.workspaces:` en ' + casa
120
+ + '\\settings.yaml con las carpetas donde puede trabajar (y `workspace_por_defecto:` si quieres'
121
+ + ' una por defecto). Sin esa lista, la única raíz sería la carpeta desde la que arrancó el'
122
+ + ' servidor, y eso, con la URL en la mano de cualquiera, es demasiado.',
123
+ );
124
+ }
125
+
126
+ let raices = ajustes.workspaces.length > 0
127
+ ? ajustes.workspaces.map(normalizarRuta)
128
+ : [normalizarRuta(ajustes.workspacePorDefecto ?? cwdPorDefecto)];
129
+ if (ajustes.workspaces.length === 0) {
130
+ avisos.push('la casa no tiene `mcp.workspaces`: sólo se permite ' + raices[0]);
131
+ }
132
+
133
+ // Ni la raíz de un disco ni la carpeta del usuario como espacio: se niegan en
134
+ // modo HTTP, que es el que se expone. (En local el humano arrancó el servidor
135
+ // en su propia carpeta a propósito, y ahí manda él.)
136
+ if (http) {
137
+ const permitidas = raices.filter((r) => !esRaizDeDisco(r) && !esCarpetaDeUsuario(r));
138
+ for (const fuera of raices.filter((r) => !permitidas.includes(r))) {
139
+ avisos.push('espacio demasiado ancho, lo ignoro: ' + fuera
140
+ + ' (ni la raíz de un disco ni tu carpeta de usuario valen como espacio de trabajo en modo HTTP)');
141
+ }
142
+ if (permitidas.length === 0) {
143
+ throw new Error(
144
+ 'no queda ningún espacio de trabajo admisible: ni la raíz de un disco ni tu carpeta de usuario ('
145
+ + homedir() + ' y ' + dirname(homedir()) + ') valen. Declara `mcp.workspaces` en '
146
+ + casa + '\\settings.yaml con carpetas de trabajo de verdad.',
147
+ );
148
+ }
149
+ raices = permitidas;
150
+ }
151
+
152
+ const candidato = pedido === undefined || pedido === null || String(pedido).trim() === ''
153
+ ? raices[0]
154
+ : normalizarRuta(String(pedido));
155
+
156
+ if (!existsSync(candidato)) {
157
+ throw new Error('el espacio de trabajo no existe: ' + candidato);
158
+ }
159
+ if (!statSync(candidato).isDirectory()) {
160
+ throw new Error('el espacio de trabajo no es una carpeta: ' + candidato);
161
+ }
162
+ if (http && (esRaizDeDisco(candidato) || esCarpetaDeUsuario(candidato))) {
163
+ throw new Error(
164
+ 'el espacio de trabajo ' + candidato + ' es demasiado ancho para modo HTTP: ni la raíz de un'
165
+ + ' disco ni tu carpeta de usuario valen. Usa una carpeta de trabajo de verdad.',
166
+ );
167
+ }
168
+
169
+ const raiz = raices.find((r) => estaDentro(candidato, r));
170
+ if (raiz === undefined) {
171
+ throw new Error(
172
+ 'el espacio de trabajo ' + candidato + ' está fuera de los espacios autorizados ('
173
+ + raices.join(', ') + '). Si de verdad quieres trabajar ahí, añádelo a `mcp.workspaces` en '
174
+ + casa + '\\settings.yaml y vuelve a llamarme.',
175
+ );
176
+ }
177
+ return { espacio: candidato, raiz, raices, avisos };
178
+ }
179
+
180
+ /**
181
+ * Qué modo de sandbox se usará, y por qué.
182
+ * @param {{casa: string, allowDangerous?: boolean}} opciones
183
+ * @returns {{modo: 'workspace-write'|'danger-full-access', motivo: string}}
184
+ */
185
+ export function resolverModo({ casa, allowDangerous }) {
186
+ if (allowDangerous !== true) {
187
+ return { modo: 'workspace-write', motivo: 'por defecto: escritura sólo dentro del espacio de trabajo' };
188
+ }
189
+ const ajustes = ajustesMcp(casa);
190
+ if (!ajustes.permitirPeligroso) {
191
+ throw new Error(
192
+ 'me pides `allow_dangerous` pero la casa no lo tiene permitido. Para habilitarlo, pon '
193
+ + '`mcp: { permitir_peligroso: true }` en ' + casa + '\\settings.yaml. Hasta entonces, la tarea '
194
+ + 'corre en `workspace-write`: escribe sólo dentro del espacio de trabajo (y lee, en los dos casos,'
195
+ + ' sólo dentro de las carpetas autorizadas: mira `lib/lectura.js`).',
196
+ );
197
+ }
198
+ return { modo: 'danger-full-access', motivo: 'autorizado por la casa (`mcp.permitir_peligroso`) y pedido en la llamada' };
199
+ }
200
+
201
+ /** Un valor YAML entre comillas simples, sin sorpresas con las barras de Windows. */
202
+ function yamlSeguro(texto) {
203
+ return "'" + String(texto).replace(/'/g, "''") + "'";
204
+ }
205
+
206
+ /**
207
+ * El parche que se le pasa al hijo para FIJAR su política, pase lo que pase en
208
+ * el entorno heredado. Es la única pieza que el MCP le añade al perfil `sdk`.
209
+ *
210
+ * Cinco bloques, y cada uno está por un motivo medido:
211
+ * 1. el modo y la raíz de escritura de esta tarea;
212
+ * 2. `permission` APAGADO: la casa puede tener
213
+ * `permission.defaultPreset: danger-full-access` (es lo que trae la fábrica,
214
+ * y es lo que el panel del usuario necesita), y ese ajuste se aplica al
215
+ * crear la sesión y ganaría a (1). Sin ese servicio manda (1);
216
+ * 3. las herramientas que ejecutan, navegan o delegan, apagadas una a una
217
+ * ({@link HERRAMIENTAS_QUE_SE_APAGAN});
218
+ * 4. el cerco de la LECTURA: se inserta `./lectura.js` (el plugin que copia
219
+ * {@link copiarCerco}) con las carpetas autorizadas dentro;
220
+ * 5. nada más: no se toca ni un fichero del motor.
221
+ *
222
+ * El `insert` es la única forma de AÑADIR una fila (un parche con `id` sólo
223
+ * retoca una que ya exista: `cordis-plugin-include/lib/index.js:67-89`), y el
224
+ * nombre `./lectura.js` se resuelve desde la CARPETA DEL PARCHE
225
+ * (`cordis-plugin-loader/lib/index.js:273-278`), o sea, `<casa>\mcp\tmp\`.
226
+ * @param {{modo: string, espacio: string, raices?: string[]}} opciones
227
+ * @returns {string} contenido YAML del overlay `--patch`.
228
+ */
229
+ export function parcheDePolitica({ modo, espacio, raices }) {
230
+ const lineas = [
231
+ '# Generado por RATACODE-MCP para UNA tarea. No editar: se reescribe en cada llamada.',
232
+ '# Fija el modo del sandbox, apaga las vías de escape y monta el cerco de lectura,',
233
+ '# por encima de lo que diga el entorno heredado o los ajustes de la casa.',
234
+ '- id: sandbox-policy',
235
+ ' config:',
236
+ ' mode: ' + modo,
237
+ ' workspaceRoot: ' + yamlSeguro(espacio),
238
+ '',
239
+ '# La casa de fábrica trae `permission.defaultPreset: danger-full-access` y ese ajuste',
240
+ '# se aplica AL CREAR la sesión, ganando al modo de arriba. Aquí se apaga el servicio de',
241
+ '# presets en el hijo del MCP: sin él, el modo que manda es el de `sandbox-policy`.',
242
+ '- id: permission',
243
+ ' disabled: true',
244
+ '',
245
+ '# Sin terminal, sin red, sin subagentes y sin guiones: nada con lo que saltar el cerco.',
246
+ ];
247
+ for (const [id, motivo] of HERRAMIENTAS_QUE_SE_APAGAN) {
248
+ lineas.push('', '# ' + id + ': ' + motivo, '- id: ' + id, ' disabled: true');
249
+ }
250
+ lineas.push(
251
+ '',
252
+ '# El cerco de la LECTURA: un plugin de cordis que engancha `tools/pre-execute` y',
253
+ '# deniega la herramienta que lleve una ruta fuera de estas carpetas.',
254
+ '- insert:',
255
+ ' - name: ' + yamlSeguro(NOMBRE_DEL_CERCO),
256
+ ' config:',
257
+ ' raices:',
258
+ );
259
+ const limpias = [...new Set((raices ?? [espacio]).map((r) => normalizarRuta(r)).filter((r) => r !== ''))];
260
+ for (const raiz of limpias) lineas.push(' - ' + yamlSeguro(raiz));
261
+ lineas.push('');
262
+ return lineas.join('\n') + '\n';
263
+ }
264
+
265
+ /** El nombre del fichero del cerco, copiado junto al parche (mismo directorio). */
266
+ const NOMBRE_DEL_CERCO = './lectura.js';
267
+
268
+ /**
269
+ * Dejar el plugin del cerco junto al parche: el motor lo busca por su nombre
270
+ * relativo (`./lectura.js`) desde la carpeta del parche, así que tiene que estar
271
+ * ahí. Se copia el fichero de verdad (el mismo que se prueba), no una copia
272
+ * escrita a mano: lo que se prueba es lo que se monta.
273
+ * @param {string} casa - la casa de RATACODE.
274
+ * @returns {string} la ruta del plugin copiado.
275
+ */
276
+ export function copiarCerco(casa) {
277
+ const carpeta = join(casa, 'mcp', 'tmp');
278
+ mkdirSync(carpeta, { recursive: true });
279
+ const destino = join(carpeta, 'lectura.js');
280
+ copyFileSync(GUION_DEL_CERCO, destino);
281
+ return destino;
282
+ }
283
+