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 +34 -8
- package/bin/zback.js +61 -15
- 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.
|
|
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
|
|
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
|
|
|
@@ -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.
|
|
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 (
|
|
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 {
|
|
@@ -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
|
-
|
|
102
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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.
|
|
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": ">=
|
|
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 ?? 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
|
-
|
|
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, 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(
|
|
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 (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
|
|
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
|
}
|