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 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.1
10
+ npm install -g zback@1.2.2
11
11
  zback
12
12
  ```
13
13
 
14
- On the first run, ZBack explains the remote-shell capability and asks for authorization once. It stores that decision locally, then creates a private agent credential. There are no prompts for each command.
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 live activity |
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.1';
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
- console.log('This module grants remote shell access to anyone holding your private session ID.');
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('Do you allow remote access to your shell? This is asked only once. [y/N] ');
81
- if (!['y', 'yes'].includes(answer.trim().toLowerCase())) return false;
82
- saveJson(consentFile, { approved: true, at: new Date().toISOString() });
83
- return true;
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: null }); }
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
- console.error('An older ZBack agent is still running. Run zback --stop, then zback to start this version.');
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
- console.log(`Agent already running (${existing.status}, PID ${existing.pid}).`);
176
- if (existing.id) showConnection(existing.id, readJson(settingsFile)?.url || DEFAULT_URL);
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())) { console.log('Authorization declined'); return; }
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
- let previousStatus = null;
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
- if (data.id !== lastId) {
192
- console.log('Connected.');
193
- showConnection(data.id, options.url);
194
- console.log('Monitoring requests and connection errors. Press Ctrl+C to disconnect.');
195
- lastId = data.id;
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 { if (sameSession(process.pid, started)) saveJson(stateFile, { pid: process.pid, started, version: VERSION, status: 'stopped', id: lastId, updated: new Date().toISOString() }); }
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
- showConnection(state.id, options.url);
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(`Agent exited unexpectedly. Inspect logs with zback --logs`);
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) console.log(`Agent started (PID ${child.pid}). Waiting for backend; run zback --status to inspect.`);
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) { console.error(`ZBack error: ${error.message}`); process.exitCode = 1; }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zback",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Opt-in foreground remote shell administration with a persistent WebSocket agent and private session credential",
5
5
  "type": "module",
6
6
  "bin": {
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 { log('Ignored invalid server message'); return; }
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
- log(`Request ${message.requestId}: ${result.timedOut ? 'timed out' : result.ok ? 'completed' : 'failed'}`);
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 => log(`Request processing error: ${error.message}`));
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) log(`Connection closed (${event.code}: ${event.reason || 'connection lost'})`); resolve(); }
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 === 'INCOMPATIBLE_BACKEND') throw error;
136
- if (!stopped && error.message !== lastError) { log(`Connection error: ${error.message}`); lastError = error.message; }
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;
@@ -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
+ }