zback 1.4.0 → 1.4.2

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
@@ -1,19 +1,29 @@
1
1
  # ZBack CLI
2
2
 
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.
3
+ ZBack is an opt-in remote administration agent for systems you own or are authorized to manage. The package uses Node.js 20 or later, uses the `ws` WebSocket dependency 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
+ Use Node.js 20 or later. Install dependencies before running `node bin/zback.js` directly from a source archive.
10
+
9
11
  ```sh
10
- npm install -g zback@1.2.2
12
+ npm install -g .
11
13
  zback
12
14
  ```
13
15
 
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.
16
+ On the first run in a terminal, ZBack presents a [Y]/[N] shell authorization question once. It stores the decision locally, then creates a private agent credential. There are no prompts for each command.
17
+
18
+ For explicitly authorized non-interactive runs (such as GitHub Actions or containers), add `--accept-shell-access` on the first start. This flag grants the same persistent local authorization without needing a TTY. Without previous authorization or that flag, non-interactive startup fails safely. Never add the flag to scripts executed by untrusted contributors, and avoid running ZBack as root.
19
+
20
+ ```sh
21
+ zback --accept-shell-access --url wss://your-domain.example/ws
22
+ ```
23
+
24
+ When using CI, remember that remote execution access lasts for the duration of the workflow job and any person holding the session ID can execute commands with the process privileges. Use trusted workflows and keep the ID private.
15
25
 
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.
26
+ 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. The agent sends WebSocket ping frames to detect silent connection failures, and the log viewer checks whether the background agent is still alive.
17
27
 
18
28
  The default server is `wss://zb.soymaycol.icu/ws`. To use your own backend:
19
29
 
@@ -27,13 +37,16 @@ For development only:
27
37
  zback --url ws://127.0.0.1:8787/ws
28
38
  ```
29
39
 
30
- 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.
40
+ The client requires **backend 1.2.0 or later**, and backend **1.2.3 or later** to disable server-side execution timeouts. The client rejects older servers rather than accepting unstable legacy session IDs. Deploy the updated backend before connecting a remote client.
41
+
42
+ The `ws` dependency is installed automatically by npm. It avoids relying on Node.js exposing a global `WebSocket` and does not run installation scripts.
31
43
 
32
44
  ## Terminal and process lifecycle
33
45
 
34
46
  | Command | Behavior |
35
47
  | --- | --- |
36
48
  | `zback` | Keep the agent in the foreground; display the session ID, MCP endpoint, colored connection notices, and command execution history |
49
+ | `zback --accept-shell-access` | Explicitly authorize first-run shell access without a TTY (CI, non-interactive scripts) |
37
50
  | `zback --background` | Explicitly start a detached agent |
38
51
  | `zback --status` | Show connection status, process ID and private session ID |
39
52
  | `zback --logs` | Display recent connection and command activity |
@@ -52,13 +65,13 @@ zback --cwd ./workspace --timeout 300000 --max-output 524288
52
65
  zback --url wss://your-domain.example/ws --background
53
66
  ```
54
67
 
55
- The client validates TLS on remote connections. Only loopback addresses are allowed for unencrypted `ws://`. Server resource protection and command timeouts are operational safety controls, not per-user paid quotas.
68
+ The client validates TLS on remote connections. Only loopback addresses are allowed for unencrypted `ws://`. Command execution has no duration timeout by default (`--timeout 0`); you can choose a positive limit using `--timeout <ms>`. Connection watchdogs, queue waiting, output caps and resource safeguards still apply.
56
69
 
57
- Files are under `~/.local/state/zback` on Linux/Termux and `%LOCALAPPDATA%/zback` on Windows. To use a custom location, set `ZBACK_STATE_DIR`. `agent.key` and the local state files must not be shared. Use `zback --reset-key` to invalidate a previously disclosed session ID.
70
+ Files are under `~/.local/state/zback` on Linux/Termux and `%LOCALAPPDATA%/zback` on Windows. To use a custom location, set `ZBACK_STATE_DIR`. `agent.key` and the local state files must not be shared. If a background agent becomes unavailable, `zback --status` will indicate whether its heartbeat is stale. Use `zback --reset-key` to invalidate a previously disclosed session ID.
58
71
 
59
72
  ## MCP
60
73
 
61
- 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.
74
+ 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. When your backend uses `ALLOWED_ORIGINS=*`, configure the backend `MCP_ACCESS_TOKEN` as a Bearer credential in your MCP client as well as the private session ID; it is not a substitute for the ID. Never embed either secret in public front-end code. 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.
62
75
 
63
76
  ## Security disclosure and review
64
77
 
@@ -69,3 +82,16 @@ The npm package declares its remote administration capabilities as dual-use soft
69
82
  ```sh
70
83
  npm test
71
84
  ```
85
+
86
+ ## Connection health (1.2.6)
87
+
88
+ Normal WebSocket ping/pong checks update the local health timestamp silently. A
89
+ `Connection restored` message is printed **only after a real WebSocket
90
+ disconnection and a successful new handshake**, never after a routine pong.
91
+ A delayed pong has a 60-second grace period before the client reconnects.
92
+ The private session identifier remains derived from the local agent key and
93
+ does not change on reconnection. If the connection truly closes, the terminal
94
+ now reports the WebSocket close code and available server reason.
95
+
96
+ Update the backend to 1.2.4 for relaxed server heartbeat handling too. This
97
+ does not change `--timeout 0`, MCP tools, or interactive consent behavior.
package/bin/zback.js CHANGED
@@ -10,8 +10,14 @@ import { createTerminal, createEventReporter } from '../src/terminal.js';
10
10
  import { activeSession, agentKey, consentFile, hasConsent, initStateDir, logFile, readJson, resetKey, sameSession, saveJson, settingsFile, stateFile, alive } from '../src/runtime.js';
11
11
 
12
12
  const ENTRY = fileURLToPath(import.meta.url);
13
- const VERSION = '1.2.2';
13
+ const VERSION = '1.2.6';
14
14
  const DEFAULT_URL = 'wss://zb.soymaycol.icu/ws';
15
+ const STALE_AFTER_MS = 120000;
16
+
17
+ function connectionStatus(state) {
18
+ if (state.status === 'connected' && (!Number.isFinite(Date.parse(state.updated)) || Date.now() - Date.parse(state.updated) > STALE_AFTER_MS)) return 'unresponsive';
19
+ return state.status;
20
+ }
15
21
 
16
22
  function help() {
17
23
  console.log(`ZBack ${VERSION} | Authorized remote shell administration
@@ -28,19 +34,22 @@ Usage:
28
34
  Options:
29
35
  --url <wss://.../ws> Server endpoint (default: ${DEFAULT_URL})
30
36
  --cwd <directory> Working directory for commands
31
- --timeout <ms> Command timeout (1000-3600000)
37
+ --timeout <ms> Command timeout (0 = no limit, default: 0)
32
38
  --max-output <bytes> Captured output (1024-786432)
33
39
  --no-reconnect Stop after a connection is lost
40
+ --accept-shell-access Authorize remote shell access non-interactively (CI)
34
41
  --help Display this help
35
42
 
36
43
  ZBack runs shell commands with your account privileges. Initial interactive
37
- consent is required once, not for every command. Keep the session ID private.
44
+ consent is required once, not for every command. For CI or other non-TTY
45
+ environments, use --accept-shell-access as an explicit first-run authorization.
46
+ Keep the session ID private; do not run as root unless strictly necessary.
38
47
  Ctrl+C stops a foreground agent. Background mode must be requested explicitly.
39
48
  Use --stop to disconnect a background agent.`);
40
49
  }
41
50
 
42
51
  export function parseArgs(args) {
43
- const options = { url: DEFAULT_URL, cwd: process.cwd(), timeout: 120000, maxOutput: 262144, reconnect: true, background: false };
52
+ const options = { url: DEFAULT_URL, cwd: process.cwd(), timeout: 0, maxOutput: 262144, reconnect: true, background: false, acceptShellAccess: false };
44
53
  const values = new Set(['--url', '--cwd', '--timeout', '--max-output']);
45
54
  let action = 'start';
46
55
  for (let i = 0; i < args.length; i++) {
@@ -54,6 +63,7 @@ export function parseArgs(args) {
54
63
  }
55
64
  if (flag === '--background' || flag === '--detach') { options.background = true; continue; }
56
65
  if (flag === '--no-reconnect') { options.reconnect = false; continue; }
66
+ if (flag === '--accept-shell-access') { options.acceptShellAccess = true; continue; }
57
67
  if (!values.has(flag) || i + 1 >= args.length) throw new Error(`Unknown or incomplete argument: ${flag}`);
58
68
  const value = args[++i];
59
69
  if (flag === '--url') options.url = value;
@@ -62,7 +72,7 @@ export function parseArgs(args) {
62
72
  if (flag === '--max-output') options.maxOutput = Number(value);
63
73
  }
64
74
  if (action !== 'start') return { action };
65
- if (!Number.isSafeInteger(options.timeout) || options.timeout < 1000 || options.timeout > 3600000) throw new Error('--timeout must be between 1000 and 3600000 ms');
75
+ if (!Number.isSafeInteger(options.timeout) || options.timeout < 0 || options.timeout > 3600000) throw new Error('--timeout must be between 0 and 3600000 ms');
66
76
  if (!Number.isSafeInteger(options.maxOutput) || options.maxOutput < 1024 || options.maxOutput > 786432) throw new Error('--max-output must be between 1024 and 786432 bytes');
67
77
  if (!existsSync(options.cwd) || !statSync(options.cwd).isDirectory()) throw new Error('--cwd must be an existing directory');
68
78
  const url = new URL(options.url);
@@ -71,9 +81,14 @@ export function parseArgs(args) {
71
81
  return { ...options, action };
72
82
  }
73
83
 
74
- async function confirmOnce(terminal) {
84
+ async function confirmOnce(terminal, explicitApproval = false) {
75
85
  if (hasConsent()) return true;
76
- if (!stdin.isTTY || !stdout.isTTY) throw new Error('First-run shell authorization requires an interactive terminal');
86
+ if (explicitApproval) {
87
+ saveJson(consentFile, { approved: true, at: new Date().toISOString(), method: 'explicit-cli-flag' });
88
+ terminal.hint('Shell access authorized by --accept-shell-access. This decision has been saved.');
89
+ return true;
90
+ }
91
+ if (!stdin.isTTY || !stdout.isTTY) throw new Error('First-run shell authorization requires a terminal or the explicit --accept-shell-access option');
77
92
  terminal.question();
78
93
  const rl = createInterface({ input: stdin, output: stdout });
79
94
  try {
@@ -98,8 +113,9 @@ async function worker() {
98
113
  const mark = data => {
99
114
  const previous = readJson(stateFile);
100
115
  if (previous && previous.status !== 'stopped' && previous.pid !== process.pid && alive(previous.pid)) return;
101
- saveJson(stateFile, { pid: process.pid, started, version: VERSION, updated: new Date().toISOString(), ...data });
102
- if (data.status === 'connected') {
116
+ const { heartbeat, ...state } = data;
117
+ saveJson(stateFile, { pid: process.pid, started, version: VERSION, updated: new Date().toISOString(), ...state });
118
+ if (data.status === 'connected' && !heartbeat) {
103
119
  reporter.clearProblem();
104
120
  if (!lastId) terminal.connected(data.id, mcpEndpoint(settings.url));
105
121
  else terminal.restored();
@@ -128,7 +144,7 @@ function showConnection(id, url) {
128
144
  function showStatus() {
129
145
  const state = activeSession();
130
146
  if (!state) { console.log('Status: stopped'); return false; }
131
- console.log(`Status: ${state.status}\nPID: ${state.pid}\nUpdated: ${state.updated || 'unknown'}`);
147
+ console.log(`Status: ${connectionStatus(state)}\nPID: ${state.pid}\nUpdated: ${state.updated || 'unknown'}`);
132
148
  if (state.id) showConnection(state.id, readJson(settingsFile)?.url || DEFAULT_URL);
133
149
  return true;
134
150
  }
@@ -148,6 +164,7 @@ function recentLog() {
148
164
  async function follow() {
149
165
  if (!stdin.isTTY || !stdout.isTTY) return;
150
166
  console.log('Monitoring background agent. Ctrl+C closes this view; the agent stays active.');
167
+ const terminal = createTerminal();
151
168
  let position = existsSync(logFile) ? statSync(logFile).size : 0;
152
169
  await new Promise(resolve => {
153
170
  const readNew = () => {
@@ -166,7 +183,32 @@ async function follow() {
166
183
  } finally { closeSync(fd); }
167
184
  } catch (error) { console.error(`Log monitor: ${error.message}`); }
168
185
  };
169
- const stop = () => { unwatchFile(logFile, readNew); process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); console.log('\nDetached. Agent continues in the background.'); resolve(); };
186
+ let finished = false;
187
+ let wasUnresponsive = false;
188
+ const stop = (detached = true) => {
189
+ if (finished) return;
190
+ finished = true;
191
+ clearInterval(monitor);
192
+ unwatchFile(logFile, readNew);
193
+ process.removeListener('SIGINT', stop);
194
+ process.removeListener('SIGTERM', stop);
195
+ if (detached) console.log('\nDetached. Agent continues in the background.');
196
+ resolve();
197
+ };
198
+ const monitor = setInterval(() => {
199
+ readNew();
200
+ const session = activeSession();
201
+ if (!session) {
202
+ terminal.error(readJson(stateFile)?.status === 'stopped' ? 'Background agent stopped.' : 'Background agent stopped unexpectedly. Use zback to restart it.');
203
+ stop(false);
204
+ return;
205
+ }
206
+ const unresponsive = connectionStatus(session) === 'unresponsive';
207
+ if (unresponsive && !wasUnresponsive) {
208
+ terminal.error('No heartbeat from the agent. Its connection may be unresponsive.', true);
209
+ }
210
+ wasUnresponsive = unresponsive;
211
+ }, 2000);
170
212
  watchFile(logFile, { interval: 400 }, readNew);
171
213
  process.once('SIGINT', stop);
172
214
  process.once('SIGTERM', stop);
@@ -183,12 +225,15 @@ async function start(options) {
183
225
  process.exitCode = 1;
184
226
  return;
185
227
  }
186
- terminal.section('+', `Agent already running (${existing.status}, PID ${existing.pid}).`);
228
+ const status = connectionStatus(existing);
229
+ terminal.section('+', `Agent already running (${status}, PID ${existing.pid}).`);
187
230
  if (existing.id) terminal.connected(existing.id, mcpEndpoint(readJson(settingsFile)?.url || DEFAULT_URL));
231
+ if (status === 'unresponsive') terminal.error('No recent heartbeat from the agent.', true);
232
+ else if (status === 'reconnecting') terminal.error('Connection lost', true);
188
233
  if (!options.background) await follow();
189
234
  return;
190
235
  }
191
- if (!(await confirmOnce(terminal))) return;
236
+ if (!(await confirmOnce(terminal, options.acceptShellAccess))) return;
192
237
  initStateDir();
193
238
  const key = agentKey();
194
239
  saveJson(settingsFile, { url: options.url, cwd: options.cwd, timeout: options.timeout, maxOutput: options.maxOutput, reconnect: options.reconnect });
@@ -198,8 +243,9 @@ async function start(options) {
198
243
  let lastId = null;
199
244
  const reporter = createEventReporter(terminal);
200
245
  const mark = data => {
201
- saveJson(stateFile, { pid: process.pid, started, version: VERSION, updated: new Date().toISOString(), ...data });
202
- if (data.status === 'connected') {
246
+ const { heartbeat, ...state } = data;
247
+ saveJson(stateFile, { pid: process.pid, started, version: VERSION, updated: new Date().toISOString(), ...state });
248
+ if (data.status === 'connected' && !heartbeat) {
203
249
  reporter.clearProblem();
204
250
  if (!lastId) terminal.connected(data.id, mcpEndpoint(options.url));
205
251
  else terminal.restored();
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "zback",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
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": {
7
7
  "zback": "bin/zback.js"
8
8
  },
9
9
  "engines": {
10
- "node": ">=22.0.0"
10
+ "node": ">=20.0.0"
11
11
  },
12
12
  "scripts": {
13
13
  "test": "node --test"
@@ -15,8 +15,23 @@
15
15
  "contentPolicy": {
16
16
  "class": "dual-use"
17
17
  },
18
- "keywords": ["remote-administration", "websocket", "cli", "mcp", "opt-in"],
19
- "files": ["bin/", "src/", "README.md", "DISCLOSURE", "LICENSE"],
18
+ "keywords": [
19
+ "remote-administration",
20
+ "websocket",
21
+ "cli",
22
+ "mcp",
23
+ "opt-in"
24
+ ],
25
+ "files": [
26
+ "bin/",
27
+ "src/",
28
+ "README.md",
29
+ "DISCLOSURE",
30
+ "LICENSE"
31
+ ],
20
32
  "license": "MIT",
21
- "author": "SoyMaycol"
33
+ "author": "SoyMaycol",
34
+ "dependencies": {
35
+ "ws": "^8.22.0"
36
+ }
22
37
  }
package/src/agent.js CHANGED
@@ -34,7 +34,7 @@ export function execute(command, cwd, timeoutMs, maxOutput) {
34
34
  };
35
35
  child.stdout.on('data', b => add('stdout', b));
36
36
  child.stderr.on('data', b => add('stderr', b));
37
- const timer = setTimeout(() => { timedOut = true; killTree(child); }, timeoutMs);
37
+ const timer = timeoutMs > 0 ? setTimeout(() => { timedOut = true; killTree(child); }, timeoutMs) : null;
38
38
  const finish = (code, message = '') => {
39
39
  if (finished) return;
40
40
  finished = true;
@@ -53,98 +53,166 @@ export function execute(command, cwd, timeoutMs, maxOutput) {
53
53
  });
54
54
  }
55
55
 
56
- export async function connectAgent(options, onState, token, onEvent = () => {}) {
56
+ export async function connectAgent(options, onState, token, onEvent = () => {}, socketConstructor) {
57
+ let WebSocketClient = socketConstructor;
58
+ if (!WebSocketClient) {
59
+ try { WebSocketClient = (await import('ws')).default; }
60
+ catch (error) {
61
+ if (error.code === 'ERR_MODULE_NOT_FOUND') throw new Error('WebSocket dependency is missing. Run npm install in the zback-client directory, or reinstall ZBack globally.');
62
+ throw error;
63
+ }
64
+ }
65
+ const heartbeatInterval = options.heartbeatIntervalMs ?? 20000;
66
+ const pongTimeout = options.pongTimeoutMs ?? 60000;
57
67
  let stopped = false;
58
68
  let retries = 0;
59
- let socket;
60
- let cancelDelay;
69
+ let socket = null;
70
+ let cancelDelay = null;
61
71
  let lastId = null;
62
- let lastError = null;
63
- const update = (status, data = {}) => onState({ status, ...data });
64
- const emit = event => { try { onEvent(event); } catch { /* UI failures must not interrupt command execution. */ } };
72
+
73
+ const emit = event => { try { onEvent(event); } catch {} };
74
+ const update = (status, data = {}) => {
75
+ try { onState({ status, id: lastId, ...data }); }
76
+ catch (error) { emit({ type: 'agent-error', problem: `Could not save connection state: ${error.message}` }); }
77
+ };
65
78
  const shutdown = () => {
66
79
  stopped = true;
67
- if (cancelDelay) cancelDelay();
68
- try { socket?.close(1000, 'Agent stopped'); } catch {}
80
+ cancelDelay?.();
81
+ try { socket?.terminate(); } catch {}
69
82
  killRunningCommands();
70
83
  };
84
+
71
85
  process.once('SIGINT', shutdown);
72
86
  process.once('SIGTERM', shutdown);
73
87
  try {
74
88
  while (!stopped) {
89
+ update(retries ? 'reconnecting' : 'connecting');
90
+ let reachedServer = false;
75
91
  try {
76
- update(retries ? 'reconnecting' : 'connecting', { id: lastId });
77
- const current = new WebSocket(options.url);
92
+ const current = new WebSocketClient(options.url, { handshakeTimeout: 15000, perMessageDeflate: false });
78
93
  socket = current;
79
94
  await new Promise((resolve, reject) => {
80
- const timer = setTimeout(() => { try { current.close(); } catch {} reject(new Error('Connection timed out')); }, 15000);
81
- const success = () => { clearTimeout(timer); resolve(); };
82
- const failure = () => { clearTimeout(timer); reject(new Error('Could not reach backend')); };
83
- current.addEventListener('open', success, { once: true });
84
- current.addEventListener('error', failure, { once: true });
85
- });
86
- if (stopped) break;
87
- current.send(JSON.stringify({ type: 'hello', protocolVersion: 2, token }));
88
- await new Promise((resolve, reject) => {
95
+ let settled = false;
89
96
  let welcomed = false;
90
- const timer = setTimeout(() => { try { current.close(); } catch {} reject(new Error('Server handshake timed out')); }, 10000);
97
+ let fatal = null;
98
+ let heartbeatTimer = null;
99
+ let pongTimer = null;
100
+ let handshakeTimer = null;
91
101
  let queue = Promise.resolve();
92
- const messageListener = event => {
102
+
103
+ const cleanup = () => {
104
+ clearInterval(heartbeatTimer);
105
+ clearTimeout(pongTimer);
106
+ clearTimeout(handshakeTimer);
107
+ current.removeListener('open', opened);
108
+ current.removeListener('message', received);
109
+ current.removeListener('pong', pong);
110
+ current.removeListener('error', failed);
111
+ current.removeListener('close', closed);
112
+ };
113
+ const finish = error => {
114
+ if (settled) return;
115
+ settled = true;
116
+ cleanup();
117
+ current.on('error', () => {});
118
+ try { current.terminate(); } catch {}
119
+ if (error) reject(error);
120
+ else resolve();
121
+ };
122
+ const fail = (reason, code) => {
123
+ const error = new Error(reason);
124
+ if (code) error.code = code;
125
+ fatal = error;
126
+ finish(error);
127
+ };
128
+ const disconnected = reason => {
129
+ if (stopped) return finish();
130
+ if (welcomed) {
131
+ update('reconnecting');
132
+ emit({ type: 'connection-lost', problem: reason });
133
+ finish();
134
+ } else finish(fatal ?? new Error(reason));
135
+ };
136
+ const pong = () => {
137
+ clearTimeout(pongTimer);
138
+ pongTimer = null;
139
+ if (welcomed && !stopped) update('connected', { id: lastId, heartbeat: true });
140
+ };
141
+ const checkConnection = () => {
142
+ if (current.readyState !== WebSocketClient.OPEN || pongTimer || stopped) return;
143
+ try {
144
+ current.ping();
145
+ pongTimer = setTimeout(() => disconnected('Connection unresponsive (heartbeat timeout)'), pongTimeout);
146
+ } catch (error) { disconnected(`Connection health check failed: ${error.message}`); }
147
+ };
148
+ const opened = () => {
149
+ if (stopped) return finish();
150
+ try {
151
+ current.send(JSON.stringify({ type: 'hello', protocolVersion: 2, token }));
152
+ handshakeTimer = setTimeout(() => disconnected('Server handshake timed out'), 10000);
153
+ } catch (error) { disconnected(`Could not authenticate connection: ${error.message}`); }
154
+ };
155
+ const received = (data, isBinary) => {
156
+ if (isBinary) return emit({ type: 'protocol-warning', problem: 'Unexpected binary message from server' });
93
157
  let message;
94
- try { message = JSON.parse(event.data); }
158
+ try { message = JSON.parse(data.toString()); }
95
159
  catch { emit({ type: 'protocol-warning', problem: 'Received an invalid server message' }); return; }
96
160
  if (message.type === 'welcome') {
97
161
  if (welcomed) return;
98
162
  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);
163
+ fail('Incompatible backend. Deploy ZBack backend 1.2.0 or later.', 'INCOMPATIBLE_BACKEND');
104
164
  return;
105
165
  }
106
166
  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);
167
+ fail('The server returned a different session identifier after reconnecting', 'SESSION_MISMATCH');
112
168
  return;
113
169
  }
114
170
  welcomed = true;
115
- clearTimeout(timer);
171
+ reachedServer = true;
172
+ clearTimeout(handshakeTimer);
116
173
  retries = 0;
117
174
  lastId = message.id;
118
- lastError = null;
119
- update('connected', { id: message.id });
175
+ update('connected', { id: lastId });
176
+ heartbeatTimer = setInterval(checkConnection, heartbeatInterval);
120
177
  return;
121
178
  }
122
179
  if (!welcomed || message.type !== 'command' || typeof message.requestId !== 'string') return;
123
180
  queue = queue.then(async () => {
124
- if (stopped || current.readyState !== WebSocket.OPEN) return;
181
+ if (stopped || current.readyState !== WebSocketClient.OPEN) return;
125
182
  const command = message.command;
126
183
  if (typeof command !== 'string' || !command.trim() || command.length > 4096 || command.includes('\0')) return;
127
- const timeout = Math.min(options.timeout, Number.isInteger(message.timeoutMs) && message.timeoutMs > 0 ? message.timeoutMs : options.timeout);
184
+ const positiveLimits = [options.timeout, message.timeoutMs].filter(value => Number.isInteger(value) && value > 0);
185
+ const timeout = positiveLimits.length ? Math.min(...positiveLimits) : 0;
128
186
  const result = await execute(command, options.cwd, timeout, options.maxOutput);
129
187
  emit({ type: 'command-result', command, result });
130
- if (current.readyState === WebSocket.OPEN && !stopped) current.send(JSON.stringify({ type: 'result', requestId: message.requestId, ...result }));
188
+ if (!stopped && current.readyState === WebSocketClient.OPEN) {
189
+ current.send(JSON.stringify({ type: 'result', requestId: message.requestId, ...result }));
190
+ }
131
191
  }).catch(error => emit({ type: 'agent-error', problem: `Request processing failed: ${error.message}` }));
132
192
  };
133
- current.addEventListener('message', messageListener);
134
- current.addEventListener('close', event => {
135
- clearTimeout(timer);
136
- if (!welcomed) reject(new Error(`Backend rejected connection (WebSocket ${event.code}: ${event.reason || 'no reason'})`));
137
- else { if (!stopped) emit({ type: 'connection-lost', problem: 'Connection lost' }); resolve(); }
138
- }, { once: true });
139
- current.addEventListener('error', () => { if (!welcomed) { clearTimeout(timer); reject(new Error('WebSocket connection failed')); } }, { once: true });
193
+ const failed = error => disconnected(error?.message || 'WebSocket connection failed');
194
+ const closed = (code, reason) => {
195
+ const detail = reason?.toString();
196
+ disconnected(welcomed ? `Connection lost (WebSocket ${code}${detail ? `: ${detail}` : ''})` : `Backend closed the connection (WebSocket ${code}${detail ? `: ${detail}` : ''})`);
197
+ };
198
+ current.on('open', opened);
199
+ current.on('message', received);
200
+ current.on('pong', pong);
201
+ current.on('error', failed);
202
+ current.on('close', closed);
140
203
  });
141
204
  } catch (error) {
142
205
  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; }
206
+ if (!stopped) {
207
+ update('reconnecting');
208
+ emit({ type: reachedServer ? 'connection-lost' : 'connection-error', problem: error.message });
209
+ }
210
+ } finally {
211
+ socket = null;
212
+ killRunningCommands();
144
213
  }
145
- killRunningCommands();
146
214
  if (stopped || !options.reconnect) break;
147
- update('reconnecting', { id: lastId });
215
+ update('reconnecting');
148
216
  const delay = Math.min(30000, 1000 * 2 ** Math.min(retries++, 5));
149
217
  await new Promise(resolve => {
150
218
  const timer = setTimeout(() => { cancelDelay = null; resolve(); }, delay);
@@ -154,6 +222,6 @@ export async function connectAgent(options, onState, token, onEvent = () => {})
154
222
  } finally {
155
223
  process.removeListener('SIGINT', shutdown);
156
224
  process.removeListener('SIGTERM', shutdown);
157
- update('stopped', { id: lastId });
225
+ update('stopped');
158
226
  }
159
227
  }