zback 1.0.0 → 1.1.1
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 +25 -16
- package/bin/zback.js +115 -139
- package/package.json +11 -5
- package/src/agent.js +98 -0
- package/src/runtime.js +39 -0
package/README.md
CHANGED
|
@@ -1,31 +1,40 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ZBack CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Global command for Node.js 22+. No npm dependencies, login, API key, or password.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Install
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npm install -g .
|
|
9
|
-
zback --help
|
|
10
9
|
zback
|
|
11
10
|
```
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
The first time, ZBack asks exactly once for permission to access **your own shell**. Answer `y` to run it as a detached background process. When connected, it prints:
|
|
14
13
|
|
|
15
|
-
|
|
14
|
+
```
|
|
15
|
+
Okay, here's your session ID "..."
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Closing the terminal does not normally stop the agent. Later calls to `zback` show its current session ID without requesting permission again. To view the connection state or terminate the worker:
|
|
16
19
|
|
|
17
20
|
```bash
|
|
18
|
-
zback --
|
|
19
|
-
zback --
|
|
20
|
-
zback --no-reconnect
|
|
21
|
+
zback --status
|
|
22
|
+
zback --stop
|
|
21
23
|
```
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
Advanced options at first launch (or after stopping it):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
zback --url wss://zb.soymaycol.icu/ws --cwd /path/to/project
|
|
29
|
+
zback --timeout 120000 --max-output 131072
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Local development: `zback --url ws://127.0.0.1:8787/ws`.
|
|
33
|
+
|
|
34
|
+
The agent automatically reconnects, potentially receiving a **new session ID**; check `zback --status` if the previous ID stops working. There are no per-command approvals after initial permission. The session ID grants anyone who possesses it remote shell access for the lifetime of that session; **keep it private**. TLS (`wss://`) is required remotely.
|
|
35
|
+
|
|
36
|
+
Status, consent, and log files are stored in the OS user state directory (`~/.local/state/zback` on most Linux systems or `%LOCALAPPDATA%/zback` on Windows). They are local and not published by the backend. Optionally set `ZBACK_STATE_DIR` to change this location.
|
|
24
37
|
|
|
25
|
-
-
|
|
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.
|
|
38
|
+
Closing a terminal normally leaves the detached worker running. Force-stopping an Android app, battery restrictions, shutdown or network loss may still terminate or interrupt it; this CLI does not auto-start itself on device boot.
|
|
30
39
|
|
|
31
|
-
|
|
40
|
+
Test with `npm test`.
|
package/bin/zback.js
CHANGED
|
@@ -1,175 +1,151 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import {
|
|
3
|
+
import { openSync } from 'node:fs';
|
|
4
4
|
import { resolve } from 'node:path';
|
|
5
5
|
import { createInterface } from 'node:readline/promises';
|
|
6
6
|
import { stdin, stdout } from 'node:process';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { existsSync, statSync } from 'node:fs';
|
|
9
|
+
import { connectAgent, execute } from '../src/agent.js';
|
|
10
|
+
import { activeSession, alive, consentFile, hasConsent, initStateDir, logFile, readJson, sameSession, saveJson, settingsFile, stateFile } from '../src/runtime.js';
|
|
8
11
|
|
|
12
|
+
const ENTRY = fileURLToPath(import.meta.url);
|
|
9
13
|
const DEFAULT_URL = 'wss://zb.soymaycol.icu/ws';
|
|
10
14
|
|
|
11
15
|
function help() {
|
|
12
|
-
console.log(`ZBack 1.
|
|
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.`);
|
|
16
|
+
console.log(`ZBack 1.1.0\n\nUsage: zback [options]\n\n --url <wss://.../ws> WebSocket endpoint (default: ${DEFAULT_URL})\n --cwd <directory> Directory for shell commands\n --timeout <ms> Command timeout (1000-300000)\n --max-output <bytes> Maximum command output (1024-262144)\n --no-reconnect Disable automatic reconnection\n --status Show background agent status and session ID\n --stop Stop background agent\n --help Display help\n\nThe first launch asks for shell access permission only once.\nThe agent runs in the background until stopped.`);
|
|
25
17
|
}
|
|
26
18
|
|
|
27
19
|
export function parseArgs(args) {
|
|
28
20
|
const options = { url: DEFAULT_URL, cwd: process.cwd(), timeout: 30000, maxOutput: 65536, reconnect: true };
|
|
29
|
-
const
|
|
21
|
+
const values = new Set(['--url', '--cwd', '--timeout', '--max-output']);
|
|
22
|
+
let action = 'start';
|
|
30
23
|
for (let i = 0; i < args.length; i++) {
|
|
31
24
|
const flag = args[i];
|
|
32
25
|
if (flag === '--help' || flag === '-h') return { help: true };
|
|
26
|
+
if (flag === '--worker') return { worker: true };
|
|
27
|
+
if (flag === '--status' || flag === '--stop') {
|
|
28
|
+
if (action !== 'start') throw new Error('Specify one action');
|
|
29
|
+
action = flag.slice(2);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
33
32
|
if (flag === '--no-reconnect') { options.reconnect = false; continue; }
|
|
34
|
-
if (!
|
|
33
|
+
if (!values.has(flag) || i + 1 >= args.length) throw new Error(`Unknown or incomplete argument: ${flag}`);
|
|
35
34
|
const value = args[++i];
|
|
36
35
|
if (flag === '--url') options.url = value;
|
|
37
36
|
if (flag === '--cwd') options.cwd = resolve(value);
|
|
38
37
|
if (flag === '--timeout') options.timeout = Number(value);
|
|
39
38
|
if (flag === '--max-output') options.maxOutput = Number(value);
|
|
40
39
|
}
|
|
41
|
-
if (
|
|
42
|
-
if (!Number.isSafeInteger(options.
|
|
43
|
-
if (!
|
|
40
|
+
if (action !== 'start') return { action };
|
|
41
|
+
if (!Number.isSafeInteger(options.timeout) || options.timeout < 1000 || options.timeout > 300000) throw new Error('--timeout must be between 1000 and 300000 ms');
|
|
42
|
+
if (!Number.isSafeInteger(options.maxOutput) || options.maxOutput < 1024 || options.maxOutput > 262144) throw new Error('--max-output must be between 1024 and 262144 bytes');
|
|
43
|
+
if (!existsSync(options.cwd) || !statSync(options.cwd).isDirectory()) throw new Error('--cwd must be an existing directory');
|
|
44
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
|
-
|
|
45
|
+
if (url.protocol !== 'wss:' && !(url.protocol === 'ws:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))) throw new Error('Use wss:// for remote connections or ws:// on localhost');
|
|
46
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== '/ws') throw new Error('Expected a WebSocket URL ending in /ws');
|
|
47
|
+
return { ...options, action };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function confirmOnce() {
|
|
51
|
+
if (hasConsent()) return true;
|
|
52
|
+
if (!stdin.isTTY || !stdout.isTTY) throw new Error('An interactive terminal is required for initial permission.');
|
|
53
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
54
|
+
try {
|
|
55
|
+
const answer = await rl.question('This module accesses your shell. Do you allow it to use your shell? Permission will not be requested again. [y/N] ');
|
|
56
|
+
if (!['y', 'yes'].includes(answer.trim().toLowerCase())) return false;
|
|
57
|
+
saveJson(consentFile, { approved: true, at: new Date().toISOString() });
|
|
58
|
+
return true;
|
|
59
|
+
} finally { rl.close(); }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function worker() {
|
|
63
|
+
if (!hasConsent()) throw new Error('Permission has not been granted. Run zback interactively first.');
|
|
64
|
+
const settings = readJson(settingsFile);
|
|
65
|
+
if (!settings || !settings.url || !settings.cwd) throw new Error('Missing agent settings');
|
|
66
|
+
const started = process.env.ZBACK_WORKER_NONCE;
|
|
67
|
+
if (!started) throw new Error('Not a valid ZBack background worker');
|
|
68
|
+
const mark = data => {
|
|
69
|
+
const current = readJson(stateFile);
|
|
70
|
+
if (current && current.status !== 'stopped' && current.pid !== process.pid && alive(current.pid)) return;
|
|
71
|
+
saveJson(stateFile, { pid: process.pid, started, updated: new Date().toISOString(), ...data });
|
|
72
|
+
};
|
|
73
|
+
mark({ status: 'connecting', id: null });
|
|
74
|
+
const hardStop = () => {
|
|
75
|
+
if (sameSession(process.pid, started)) mark({ status: 'stopped', id: null });
|
|
76
|
+
setTimeout(() => process.exit(0), 150).unref();
|
|
77
|
+
};
|
|
78
|
+
process.once('SIGTERM', hardStop);
|
|
79
|
+
process.once('SIGINT', hardStop);
|
|
80
|
+
try { await connectAgent(settings, mark); }
|
|
81
|
+
finally {
|
|
82
|
+
if (sameSession(process.pid, started)) mark({ status: 'stopped', id: null });
|
|
47
83
|
}
|
|
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
84
|
}
|
|
51
85
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
86
|
+
async function start(options) {
|
|
87
|
+
const running = activeSession();
|
|
88
|
+
if (running) {
|
|
89
|
+
console.log(running.id ? `Okay, here's your session ID "${running.id}"` : `ZBack is ${running.status}.`);
|
|
90
|
+
console.log('Use zback --status to inspect the agent or zback --stop to stop it.');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (!(await confirmOnce())) { console.log('Permission declined.'); return; }
|
|
94
|
+
initStateDir();
|
|
95
|
+
saveJson(settingsFile, { url: options.url, cwd: options.cwd, timeout: options.timeout, maxOutput: options.maxOutput, reconnect: options.reconnect });
|
|
96
|
+
const fd = openSync(logFile, 'a', 0o600);
|
|
97
|
+
const started = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
98
|
+
let child;
|
|
99
|
+
try {
|
|
100
|
+
child = spawn(process.execPath, [ENTRY, '--worker'], {
|
|
101
|
+
detached: true, windowsHide: true,
|
|
102
|
+
stdio: ['ignore', fd, fd],
|
|
103
|
+
env: { ...process.env, ZBACK_WORKER_NONCE: started }
|
|
104
|
+
});
|
|
105
|
+
child.unref();
|
|
106
|
+
} finally {
|
|
107
|
+
const { closeSync } = await import('node:fs');
|
|
108
|
+
closeSync(fd);
|
|
109
|
+
}
|
|
110
|
+
if (!child?.pid) throw new Error('Could not launch background process');
|
|
111
|
+
for (let i = 0; i < 80; i++) {
|
|
112
|
+
await new Promise(r => setTimeout(r, 100));
|
|
113
|
+
const state = readJson(stateFile);
|
|
114
|
+
if (state?.pid === child.pid && state.started === started && state.id && state.status === 'connected') {
|
|
115
|
+
console.log(`Okay, here's your session ID "${state.id}"`);
|
|
62
116
|
return;
|
|
63
117
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
});
|
|
118
|
+
if (!alive(child.pid) || (state?.pid === child.pid && state.status === 'stopped')) {
|
|
119
|
+
throw new Error(`Background process exited. See ${logFile}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
console.log(`Background agent started (PID ${child.pid}); waiting for the server.`);
|
|
123
|
+
console.log('Use zback --status to see the session ID once connected.');
|
|
93
124
|
}
|
|
94
125
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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; }
|
|
126
|
+
function status() {
|
|
127
|
+
const state = activeSession();
|
|
128
|
+
if (!state) return console.log('ZBack is stopped.');
|
|
129
|
+
console.log(`Status: ${state.status}\nPID: ${state.pid}${state.id ? `\nSession ID: ${state.id}` : ''}`);
|
|
130
|
+
}
|
|
112
131
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
132
|
+
function stop() {
|
|
133
|
+
const state = activeSession();
|
|
134
|
+
if (!state) return console.log('ZBack is not running.');
|
|
135
|
+
process.kill(state.pid, 'SIGTERM');
|
|
136
|
+
console.log('ZBack stop requested.');
|
|
137
|
+
}
|
|
119
138
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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();
|
|
139
|
+
export { execute };
|
|
140
|
+
export async function main(args = process.argv.slice(2)) {
|
|
141
|
+
try {
|
|
142
|
+
const options = parseArgs(args);
|
|
143
|
+
if (options.help) return help();
|
|
144
|
+
if (options.worker) return worker();
|
|
145
|
+
if (options.action === 'status') return status();
|
|
146
|
+
if (options.action === 'stop') return stop();
|
|
147
|
+
return await start(options);
|
|
148
|
+
} catch (error) { console.error(`Error: ${error.message}`); process.exitCode = 1; }
|
|
171
149
|
}
|
|
172
150
|
|
|
173
|
-
if (process.argv[1] &&
|
|
174
|
-
console.error(error.message); process.exitCode = 1;
|
|
175
|
-
});
|
|
151
|
+
if (process.argv[1] && resolve(process.argv[1]) === ENTRY) main();
|
package/package.json
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zback",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"description": "Background WebSocket shell agent with one-time local consent",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
"bin": {
|
|
7
|
+
"zback": "bin/zback.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=22.0.0"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node --test"
|
|
14
|
+
},
|
|
9
15
|
"license": "MIT"
|
|
10
16
|
}
|
package/src/agent.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const children = new Set();
|
|
4
|
+
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
5
|
+
|
|
6
|
+
function killTree(child) {
|
|
7
|
+
if (!child?.pid) return;
|
|
8
|
+
try {
|
|
9
|
+
if (process.platform === 'win32') {
|
|
10
|
+
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
11
|
+
killer.unref();
|
|
12
|
+
} else process.kill(-child.pid, 'SIGKILL');
|
|
13
|
+
} catch { try { child.kill('SIGKILL'); } catch {} }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function killRunningCommands() { for (const child of children) killTree(child); }
|
|
17
|
+
|
|
18
|
+
export function execute(command, cwd, timeoutMs, maxOutput) {
|
|
19
|
+
return new Promise(resolveResult => {
|
|
20
|
+
let finished = false, timedOut = false, truncated = false;
|
|
21
|
+
const chunks = { stdout: [], stderr: [] };
|
|
22
|
+
let collected = 0;
|
|
23
|
+
let child;
|
|
24
|
+
try {
|
|
25
|
+
child = spawn(command, { cwd, shell: true, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
26
|
+
children.add(child);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
resolveResult({ ok: false, code: null, stdout: '', stderr: String(err.message) });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const add = (kind, chunk) => {
|
|
32
|
+
const remaining = maxOutput - collected;
|
|
33
|
+
if (remaining <= 0) { truncated = true; return; }
|
|
34
|
+
if (chunk.length > remaining) truncated = true;
|
|
35
|
+
const part = chunk.subarray(0, remaining);
|
|
36
|
+
collected += part.length;
|
|
37
|
+
chunks[kind].push(part);
|
|
38
|
+
};
|
|
39
|
+
child.stdout.on('data', b => add('stdout', b));
|
|
40
|
+
child.stderr.on('data', b => add('stderr', b));
|
|
41
|
+
const timer = setTimeout(() => { timedOut = true; killTree(child); }, timeoutMs);
|
|
42
|
+
const finish = (code, message = '') => {
|
|
43
|
+
if (finished) return;
|
|
44
|
+
finished = true;
|
|
45
|
+
children.delete(child);
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
resolveResult({ ok: code === 0 && !timedOut, code: Number.isInteger(code) ? code : null,
|
|
48
|
+
stdout: Buffer.concat(chunks.stdout).toString('utf8'),
|
|
49
|
+
stderr: Buffer.concat(chunks.stderr).toString('utf8') + message,
|
|
50
|
+
truncated, timedOut });
|
|
51
|
+
};
|
|
52
|
+
child.once('error', error => finish(null, `\n${error.message}`));
|
|
53
|
+
child.once('close', code => finish(code));
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function connectAgent(options, onState) {
|
|
58
|
+
let stopped = false, retries = 0, socket;
|
|
59
|
+
const shutdown = () => { stopped = true; try { socket?.close(); } catch {} killRunningCommands(); };
|
|
60
|
+
process.once('SIGINT', shutdown);
|
|
61
|
+
process.once('SIGTERM', shutdown);
|
|
62
|
+
while (!stopped) {
|
|
63
|
+
try {
|
|
64
|
+
socket = new WebSocket(options.url);
|
|
65
|
+
await new Promise((resolve, reject) => {
|
|
66
|
+
socket.addEventListener('open', resolve, { once: true });
|
|
67
|
+
socket.addEventListener('error', () => reject(new Error('Connection failed')), { once: true });
|
|
68
|
+
});
|
|
69
|
+
if (stopped) break;
|
|
70
|
+
retries = 0;
|
|
71
|
+
onState({ status: 'connected', id: null });
|
|
72
|
+
let queue = Promise.resolve();
|
|
73
|
+
socket.addEventListener('message', event => {
|
|
74
|
+
let message;
|
|
75
|
+
try { message = JSON.parse(event.data); } catch { return; }
|
|
76
|
+
if (message.type === 'welcome' && typeof message.id === 'string') {
|
|
77
|
+
onState({ status: 'connected', id: message.id });
|
|
78
|
+
console.log(`[zback] Connected; session ${message.id}`);
|
|
79
|
+
}
|
|
80
|
+
if (message.type === 'command' && typeof message.requestId === 'string') {
|
|
81
|
+
queue = queue.then(async () => {
|
|
82
|
+
const command = message.command;
|
|
83
|
+
if (typeof command !== 'string' || !command.trim() || command.length > 1000 || command.includes('\0')) return;
|
|
84
|
+
const timeout = Math.min(options.timeout, Number.isInteger(message.timeoutMs) && message.timeoutMs > 0 ? message.timeoutMs : options.timeout);
|
|
85
|
+
console.log(`[zback] Executing request ${message.requestId}`);
|
|
86
|
+
const result = await execute(command, options.cwd, timeout, options.maxOutput);
|
|
87
|
+
if (socket.readyState === WebSocket.OPEN && !stopped) socket.send(JSON.stringify({ type: 'result', requestId: message.requestId, ...result }));
|
|
88
|
+
}).catch(error => console.error(`[zback] Request failed: ${error.message}`));
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
await new Promise(resolve => socket.addEventListener('close', resolve, { once: true }));
|
|
92
|
+
} catch (error) { if (!stopped) console.error(`[zback] ${error.message}`); }
|
|
93
|
+
if (stopped || !options.reconnect) break;
|
|
94
|
+
onState({ status: 'reconnecting', id: null });
|
|
95
|
+
await wait(Math.min(30000, 1000 * 2 ** Math.min(retries++, 5)));
|
|
96
|
+
}
|
|
97
|
+
onState({ status: 'stopped', id: null });
|
|
98
|
+
}
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const stateDir = process.env.ZBACK_STATE_DIR || join(process.env.XDG_STATE_HOME || (process.platform === 'win32' ? process.env.LOCALAPPDATA || homedir() : join(homedir(), '.local', 'state')), 'zback');
|
|
6
|
+
export const consentFile = join(stateDir, 'consent.json');
|
|
7
|
+
export const stateFile = join(stateDir, 'session.json');
|
|
8
|
+
export const settingsFile = join(stateDir, 'settings.json');
|
|
9
|
+
export const logFile = join(stateDir, 'zback.log');
|
|
10
|
+
|
|
11
|
+
export function initStateDir() { mkdirSync(stateDir, { recursive: true, mode: 0o700 }); }
|
|
12
|
+
export function readJson(file) { try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; } }
|
|
13
|
+
export function saveJson(file, data) {
|
|
14
|
+
initStateDir();
|
|
15
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
16
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
17
|
+
renameSync(tmp, file);
|
|
18
|
+
}
|
|
19
|
+
export function hasConsent() { return readJson(consentFile)?.approved === true; }
|
|
20
|
+
export function alive(pid) {
|
|
21
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
22
|
+
try {
|
|
23
|
+
process.kill(pid, 0);
|
|
24
|
+
if (process.platform === 'linux') {
|
|
25
|
+
try { if (readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1]?.[0] === 'Z') return false; } catch {}
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch (e) { return e.code === 'EPERM'; }
|
|
30
|
+
}
|
|
31
|
+
export function activeSession() {
|
|
32
|
+
const data = readJson(stateFile);
|
|
33
|
+
return data && data.status !== 'stopped' && alive(data.pid) ? data : null;
|
|
34
|
+
}
|
|
35
|
+
export function sameSession(pid, started) {
|
|
36
|
+
const data = readJson(stateFile);
|
|
37
|
+
return data?.pid === pid && data?.started === started;
|
|
38
|
+
}
|
|
39
|
+
export function hasState() { return existsSync(stateFile); }
|