zback 1.4.0 → 1.4.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 +21 -8
- package/bin/zback.js +55 -11
- package/package.json +20 -5
- package/src/agent.js +118 -50
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
|
|
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
|
|
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
|
|
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.
|
|
15
19
|
|
|
16
|
-
|
|
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.
|
|
25
|
+
|
|
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
|
|
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://`.
|
|
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
|
|
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.
|
|
13
|
+
const VERSION = '1.2.5';
|
|
14
14
|
const DEFAULT_URL = 'wss://zb.soymaycol.icu/ws';
|
|
15
|
+
const STALE_AFTER_MS = 60000;
|
|
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 (
|
|
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.
|
|
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:
|
|
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 <
|
|
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 (
|
|
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 {
|
|
@@ -128,7 +143,7 @@ function showConnection(id, url) {
|
|
|
128
143
|
function showStatus() {
|
|
129
144
|
const state = activeSession();
|
|
130
145
|
if (!state) { console.log('Status: stopped'); return false; }
|
|
131
|
-
console.log(`Status: ${state
|
|
146
|
+
console.log(`Status: ${connectionStatus(state)}\nPID: ${state.pid}\nUpdated: ${state.updated || 'unknown'}`);
|
|
132
147
|
if (state.id) showConnection(state.id, readJson(settingsFile)?.url || DEFAULT_URL);
|
|
133
148
|
return true;
|
|
134
149
|
}
|
|
@@ -148,6 +163,7 @@ function recentLog() {
|
|
|
148
163
|
async function follow() {
|
|
149
164
|
if (!stdin.isTTY || !stdout.isTTY) return;
|
|
150
165
|
console.log('Monitoring background agent. Ctrl+C closes this view; the agent stays active.');
|
|
166
|
+
const terminal = createTerminal();
|
|
151
167
|
let position = existsSync(logFile) ? statSync(logFile).size : 0;
|
|
152
168
|
await new Promise(resolve => {
|
|
153
169
|
const readNew = () => {
|
|
@@ -166,7 +182,32 @@ async function follow() {
|
|
|
166
182
|
} finally { closeSync(fd); }
|
|
167
183
|
} catch (error) { console.error(`Log monitor: ${error.message}`); }
|
|
168
184
|
};
|
|
169
|
-
|
|
185
|
+
let finished = false;
|
|
186
|
+
let wasUnresponsive = false;
|
|
187
|
+
const stop = (detached = true) => {
|
|
188
|
+
if (finished) return;
|
|
189
|
+
finished = true;
|
|
190
|
+
clearInterval(monitor);
|
|
191
|
+
unwatchFile(logFile, readNew);
|
|
192
|
+
process.removeListener('SIGINT', stop);
|
|
193
|
+
process.removeListener('SIGTERM', stop);
|
|
194
|
+
if (detached) console.log('\nDetached. Agent continues in the background.');
|
|
195
|
+
resolve();
|
|
196
|
+
};
|
|
197
|
+
const monitor = setInterval(() => {
|
|
198
|
+
readNew();
|
|
199
|
+
const session = activeSession();
|
|
200
|
+
if (!session) {
|
|
201
|
+
terminal.error(readJson(stateFile)?.status === 'stopped' ? 'Background agent stopped.' : 'Background agent stopped unexpectedly. Use zback to restart it.');
|
|
202
|
+
stop(false);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const unresponsive = connectionStatus(session) === 'unresponsive';
|
|
206
|
+
if (unresponsive && !wasUnresponsive) {
|
|
207
|
+
terminal.error('No heartbeat from the agent. Its connection may be unresponsive.', true);
|
|
208
|
+
}
|
|
209
|
+
wasUnresponsive = unresponsive;
|
|
210
|
+
}, 2000);
|
|
170
211
|
watchFile(logFile, { interval: 400 }, readNew);
|
|
171
212
|
process.once('SIGINT', stop);
|
|
172
213
|
process.once('SIGTERM', stop);
|
|
@@ -183,12 +224,15 @@ async function start(options) {
|
|
|
183
224
|
process.exitCode = 1;
|
|
184
225
|
return;
|
|
185
226
|
}
|
|
186
|
-
|
|
227
|
+
const status = connectionStatus(existing);
|
|
228
|
+
terminal.section('+', `Agent already running (${status}, PID ${existing.pid}).`);
|
|
187
229
|
if (existing.id) terminal.connected(existing.id, mcpEndpoint(readJson(settingsFile)?.url || DEFAULT_URL));
|
|
230
|
+
if (status === 'unresponsive') terminal.error('No recent heartbeat from the agent.', true);
|
|
231
|
+
else if (status === 'reconnecting') terminal.error('Connection lost', true);
|
|
188
232
|
if (!options.background) await follow();
|
|
189
233
|
return;
|
|
190
234
|
}
|
|
191
|
-
if (!(await confirmOnce(terminal))) return;
|
|
235
|
+
if (!(await confirmOnce(terminal, options.acceptShellAccess))) return;
|
|
192
236
|
initStateDir();
|
|
193
237
|
const key = agentKey();
|
|
194
238
|
saveJson(settingsFile, { url: options.url, cwd: options.cwd, timeout: options.timeout, maxOutput: options.maxOutput, reconnect: options.reconnect });
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zback",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
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": ">=
|
|
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": [
|
|
19
|
-
|
|
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 ?? 12000;
|
|
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
|
-
|
|
63
|
-
const
|
|
64
|
-
const
|
|
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
|
-
|
|
68
|
-
try { socket?.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
+
let fatal = null;
|
|
98
|
+
let heartbeatTimer = null;
|
|
99
|
+
let pongTimer = null;
|
|
100
|
+
let handshakeTimer = null;
|
|
91
101
|
let queue = Promise.resolve();
|
|
92
|
-
|
|
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 });
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
+
reachedServer = true;
|
|
172
|
+
clearTimeout(handshakeTimer);
|
|
116
173
|
retries = 0;
|
|
117
174
|
lastId = message.id;
|
|
118
|
-
|
|
119
|
-
|
|
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 !==
|
|
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
|
|
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 ===
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
current.
|
|
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' : `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
|
|
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'
|
|
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'
|
|
225
|
+
update('stopped');
|
|
158
226
|
}
|
|
159
227
|
}
|