zback 0.0.0-stage → 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ZBack contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,31 @@
1
- # Temporary Holding Version
1
+ # zback (CLI global)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Agente CLI para **Node.js 22 o superior**, sin dependencias npm externas. Se conecta a tu backend por WebSocket cifrado, enseña un ID temporal y solicita **autorización presencial para cada comando**.
4
+
5
+ ## Instalar
6
+
7
+ ```bash
8
+ npm install -g .
9
+ zback --help
10
+ zback
11
+ ```
12
+
13
+ Extrae este ZIP antes de ejecutar `npm install -g .` dentro de su directorio.
14
+
15
+ Opciones:
16
+
17
+ ```bash
18
+ zback --url wss://zb.soymaycol.icu/ws
19
+ zback --cwd /ruta/a/proyecto --timeout 15000 --max-output 32768
20
+ zback --no-reconnect
21
+ ```
22
+
23
+ Para pruebas locales: `zback --url ws://127.0.0.1:8787/ws`.
24
+
25
+ - Escribe `ACEPTO` para iniciar la sesión.
26
+ - Verás un **ID nuevo por cada conexión**. No es una contraseña creada por el usuario, pero funciona como credencial temporal: mantenlo privado.
27
+ - Cuando el MCP pida ejecutar un comando, verás su contenido completo y tendrás que escribir `s` para autorizarlo. Pulsar Enter, responder `n` o agotar el tiempo lo rechaza.
28
+ - Ctrl+C termina la conexión; desconectarse invalida el ID.
29
+ - Conexiones públicas `ws://` sin TLS están bloqueadas.
30
+
31
+ **Advertencia:** revisar antes de aprobar es fundamental: cualquier comando autorizado se ejecuta con los permisos de tu usuario. El backend no hace que un comando sea confiable.
package/bin/zback.js ADDED
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync, realpathSync, statSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { createInterface } from 'node:readline/promises';
6
+ import { stdin, stdout } from 'node:process';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const DEFAULT_URL = 'wss://zb.soymaycol.icu/ws';
10
+
11
+ function help() {
12
+ console.log(`ZBack 1.0.0 - WebSocket agent (Node.js >=22)
13
+
14
+ Uso: zback [opciones]
15
+
16
+ --url <wss://.../ws> WebSocket de destino (default: ${DEFAULT_URL})
17
+ --cwd <directorio> Directorio desde el cual ejecutar comandos
18
+ --timeout <ms> Máximo tiempo de ejecución por comando (1000-300000)
19
+ --max-output <bytes> Límite combinado de stdout+stderr (1024-262144)
20
+ --no-reconnect Desactivar reconexión automática
21
+ --help Mostrar esta ayuda
22
+
23
+ Se solicita permiso al inicio y cada vez que un comando remoto llega.
24
+ No hay API keys ni ejecución sin confirmación.`);
25
+ }
26
+
27
+ export function parseArgs(args) {
28
+ const options = { url: DEFAULT_URL, cwd: process.cwd(), timeout: 30000, maxOutput: 65536, reconnect: true };
29
+ const set = new Set(['--url', '--cwd', '--timeout', '--max-output']);
30
+ for (let i = 0; i < args.length; i++) {
31
+ const flag = args[i];
32
+ if (flag === '--help' || flag === '-h') return { help: true };
33
+ if (flag === '--no-reconnect') { options.reconnect = false; continue; }
34
+ if (!set.has(flag) || i + 1 >= args.length) throw new Error(`Argumento desconocido o incompleto: ${flag}`);
35
+ const value = args[++i];
36
+ if (flag === '--url') options.url = value;
37
+ if (flag === '--cwd') options.cwd = resolve(value);
38
+ if (flag === '--timeout') options.timeout = Number(value);
39
+ if (flag === '--max-output') options.maxOutput = Number(value);
40
+ }
41
+ if (!Number.isSafeInteger(options.timeout) || options.timeout < 1000 || options.timeout > 300000) throw new Error('--timeout debe estar entre 1000 y 300000 ms');
42
+ if (!Number.isSafeInteger(options.maxOutput) || options.maxOutput < 1024 || options.maxOutput > 262144) throw new Error('--max-output debe estar entre 1024 y 262144 bytes');
43
+ if (!existsSync(options.cwd) || !statSync(options.cwd).isDirectory()) throw new Error('--cwd debe ser un directorio existente');
44
+ const url = new URL(options.url);
45
+ if (url.protocol !== 'wss:' && !(url.protocol === 'ws:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))) {
46
+ throw new Error('Solo se permite wss:// público o ws:// local. Usa TLS para conexiones remotas.');
47
+ }
48
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/ws') throw new Error('URL inválida: usa una dirección WebSocket terminada en /ws sin credenciales ni parámetros');
49
+ return options;
50
+ }
51
+
52
+ export function execute(command, cwd, timeoutMs, maxOutput) {
53
+ return new Promise(resolveResult => {
54
+ let finished = false, timedOut = false, truncated = false;
55
+ const chunks = { stdout: [], stderr: [] };
56
+ let collected = 0;
57
+ let child;
58
+ try {
59
+ child = spawn(command, { cwd, shell: true, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
60
+ } catch (err) {
61
+ resolveResult({ ok: false, code: null, stdout: '', stderr: String(err.message) });
62
+ return;
63
+ }
64
+ const add = (kind, chunk) => {
65
+ const remaining = maxOutput - collected;
66
+ if (remaining <= 0) { truncated = true; return; }
67
+ if (chunk.length > remaining) truncated = true;
68
+ const part = chunk.subarray(0, remaining);
69
+ collected += part.length;
70
+ chunks[kind].push(part);
71
+ };
72
+ child.stdout.on('data', b => add('stdout', b));
73
+ child.stderr.on('data', b => add('stderr', b));
74
+ const timer = setTimeout(() => {
75
+ timedOut = true;
76
+ try {
77
+ if (process.platform === 'win32') spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
78
+ else process.kill(-child.pid, 'SIGKILL');
79
+ } catch { try { child.kill('SIGKILL'); } catch {} }
80
+ }, timeoutMs);
81
+ const finish = (code, message = '') => {
82
+ if (finished) return;
83
+ finished = true;
84
+ clearTimeout(timer);
85
+ resolveResult({ ok: code === 0 && !timedOut, code: Number.isInteger(code) ? code : null,
86
+ stdout: Buffer.concat(chunks.stdout).toString('utf8'),
87
+ stderr: Buffer.concat(chunks.stderr).toString('utf8') + message,
88
+ truncated, timedOut });
89
+ };
90
+ child.once('error', error => finish(null, `\n${error.message}`));
91
+ child.once('close', code => finish(code));
92
+ });
93
+ }
94
+
95
+ export async function main(args = process.argv.slice(2)) {
96
+ let options;
97
+ try { options = parseArgs(args); }
98
+ catch (error) { console.error(`Error: ${error.message}`); process.exitCode = 2; return; }
99
+ if (options.help) return help();
100
+ if (!stdin.isTTY || !stdout.isTTY) {
101
+ console.error('Error: se requiere una terminal interactiva para confirmar comandos.');
102
+ process.exitCode = 2; return;
103
+ }
104
+ console.log('\nZBack - acceso remoto a tu shell');
105
+ console.log('ADVERTENCIA: ejecutar comandos ajenos puede borrar archivos, modificar el sistema o comprometer información.');
106
+ console.log('Conéctate solo a tu propio servidor. Revisa cada comando antes de aprobarlo.');
107
+ console.log(`Servidor: ${options.url}`);
108
+ console.log(`Directorio: ${options.cwd}`);
109
+ const rl = createInterface({ input: stdin, output: stdout });
110
+ const consent = await rl.question('Escribe ACEPTO para habilitar solicitudes remotas: ');
111
+ if (consent.trim() !== 'ACEPTO') { console.log('Cancelado.'); rl.close(); return; }
112
+
113
+ let ws, active = false, stopped = false, retries = 0;
114
+ let queue = Promise.resolve();
115
+ const wait = ms => new Promise(resolveWait => setTimeout(resolveWait, ms));
116
+ const shutdown = () => { stopped = true; try { ws?.close(); } catch {} rl.close(); };
117
+ process.once('SIGINT', shutdown);
118
+ process.once('SIGTERM', shutdown);
119
+
120
+ while (!stopped) {
121
+ try {
122
+ ws = new WebSocket(options.url);
123
+ await new Promise((accept, reject) => {
124
+ ws.addEventListener('open', accept, { once: true });
125
+ ws.addEventListener('error', () => reject(new Error('Conexión fallida')), { once: true });
126
+ });
127
+ active = true;
128
+ retries = 0;
129
+ console.log('\nConectado. Esperando solicitudes...');
130
+ const closed = new Promise(resolveClosed => ws.addEventListener('close', resolveClosed, { once: true }));
131
+ ws.addEventListener('message', event => {
132
+ let message;
133
+ try { message = JSON.parse(event.data); } catch { return; }
134
+ if (message.type === 'welcome') {
135
+ console.log(`\nID temporal: ${message.id}`);
136
+ console.log('Comparte el ID solo con el cliente MCP autorizado. No se mostrará en el backend.');
137
+ }
138
+ if (message.type === 'command' && typeof message.requestId === 'string') {
139
+ queue = queue.then(async () => {
140
+ const command = message.command;
141
+ if (typeof command !== 'string' || !command.trim() || command.length > 1000 || command.includes('\0')) return;
142
+ console.log(`\nSolicitud de ejecución recibida:\n ${JSON.stringify(command)}`);
143
+ let approval;
144
+ try {
145
+ approval = await rl.question('¿Autorizar este comando? [s/N]: ', { signal: AbortSignal.timeout(Math.min(message.approvalTimeoutMs ?? 60000, 300000)) });
146
+ } catch { approval = ''; }
147
+ let result;
148
+ if (approval.trim().toLowerCase() !== 's') {
149
+ result = { ok: false, rejected: true, code: null, stdout: '', stderr: 'Rechazado por la persona conectada.' };
150
+ console.log('Comando rechazado.');
151
+ } else {
152
+ const timeout = Math.min(options.timeout, Number.isInteger(message.timeoutMs) ? message.timeoutMs : options.timeout);
153
+ console.log('Ejecutando...');
154
+ result = await execute(command, options.cwd, timeout, options.maxOutput);
155
+ console.log(result.ok ? 'Comando completado.' : 'Comando terminado con error o límite.');
156
+ }
157
+ if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'result', requestId: message.requestId, ...result }));
158
+ }).catch(error => console.error(`Error al procesar solicitud: ${error.message}`));
159
+ }
160
+ });
161
+ await closed;
162
+ console.log('\nDesconectado. El ID anterior ya no es válido.');
163
+ } catch (error) { console.error(`Conexión: ${error.message}`); }
164
+ active = false;
165
+ if (!options.reconnect || stopped) break;
166
+ const delay = Math.min(30000, 1000 * 2 ** Math.min(retries++, 5));
167
+ console.log(`Reconectando en ${Math.ceil(delay / 1000)} s...`);
168
+ await wait(delay);
169
+ }
170
+ shutdown();
171
+ }
172
+
173
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch(error => {
174
+ console.error(error.message); process.exitCode = 1;
175
+ });
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "zback",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Consent-based remote shell agent via WebSocket and MCP",
5
+ "type": "module",
6
+ "bin": { "zback": "./bin/zback.js" },
7
+ "engines": { "node": ">=22.0.0" },
8
+ "scripts": { "test": "node --test" },
9
+ "license": "MIT"
10
+ }
@@ -0,0 +1,28 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { parseArgs, execute } from '../bin/zback.js';
4
+
5
+ test('default secure endpoint', () => {
6
+ const args = parseArgs([]);
7
+ assert.equal(args.url, 'wss://zb.soymaycol.icu/ws');
8
+ assert.equal(args.reconnect, true);
9
+ });
10
+
11
+ test('unsafe remote ws URL blocked', () => {
12
+ assert.throws(() => parseArgs(['--url', 'ws://example.com/ws']), /wss/);
13
+ assert.equal(parseArgs(['--url', 'ws://127.0.0.1:8787/ws']).timeout, 30000);
14
+ assert.throws(() => parseArgs(['--timeout', '0']), /timeout/);
15
+ });
16
+
17
+ test('subprocess respects success and returned output', async () => {
18
+ const res = await execute('node -p "3*7"', process.cwd(), 5000, 1024);
19
+ assert.equal(res.ok, true);
20
+ assert.equal(res.stdout.trim(), '21');
21
+ });
22
+
23
+ test('subprocess output limit is enforced', async () => {
24
+ const res = await execute('node -e "process.stdout.write(\'a\'.repeat(2500))"', process.cwd(), 5000, 1024);
25
+ assert.equal(res.ok, true);
26
+ assert.equal(res.stdout.length, 1024);
27
+ assert.equal(res.truncated, true);
28
+ });