zback 1.3.1 → 1.4.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/README.md +5 -3
- package/bin/zback.js +41 -32
- package/package.json +1 -1
- package/src/agent.js +16 -9
- package/src/terminal.js +98 -0
package/README.md
CHANGED
|
@@ -7,11 +7,13 @@ ZBack is an opt-in remote administration agent for systems you own or are author
|
|
|
7
7
|
## Install and start
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
npm install -g zback@1.2.
|
|
10
|
+
npm install -g zback@1.2.2
|
|
11
11
|
zback
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
On the first run, ZBack
|
|
14
|
+
On the first run, ZBack presents a [Y]/[N] shell authorization question once. It stores that decision locally, then creates a private agent credential. There are no prompts for each command.
|
|
15
|
+
|
|
16
|
+
Terminal status markers are colored on supported interactive terminals: yellow `[+]` for progress, red `[×]` for errors and gray `>` for contextual hints. Plain text is used when output is redirected. The command history updates as commands complete and shows each command's exit code and cumulative successes/errors. Connection problems are summarized once per incident while reconnect attempts continue automatically.
|
|
15
17
|
|
|
16
18
|
The default server is `wss://zb.soymaycol.icu/ws`. To use your own backend:
|
|
17
19
|
|
|
@@ -31,7 +33,7 @@ The client requires **backend 1.2.0 or later**. The client rejects older servers
|
|
|
31
33
|
|
|
32
34
|
| Command | Behavior |
|
|
33
35
|
| --- | --- |
|
|
34
|
-
| `zback` | Keep the agent in the foreground; display the session ID, MCP endpoint and
|
|
36
|
+
| `zback` | Keep the agent in the foreground; display the session ID, MCP endpoint, colored connection notices, and command execution history |
|
|
35
37
|
| `zback --background` | Explicitly start a detached agent |
|
|
36
38
|
| `zback --status` | Show connection status, process ID and private session ID |
|
|
37
39
|
| `zback --logs` | Display recent connection and command activity |
|
package/bin/zback.js
CHANGED
|
@@ -6,10 +6,11 @@ import { createInterface } from 'node:readline/promises';
|
|
|
6
6
|
import { stdin, stdout } from 'node:process';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { connectAgent, execute } from '../src/agent.js';
|
|
9
|
+
import { createTerminal, createEventReporter } from '../src/terminal.js';
|
|
9
10
|
import { activeSession, agentKey, consentFile, hasConsent, initStateDir, logFile, readJson, resetKey, sameSession, saveJson, settingsFile, stateFile, alive } from '../src/runtime.js';
|
|
10
11
|
|
|
11
12
|
const ENTRY = fileURLToPath(import.meta.url);
|
|
12
|
-
const VERSION = '1.2.
|
|
13
|
+
const VERSION = '1.2.2';
|
|
13
14
|
const DEFAULT_URL = 'wss://zb.soymaycol.icu/ws';
|
|
14
15
|
|
|
15
16
|
function help() {
|
|
@@ -70,17 +71,17 @@ export function parseArgs(args) {
|
|
|
70
71
|
return { ...options, action };
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
async function confirmOnce() {
|
|
74
|
+
async function confirmOnce(terminal) {
|
|
74
75
|
if (hasConsent()) return true;
|
|
75
76
|
if (!stdin.isTTY || !stdout.isTTY) throw new Error('First-run shell authorization requires an interactive terminal');
|
|
76
|
-
|
|
77
|
-
console.log('Commands run with your user permissions. Keep the session ID private.');
|
|
77
|
+
terminal.question();
|
|
78
78
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
79
79
|
try {
|
|
80
|
-
const answer = await rl.question('
|
|
81
|
-
|
|
82
|
-
saveJson(consentFile, { approved: true, at: new Date().toISOString() });
|
|
83
|
-
|
|
80
|
+
const answer = await rl.question('Your choice [y/N]: ');
|
|
81
|
+
const approved = ['y', 'yes'].includes(answer.trim().toLowerCase());
|
|
82
|
+
if (approved) saveJson(consentFile, { approved: true, at: new Date().toISOString() });
|
|
83
|
+
terminal.consent(approved);
|
|
84
|
+
return approved;
|
|
84
85
|
} finally { rl.close(); }
|
|
85
86
|
}
|
|
86
87
|
|
|
@@ -91,14 +92,23 @@ async function worker() {
|
|
|
91
92
|
const started = process.env.ZBACK_WORKER_NONCE;
|
|
92
93
|
if (!started) throw new Error('Worker must be launched through zback');
|
|
93
94
|
const key = agentKey();
|
|
95
|
+
const terminal = createTerminal();
|
|
96
|
+
const reporter = createEventReporter(terminal);
|
|
97
|
+
let lastId = null;
|
|
94
98
|
const mark = data => {
|
|
95
99
|
const previous = readJson(stateFile);
|
|
96
100
|
if (previous && previous.status !== 'stopped' && previous.pid !== process.pid && alive(previous.pid)) return;
|
|
97
101
|
saveJson(stateFile, { pid: process.pid, started, version: VERSION, updated: new Date().toISOString(), ...data });
|
|
102
|
+
if (data.status === 'connected') {
|
|
103
|
+
reporter.clearProblem();
|
|
104
|
+
if (!lastId) terminal.connected(data.id, mcpEndpoint(settings.url));
|
|
105
|
+
else terminal.restored();
|
|
106
|
+
lastId = data.id;
|
|
107
|
+
}
|
|
98
108
|
};
|
|
99
109
|
mark({ status: 'connecting', id: null });
|
|
100
|
-
try { await connectAgent(settings, mark, key); }
|
|
101
|
-
finally { if (sameSession(process.pid, started)) mark({ status: 'stopped', id:
|
|
110
|
+
try { await connectAgent(settings, mark, key, reporter.event); }
|
|
111
|
+
finally { if (sameSession(process.pid, started)) mark({ status: 'stopped', id: lastId }); }
|
|
102
112
|
}
|
|
103
113
|
|
|
104
114
|
function mcpEndpoint(url) {
|
|
@@ -165,43 +175,43 @@ async function follow() {
|
|
|
165
175
|
}
|
|
166
176
|
|
|
167
177
|
async function start(options) {
|
|
178
|
+
const terminal = createTerminal();
|
|
168
179
|
const existing = activeSession();
|
|
169
180
|
if (existing) {
|
|
170
181
|
if (existing.version !== VERSION) {
|
|
171
|
-
|
|
182
|
+
terminal.error('An older ZBack agent is still running. Run zback --stop before starting this version.');
|
|
172
183
|
process.exitCode = 1;
|
|
173
184
|
return;
|
|
174
185
|
}
|
|
175
|
-
|
|
176
|
-
if (existing.id)
|
|
186
|
+
terminal.section('+', `Agent already running (${existing.status}, PID ${existing.pid}).`);
|
|
187
|
+
if (existing.id) terminal.connected(existing.id, mcpEndpoint(readJson(settingsFile)?.url || DEFAULT_URL));
|
|
177
188
|
if (!options.background) await follow();
|
|
178
189
|
return;
|
|
179
190
|
}
|
|
180
|
-
if (!(await confirmOnce()))
|
|
191
|
+
if (!(await confirmOnce(terminal))) return;
|
|
181
192
|
initStateDir();
|
|
182
193
|
const key = agentKey();
|
|
183
194
|
saveJson(settingsFile, { url: options.url, cwd: options.cwd, timeout: options.timeout, maxOutput: options.maxOutput, reconnect: options.reconnect });
|
|
195
|
+
terminal.connecting();
|
|
184
196
|
if (!options.background) {
|
|
185
197
|
const started = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
186
198
|
let lastId = null;
|
|
187
|
-
|
|
199
|
+
const reporter = createEventReporter(terminal);
|
|
188
200
|
const mark = data => {
|
|
189
201
|
saveJson(stateFile, { pid: process.pid, started, version: VERSION, updated: new Date().toISOString(), ...data });
|
|
190
202
|
if (data.status === 'connected') {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
} else if (previousStatus !== 'connected') console.log('Connection restored.');
|
|
197
|
-
} else if (data.status === 'reconnecting' && previousStatus !== 'reconnecting') console.error('Connection lost. Attempting to reconnect...');
|
|
198
|
-
else if (data.status === 'stopped' && previousStatus !== 'stopped' && lastId) console.log('Disconnected.');
|
|
199
|
-
previousStatus = data.status;
|
|
203
|
+
reporter.clearProblem();
|
|
204
|
+
if (!lastId) terminal.connected(data.id, mcpEndpoint(options.url));
|
|
205
|
+
else terminal.restored();
|
|
206
|
+
lastId = data.id;
|
|
207
|
+
}
|
|
200
208
|
};
|
|
201
|
-
console.log(`Connecting to ${options.url}...`);
|
|
202
209
|
mark({ status: 'connecting', id: null });
|
|
203
|
-
try { await connectAgent(options, mark, key); }
|
|
204
|
-
finally {
|
|
210
|
+
try { await connectAgent(options, mark, key, reporter.event); }
|
|
211
|
+
finally {
|
|
212
|
+
if (sameSession(process.pid, started)) saveJson(stateFile, { pid: process.pid, started, version: VERSION, status: 'stopped', id: lastId, updated: new Date().toISOString() });
|
|
213
|
+
terminal.stopped();
|
|
214
|
+
}
|
|
205
215
|
return;
|
|
206
216
|
}
|
|
207
217
|
const fd = openSync(logFile, 'a', 0o600);
|
|
@@ -221,14 +231,13 @@ async function start(options) {
|
|
|
221
231
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
222
232
|
const state = readJson(stateFile);
|
|
223
233
|
if (state?.pid === child.pid && state.started === started && state.status === 'connected' && state.id) {
|
|
224
|
-
|
|
234
|
+
terminal.connected(state.id, mcpEndpoint(options.url));
|
|
225
235
|
connected = true;
|
|
226
236
|
break;
|
|
227
237
|
}
|
|
228
|
-
if (!alive(child.pid) || (state?.pid === child.pid && state.status === 'stopped')) throw new Error(
|
|
238
|
+
if (!alive(child.pid) || (state?.pid === child.pid && state.status === 'stopped')) throw new Error('Agent exited unexpectedly. Inspect logs with zback --logs');
|
|
229
239
|
}
|
|
230
|
-
if (!connected)
|
|
231
|
-
if (!options.background) await follow();
|
|
240
|
+
if (!connected) terminal.hint(`Agent started (PID ${child.pid}). Run zback --status to inspect the connection.`);
|
|
232
241
|
}
|
|
233
242
|
|
|
234
243
|
function stop() {
|
|
@@ -256,7 +265,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
256
265
|
if (options.action === 'follow') { showStatus(); return follow(); }
|
|
257
266
|
if (options.action === 'reset-key') return rotate();
|
|
258
267
|
return await start(options);
|
|
259
|
-
} catch (error) {
|
|
268
|
+
} catch (error) { createTerminal().error(error.message); process.exitCode = 1; }
|
|
260
269
|
}
|
|
261
270
|
|
|
262
271
|
if (process.argv[1] && realpathSync(process.argv[1]) === ENTRY) main();
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -2,7 +2,6 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
|
|
3
3
|
const children = new Set();
|
|
4
4
|
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
5
|
-
const log = message => console.log(`[${new Date().toISOString()}] ${message}`);
|
|
6
5
|
|
|
7
6
|
function killTree(child) {
|
|
8
7
|
if (!child?.pid) return;
|
|
@@ -54,7 +53,7 @@ export function execute(command, cwd, timeoutMs, maxOutput) {
|
|
|
54
53
|
});
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
export async function connectAgent(options, onState, token) {
|
|
56
|
+
export async function connectAgent(options, onState, token, onEvent = () => {}) {
|
|
58
57
|
let stopped = false;
|
|
59
58
|
let retries = 0;
|
|
60
59
|
let socket;
|
|
@@ -62,6 +61,7 @@ export async function connectAgent(options, onState, token) {
|
|
|
62
61
|
let lastId = null;
|
|
63
62
|
let lastError = null;
|
|
64
63
|
const update = (status, data = {}) => onState({ status, ...data });
|
|
64
|
+
const emit = event => { try { onEvent(event); } catch { /* UI failures must not interrupt command execution. */ } };
|
|
65
65
|
const shutdown = () => {
|
|
66
66
|
stopped = true;
|
|
67
67
|
if (cancelDelay) cancelDelay();
|
|
@@ -92,7 +92,7 @@ export async function connectAgent(options, onState, token) {
|
|
|
92
92
|
const messageListener = event => {
|
|
93
93
|
let message;
|
|
94
94
|
try { message = JSON.parse(event.data); }
|
|
95
|
-
catch {
|
|
95
|
+
catch { emit({ type: 'protocol-warning', problem: 'Received an invalid server message' }); return; }
|
|
96
96
|
if (message.type === 'welcome') {
|
|
97
97
|
if (welcomed) return;
|
|
98
98
|
if (message.protocolVersion !== 2 || typeof message.id !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(message.id)) {
|
|
@@ -103,6 +103,14 @@ export async function connectAgent(options, onState, token) {
|
|
|
103
103
|
reject(mismatch);
|
|
104
104
|
return;
|
|
105
105
|
}
|
|
106
|
+
if (lastId && message.id !== lastId) {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
current.close(1000, 'Session identifier changed');
|
|
109
|
+
const mismatch = new Error('The server returned a different session identifier after reconnecting');
|
|
110
|
+
mismatch.code = 'SESSION_MISMATCH';
|
|
111
|
+
reject(mismatch);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
106
114
|
welcomed = true;
|
|
107
115
|
clearTimeout(timer);
|
|
108
116
|
retries = 0;
|
|
@@ -117,23 +125,22 @@ export async function connectAgent(options, onState, token) {
|
|
|
117
125
|
const command = message.command;
|
|
118
126
|
if (typeof command !== 'string' || !command.trim() || command.length > 4096 || command.includes('\0')) return;
|
|
119
127
|
const timeout = Math.min(options.timeout, Number.isInteger(message.timeoutMs) && message.timeoutMs > 0 ? message.timeoutMs : options.timeout);
|
|
120
|
-
log(`Running authorized request ${message.requestId}`);
|
|
121
128
|
const result = await execute(command, options.cwd, timeout, options.maxOutput);
|
|
122
|
-
|
|
129
|
+
emit({ type: 'command-result', command, result });
|
|
123
130
|
if (current.readyState === WebSocket.OPEN && !stopped) current.send(JSON.stringify({ type: 'result', requestId: message.requestId, ...result }));
|
|
124
|
-
}).catch(error =>
|
|
131
|
+
}).catch(error => emit({ type: 'agent-error', problem: `Request processing failed: ${error.message}` }));
|
|
125
132
|
};
|
|
126
133
|
current.addEventListener('message', messageListener);
|
|
127
134
|
current.addEventListener('close', event => {
|
|
128
135
|
clearTimeout(timer);
|
|
129
136
|
if (!welcomed) reject(new Error(`Backend rejected connection (WebSocket ${event.code}: ${event.reason || 'no reason'})`));
|
|
130
|
-
else { if (!stopped)
|
|
137
|
+
else { if (!stopped) emit({ type: 'connection-lost', problem: 'Connection lost' }); resolve(); }
|
|
131
138
|
}, { once: true });
|
|
132
139
|
current.addEventListener('error', () => { if (!welcomed) { clearTimeout(timer); reject(new Error('WebSocket connection failed')); } }, { once: true });
|
|
133
140
|
});
|
|
134
141
|
} catch (error) {
|
|
135
|
-
if (!stopped && error.code
|
|
136
|
-
if (!stopped && error.message !== lastError) {
|
|
142
|
+
if (!stopped && ['INCOMPATIBLE_BACKEND', 'SESSION_MISMATCH'].includes(error.code)) throw error;
|
|
143
|
+
if (!stopped && error.message !== lastError) { emit({ type: 'connection-error', problem: error.message }); lastError = error.message; }
|
|
137
144
|
}
|
|
138
145
|
killRunningCommands();
|
|
139
146
|
if (stopped || !options.reconnect) break;
|
package/src/terminal.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const ANSI = Object.freeze({ yellow: '\x1b[33m', red: '\x1b[31m', gray: '\x1b[90m', white: '\x1b[37m', reset: '\x1b[0m' });
|
|
2
|
+
|
|
3
|
+
// Never allow server-supplied text to inject terminal control sequences.
|
|
4
|
+
export function terminalText(value, maxLength = 240) {
|
|
5
|
+
const text = String(value ?? 'unknown')
|
|
6
|
+
.replace(/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)?)/g, '')
|
|
7
|
+
.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ')
|
|
8
|
+
.replace(/\s+/g, ' ')
|
|
9
|
+
.trim();
|
|
10
|
+
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createTerminal({ output = process.stdout, color = Boolean(output.isTTY && !process.env.NO_COLOR) } = {}) {
|
|
14
|
+
const tint = (s, name) => color ? `${ANSI[name]}${s}${ANSI.reset}` : s;
|
|
15
|
+
const write = line => output.write(`${line}\n`);
|
|
16
|
+
const section = (symbol, text, tone = 'yellow') => write(`${tint(`[${symbol}]`, tone)} ${tint(text, 'white')}`);
|
|
17
|
+
const detail = line => write(`${tint(' -', 'gray')} ${tint(line, 'white')}`);
|
|
18
|
+
const hint = line => write(`${tint('>', 'gray')} ${tint(line, 'gray')}`);
|
|
19
|
+
const gap = () => write('');
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
section, detail, hint, gap,
|
|
23
|
+
question() {
|
|
24
|
+
section('?', 'Shell access authorization');
|
|
25
|
+
write(tint('Do you allow ZBack to provide remote shell access to holders of your private session ID?', 'white'));
|
|
26
|
+
write(`${tint('[Y]', 'yellow')} ${tint('Yes, I accept', 'white')} ${tint('[N]', 'yellow')} ${tint('No, I do not accept', 'white')}`);
|
|
27
|
+
hint('Your decision is saved. Individual commands will not trigger additional prompts.');
|
|
28
|
+
},
|
|
29
|
+
consent(approved) {
|
|
30
|
+
hint(approved ? 'Selected: Yes. Remote shell access is authorized.' : 'Selected: No. Remote shell access remains disabled.');
|
|
31
|
+
gap();
|
|
32
|
+
},
|
|
33
|
+
connecting() {
|
|
34
|
+
section('+', 'Connecting to the server, hang on a minute.');
|
|
35
|
+
hint('This may take a moment depending on your internet connection.');
|
|
36
|
+
gap();
|
|
37
|
+
},
|
|
38
|
+
connected(id, endpoint) {
|
|
39
|
+
section('+', 'Setting up our environment');
|
|
40
|
+
detail(`Session Identifier: "${terminalText(id, 90)}"`);
|
|
41
|
+
detail(`MCP Server: "${terminalText(endpoint, 512)}"`);
|
|
42
|
+
detail('MCP Tools: "zback_status", "zback_execute"');
|
|
43
|
+
hint('Enjoy being able to have a model manage your environment. Keep your ID private.');
|
|
44
|
+
gap();
|
|
45
|
+
},
|
|
46
|
+
restored() {
|
|
47
|
+
section('+', 'Connection restored.');
|
|
48
|
+
hint('Your session identifier has not changed.');
|
|
49
|
+
gap();
|
|
50
|
+
},
|
|
51
|
+
error(problem, retrying = false) {
|
|
52
|
+
section('×', 'Oops, an error has occurred.', 'red');
|
|
53
|
+
detail(`Problem: "${terminalText(problem, 240)}"`);
|
|
54
|
+
hint(retrying ? 'This error is being handled. Reconnecting automatically, please wait.' : 'The connection cannot continue. Check the problem above.');
|
|
55
|
+
gap();
|
|
56
|
+
},
|
|
57
|
+
stopped() {
|
|
58
|
+
section('+', 'Disconnected.');
|
|
59
|
+
},
|
|
60
|
+
command(result, totals) {
|
|
61
|
+
if (totals.count === 1) section('+', 'History of executed commands');
|
|
62
|
+
const code = Number.isInteger(result.code) ? result.code : result.timedOut ? 'timeout' : 'unknown';
|
|
63
|
+
detail(`${JSON.stringify(terminalText(result.command))} was executed with exit code ${code}.`);
|
|
64
|
+
const countWord = totals.count === 1 ? 'command was' : 'commands were';
|
|
65
|
+
const errorWord = totals.errors === 1 ? 'an error' : 'errors';
|
|
66
|
+
hint(`Currently, ${totals.count} ${countWord} executed, ${totals.errors} with ${errorWord} and ${totals.successes} successfully.`);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createCommandTracker(terminal) {
|
|
72
|
+
const totals = { count: 0, successes: 0, errors: 0 };
|
|
73
|
+
return event => {
|
|
74
|
+
if (event.type !== 'command-result') return;
|
|
75
|
+
totals.count++;
|
|
76
|
+
if (event.result.ok) totals.successes++;
|
|
77
|
+
else totals.errors++;
|
|
78
|
+
terminal.command({ command: event.command, ...event.result }, totals);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createEventReporter(terminal) {
|
|
83
|
+
const reportCommand = createCommandTracker(terminal);
|
|
84
|
+
let activeProblem = null;
|
|
85
|
+
return {
|
|
86
|
+
clearProblem() { activeProblem = null; },
|
|
87
|
+
event(event) {
|
|
88
|
+
if (event.type === 'command-result') return reportCommand(event);
|
|
89
|
+
if (event.type === 'connection-lost' || event.type === 'connection-error') {
|
|
90
|
+
// A dropped connection may lead to many retries; report the incident once.
|
|
91
|
+
if (activeProblem !== null) return;
|
|
92
|
+
activeProblem = event.problem;
|
|
93
|
+
return terminal.error(event.problem, true);
|
|
94
|
+
}
|
|
95
|
+
if (event.type === 'agent-error' || event.type === 'protocol-warning') terminal.error(event.problem, false);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|