zback 1.3.0 → 1.3.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/DISCLOSURE CHANGED
@@ -2,7 +2,7 @@ ZBack is an opt-in remote shell administration tool for devices owned or explici
2
2
 
3
3
  Security-sensitive functionality:
4
4
  - A user manually starts the ZBack command and approves remote shell administration on the first run. The decision is stored in a local user-scoped file; subsequent commands do not require repeated prompts.
5
- - A background Node.js worker may persist after the invoking terminal detaches. It can be stopped with `zback --stop`.
5
+ - The agent runs in the foreground by default and stops on Ctrl+C. An explicitly requested background worker may persist after the terminal detaches and can be stopped with `zback --stop`.
6
6
  - Remote shell commands execute with the operating-system permissions of the account that launched the agent.
7
7
  - The worker connects outbound to the configured secure WebSocket endpoint. It does not open an inbound shell listener.
8
8
  - The server exposes an MCP endpoint that routes authorized commands to connected workers when the caller presents the private session capability.
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  # ZBack CLI
2
2
 
3
- ZBack is a deliberately opt-in remote administration agent for systems you own or are authorized to manage. The package uses Node.js 22 or later, has **no third-party runtime dependencies** and runs no installation scripts.
3
+ ZBack is an opt-in remote administration agent for systems you own or are authorized to manage. The package uses Node.js 22 or later, has **no third-party runtime dependencies** and runs no installation scripts.
4
4
 
5
5
  > **Security:** ZBack grants remote shell access with your local user privileges to anyone who possesses its private session ID. Treat that ID as a password. Do not publish it, paste it in issue reports, or share screenshots containing it. Do not run ZBack as root or as an account with unnecessary permissions.
6
6
 
7
7
  ## Install and start
8
8
 
9
9
  ```sh
10
- npm install -g zback@1.2.0
10
+ npm install -g zback@1.2.1
11
11
  zback
12
12
  ```
13
13
 
@@ -25,21 +25,21 @@ For development only:
25
25
  zback --url ws://127.0.0.1:8787/ws
26
26
  ```
27
27
 
28
- The client and backend must both be **version 1.2.0** because the connection handshake changed. The backend must be deployed before a remote client can connect.
28
+ The client requires **backend 1.2.0 or later**. The client rejects older servers rather than accepting unstable legacy session IDs. Deploy the updated backend before connecting a remote client.
29
29
 
30
30
  ## Terminal and process lifecycle
31
31
 
32
32
  | Command | Behavior |
33
33
  | --- | --- |
34
- | `zback` | Start if needed and attach the terminal to a live event display |
35
- | `zback --background` | Start the agent without keeping a live terminal display |
34
+ | `zback` | Keep the agent in the foreground; display the session ID, MCP endpoint and live activity |
35
+ | `zback --background` | Explicitly start a detached agent |
36
36
  | `zback --status` | Show connection status, process ID and private session ID |
37
37
  | `zback --logs` | Display recent connection and command activity |
38
- | `zback --follow` | Attach to live events from the existing background agent |
38
+ | `zback --follow` | Watch new events from an existing background agent without replaying old logs |
39
39
  | `zback --stop` | Stop the background agent |
40
40
  | `zback --reset-key` | Change the device identity after the agent is stopped |
41
41
 
42
- `Ctrl+C` while viewing events **detaches the display**, but the agent stays running. Use `zback --stop` to disconnect. The server session ID stays the same over ordinary network reconnections and backend restarts, as long as the private local agent credential remains unchanged.
42
+ `Ctrl+C` in the default foreground mode **stops the agent**. In background mode, `Ctrl+C` detaches the log display only. Use `zback --stop` to disconnect a background agent. Before upgrading from version 1.1.x or an earlier running version, stop its existing background worker with `zback --stop`. The server session ID stays the same over ordinary network reconnections and backend restarts, as long as the private local agent credential remains unchanged.
43
43
 
44
44
  After a device restart, ZBack does **not** silently install an autostart service. Launch it again when needed. Android/Termux process and battery restrictions can still terminate background tasks; a Node.js program cannot override the operating system's lifecycle.
45
45
 
@@ -56,7 +56,7 @@ Files are under `~/.local/state/zback` on Linux/Termux and `%LOCALAPPDATA%/zback
56
56
 
57
57
  ## MCP
58
58
 
59
- The companion backend serves `POST /mcp` with `zback_status` and `zback_execute`. The client prints a **private** session ID for passing to these tools. Only grant your own session ID to trusted applications. MCP clients, LLMs and tools that receive the ID can perform any command your operating-system account can perform.
59
+ The companion backend serves `POST /mcp` with `zback_status` and `zback_execute`. On connection, the client displays the MCP endpoint and a **private** session ID, which is supplied as the `id` argument to either tool. Only grant your own session ID to trusted applications. MCP clients, LLMs and tools that receive the ID can perform any command your operating-system account can perform.
60
60
 
61
61
  ## Security disclosure and review
62
62
 
package/bin/zback.js CHANGED
@@ -9,15 +9,15 @@ import { connectAgent, execute } from '../src/agent.js';
9
9
  import { activeSession, agentKey, consentFile, hasConsent, initStateDir, logFile, readJson, resetKey, sameSession, saveJson, settingsFile, stateFile, alive } from '../src/runtime.js';
10
10
 
11
11
  const ENTRY = fileURLToPath(import.meta.url);
12
- const VERSION = '1.2.0';
12
+ const VERSION = '1.2.1';
13
13
  const DEFAULT_URL = 'wss://zb.soymaycol.icu/ws';
14
14
 
15
15
  function help() {
16
16
  console.log(`ZBack ${VERSION} | Authorized remote shell administration
17
17
 
18
18
  Usage:
19
- zback Start the agent and display live events
20
- zback --background Start without attaching the terminal
19
+ zback Start in the foreground and display live events
20
+ zback --background Run as a background agent
21
21
  zback --status Show agent status, PID and private session ID
22
22
  zback --logs Show recent agent events
23
23
  zback --follow Follow live events from a running agent
@@ -34,8 +34,8 @@ Options:
34
34
 
35
35
  ZBack runs shell commands with your account privileges. Initial interactive
36
36
  consent is required once, not for every command. Keep the session ID private.
37
- Ctrl+C detaches the live display; the agent continues running.
38
- Use --stop to actually disconnect the agent.`);
37
+ Ctrl+C stops a foreground agent. Background mode must be requested explicitly.
38
+ Use --stop to disconnect a background agent.`);
39
39
  }
40
40
 
41
41
  export function parseArgs(args) {
@@ -73,11 +73,11 @@ export function parseArgs(args) {
73
73
  async function confirmOnce() {
74
74
  if (hasConsent()) return true;
75
75
  if (!stdin.isTTY || !stdout.isTTY) throw new Error('First-run shell authorization requires an interactive terminal');
76
- console.log('ZBack permits remote shell commands through a private session credential.');
77
- console.log('These commands run as your current user. Do not share your session ID.');
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.');
78
78
  const rl = createInterface({ input: stdin, output: stdout });
79
79
  try {
80
- const answer = await rl.question('Authorize remote shell administration on this device? [y/N] ');
80
+ const answer = await rl.question('Do you allow remote access to your shell? This is asked only once. [y/N] ');
81
81
  if (!['y', 'yes'].includes(answer.trim().toLowerCase())) return false;
82
82
  saveJson(consentFile, { approved: true, at: new Date().toISOString() });
83
83
  return true;
@@ -94,17 +94,32 @@ async function worker() {
94
94
  const mark = data => {
95
95
  const previous = readJson(stateFile);
96
96
  if (previous && previous.status !== 'stopped' && previous.pid !== process.pid && alive(previous.pid)) return;
97
- saveJson(stateFile, { pid: process.pid, started, updated: new Date().toISOString(), ...data });
97
+ saveJson(stateFile, { pid: process.pid, started, version: VERSION, updated: new Date().toISOString(), ...data });
98
98
  };
99
99
  mark({ status: 'connecting', id: null });
100
100
  try { await connectAgent(settings, mark, key); }
101
101
  finally { if (sameSession(process.pid, started)) mark({ status: 'stopped', id: null }); }
102
102
  }
103
103
 
104
+ function mcpEndpoint(url) {
105
+ const endpoint = new URL(url);
106
+ endpoint.protocol = endpoint.protocol === 'wss:' ? 'https:' : 'http:';
107
+ endpoint.pathname = '/mcp';
108
+ return endpoint.toString();
109
+ }
110
+
111
+ function showConnection(id, url) {
112
+ console.log(`Session ID: ${id}`);
113
+ console.log(`MCP endpoint: ${mcpEndpoint(url)}`);
114
+ console.log('MCP tools: zback_status, zback_execute (provide the ID as the id argument)');
115
+ console.log('Keep the session ID private; it grants remote shell access.');
116
+ }
117
+
104
118
  function showStatus() {
105
119
  const state = activeSession();
106
120
  if (!state) { console.log('Status: stopped'); return false; }
107
- console.log(`Status: ${state.status}\nPID: ${state.pid}${state.id ? `\nSession ID: ${state.id}` : ''}\nUpdated: ${state.updated || 'unknown'}`);
121
+ console.log(`Status: ${state.status}\nPID: ${state.pid}\nUpdated: ${state.updated || 'unknown'}`);
122
+ if (state.id) showConnection(state.id, readJson(settingsFile)?.url || DEFAULT_URL);
108
123
  return true;
109
124
  }
110
125
 
@@ -122,8 +137,7 @@ function recentLog() {
122
137
 
123
138
  async function follow() {
124
139
  if (!stdin.isTTY || !stdout.isTTY) return;
125
- console.log('Live agent events. Ctrl+C detaches; the agent stays active.');
126
- recentLog();
140
+ console.log('Monitoring background agent. Ctrl+C closes this view; the agent stays active.');
127
141
  let position = existsSync(logFile) ? statSync(logFile).size : 0;
128
142
  await new Promise(resolve => {
129
143
  const readNew = () => {
@@ -153,14 +167,43 @@ async function follow() {
153
167
  async function start(options) {
154
168
  const existing = activeSession();
155
169
  if (existing) {
156
- console.log(existing.id ? `Session ID: ${existing.id}` : `ZBack is ${existing.status} (PID ${existing.pid}).`);
170
+ if (existing.version !== VERSION) {
171
+ console.error('An older ZBack agent is still running. Run zback --stop, then zback to start this version.');
172
+ process.exitCode = 1;
173
+ return;
174
+ }
175
+ console.log(`Agent already running (${existing.status}, PID ${existing.pid}).`);
176
+ if (existing.id) showConnection(existing.id, readJson(settingsFile)?.url || DEFAULT_URL);
157
177
  if (!options.background) await follow();
158
178
  return;
159
179
  }
160
180
  if (!(await confirmOnce())) { console.log('Authorization declined'); return; }
161
181
  initStateDir();
162
- agentKey();
182
+ const key = agentKey();
163
183
  saveJson(settingsFile, { url: options.url, cwd: options.cwd, timeout: options.timeout, maxOutput: options.maxOutput, reconnect: options.reconnect });
184
+ if (!options.background) {
185
+ const started = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
186
+ let lastId = null;
187
+ let previousStatus = null;
188
+ const mark = data => {
189
+ saveJson(stateFile, { pid: process.pid, started, version: VERSION, updated: new Date().toISOString(), ...data });
190
+ 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;
200
+ };
201
+ console.log(`Connecting to ${options.url}...`);
202
+ 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() }); }
205
+ return;
206
+ }
164
207
  const fd = openSync(logFile, 'a', 0o600);
165
208
  const started = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
166
209
  let child;
@@ -178,13 +221,13 @@ async function start(options) {
178
221
  await new Promise(resolve => setTimeout(resolve, 100));
179
222
  const state = readJson(stateFile);
180
223
  if (state?.pid === child.pid && state.started === started && state.status === 'connected' && state.id) {
181
- console.log(`Session ID: ${state.id}`);
224
+ showConnection(state.id, options.url);
182
225
  connected = true;
183
226
  break;
184
227
  }
185
228
  if (!alive(child.pid) || (state?.pid === child.pid && state.status === 'stopped')) throw new Error(`Agent exited unexpectedly. Inspect logs with zback --logs`);
186
229
  }
187
- if (!connected) console.log(`Agent started (PID ${child.pid}). Waiting for backend; use zback --status for current state.`);
230
+ if (!connected) console.log(`Agent started (PID ${child.pid}). Waiting for backend; run zback --status to inspect.`);
188
231
  if (!options.background) await follow();
189
232
  }
190
233
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "zback",
3
- "version": "1.3.0",
4
- "description": "Opt-in remote shell administration with a persistent WebSocket agent, audit logs and a private session credential",
3
+ "version": "1.3.1",
4
+ "description": "Opt-in foreground remote shell administration with a persistent WebSocket agent and private session credential",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "zback": "bin/zback.js"
package/src/agent.js CHANGED
@@ -59,11 +59,9 @@ export async function connectAgent(options, onState, token) {
59
59
  let retries = 0;
60
60
  let socket;
61
61
  let cancelDelay;
62
- let currentState = '';
63
- const update = (status, data = {}) => {
64
- onState({ status, ...data });
65
- if (currentState !== status) { log(`Agent status: ${status}`); currentState = status; }
66
- };
62
+ let lastId = null;
63
+ let lastError = null;
64
+ const update = (status, data = {}) => onState({ status, ...data });
67
65
  const shutdown = () => {
68
66
  stopped = true;
69
67
  if (cancelDelay) cancelDelay();
@@ -75,7 +73,7 @@ export async function connectAgent(options, onState, token) {
75
73
  try {
76
74
  while (!stopped) {
77
75
  try {
78
- update(retries ? 'reconnecting' : 'connecting');
76
+ update(retries ? 'reconnecting' : 'connecting', { id: lastId });
79
77
  const current = new WebSocket(options.url);
80
78
  socket = current;
81
79
  await new Promise((resolve, reject) => {
@@ -95,13 +93,22 @@ export async function connectAgent(options, onState, token) {
95
93
  let message;
96
94
  try { message = JSON.parse(event.data); }
97
95
  catch { log('Ignored invalid server message'); return; }
98
- if (message.type === 'welcome' && typeof message.id === 'string') {
96
+ if (message.type === 'welcome') {
99
97
  if (welcomed) return;
98
+ if (message.protocolVersion !== 2 || typeof message.id !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(message.id)) {
99
+ clearTimeout(timer);
100
+ current.close(1000, 'Incompatible protocol');
101
+ const mismatch = new Error('Incompatible backend. Deploy ZBack backend 1.2.0 or later; this server issues legacy session IDs.');
102
+ mismatch.code = 'INCOMPATIBLE_BACKEND';
103
+ reject(mismatch);
104
+ return;
105
+ }
100
106
  welcomed = true;
101
107
  clearTimeout(timer);
102
108
  retries = 0;
109
+ lastId = message.id;
110
+ lastError = null;
103
111
  update('connected', { id: message.id });
104
- log('Connected and ready for authorized requests');
105
112
  return;
106
113
  }
107
114
  if (!welcomed || message.type !== 'command' || typeof message.requestId !== 'string') return;
@@ -120,16 +127,18 @@ export async function connectAgent(options, onState, token) {
120
127
  current.addEventListener('close', event => {
121
128
  clearTimeout(timer);
122
129
  if (!welcomed) reject(new Error(`Backend rejected connection (WebSocket ${event.code}: ${event.reason || 'no reason'})`));
123
- else { log(`Connection closed (${event.code}: ${event.reason || 'connection lost'})`); resolve(); }
130
+ else { if (!stopped) log(`Connection closed (${event.code}: ${event.reason || 'connection lost'})`); resolve(); }
124
131
  }, { once: true });
125
132
  current.addEventListener('error', () => { if (!welcomed) { clearTimeout(timer); reject(new Error('WebSocket connection failed')); } }, { once: true });
126
133
  });
127
- } catch (error) { if (!stopped) log(`Connection error: ${error.message}`); }
134
+ } 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; }
137
+ }
128
138
  killRunningCommands();
129
139
  if (stopped || !options.reconnect) break;
130
- update('reconnecting', { id: null });
140
+ update('reconnecting', { id: lastId });
131
141
  const delay = Math.min(30000, 1000 * 2 ** Math.min(retries++, 5));
132
- log(`Retrying connection in ${Math.ceil(delay / 1000)}s`);
133
142
  await new Promise(resolve => {
134
143
  const timer = setTimeout(() => { cancelDelay = null; resolve(); }, delay);
135
144
  cancelDelay = () => { clearTimeout(timer); cancelDelay = null; resolve(); };
@@ -138,6 +147,6 @@ export async function connectAgent(options, onState, token) {
138
147
  } finally {
139
148
  process.removeListener('SIGINT', shutdown);
140
149
  process.removeListener('SIGTERM', shutdown);
141
- update('stopped', { id: null });
150
+ update('stopped', { id: lastId });
142
151
  }
143
152
  }
package/src/runtime.js CHANGED
@@ -46,7 +46,7 @@ export function alive(pid) {
46
46
  try {
47
47
  if (readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1]?.[0] === 'Z') return false;
48
48
  const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
49
- if (!cmd.includes('--worker') || !cmd.includes('zback.js')) return false;
49
+ if (!/(?:^|[\x00/\\])zback(?:\.js)?(?:\x00|$)/.test(cmd)) return false;
50
50
  } catch {}
51
51
  }
52
52
  return true;