zback 1.3.1 → 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 +23 -8
- package/bin/zback.js +92 -39
- package/package.json +20 -5
- package/src/agent.js +125 -50
- package/src/terminal.js +98 -0
package/README.md
CHANGED
|
@@ -1,17 +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
|
|
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.
|
|
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.
|
|
15
27
|
|
|
16
28
|
The default server is `wss://zb.soymaycol.icu/ws`. To use your own backend:
|
|
17
29
|
|
|
@@ -25,13 +37,16 @@ For development only:
|
|
|
25
37
|
zback --url ws://127.0.0.1:8787/ws
|
|
26
38
|
```
|
|
27
39
|
|
|
28
|
-
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.
|
|
29
43
|
|
|
30
44
|
## Terminal and process lifecycle
|
|
31
45
|
|
|
32
46
|
| Command | Behavior |
|
|
33
47
|
| --- | --- |
|
|
34
|
-
| `zback` | Keep the agent in the foreground; display the session ID, MCP endpoint and
|
|
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) |
|
|
35
50
|
| `zback --background` | Explicitly start a detached agent |
|
|
36
51
|
| `zback --status` | Show connection status, process ID and private session ID |
|
|
37
52
|
| `zback --logs` | Display recent connection and command activity |
|
|
@@ -50,13 +65,13 @@ zback --cwd ./workspace --timeout 300000 --max-output 524288
|
|
|
50
65
|
zback --url wss://your-domain.example/ws --background
|
|
51
66
|
```
|
|
52
67
|
|
|
53
|
-
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.
|
|
54
69
|
|
|
55
|
-
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.
|
|
56
71
|
|
|
57
72
|
## MCP
|
|
58
73
|
|
|
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.
|
|
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.
|
|
60
75
|
|
|
61
76
|
## Security disclosure and review
|
|
62
77
|
|
package/bin/zback.js
CHANGED
|
@@ -6,11 +6,18 @@ import { createInterface } from 'node:readline/promises';
|
|
|
6
6
|
import { stdin, stdout } from 'node:process';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { connectAgent, execute } from '../src/agent.js';
|
|
9
|
+
import { createTerminal, createEventReporter } from '../src/terminal.js';
|
|
9
10
|
import { activeSession, agentKey, consentFile, hasConsent, initStateDir, logFile, readJson, resetKey, sameSession, saveJson, settingsFile, stateFile, alive } from '../src/runtime.js';
|
|
10
11
|
|
|
11
12
|
const ENTRY = fileURLToPath(import.meta.url);
|
|
12
|
-
const VERSION = '1.2.
|
|
13
|
+
const VERSION = '1.2.5';
|
|
13
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
|
+
}
|
|
14
21
|
|
|
15
22
|
function help() {
|
|
16
23
|
console.log(`ZBack ${VERSION} | Authorized remote shell administration
|
|
@@ -27,19 +34,22 @@ Usage:
|
|
|
27
34
|
Options:
|
|
28
35
|
--url <wss://.../ws> Server endpoint (default: ${DEFAULT_URL})
|
|
29
36
|
--cwd <directory> Working directory for commands
|
|
30
|
-
--timeout <ms> Command timeout (
|
|
37
|
+
--timeout <ms> Command timeout (0 = no limit, default: 0)
|
|
31
38
|
--max-output <bytes> Captured output (1024-786432)
|
|
32
39
|
--no-reconnect Stop after a connection is lost
|
|
40
|
+
--accept-shell-access Authorize remote shell access non-interactively (CI)
|
|
33
41
|
--help Display this help
|
|
34
42
|
|
|
35
43
|
ZBack runs shell commands with your account privileges. Initial interactive
|
|
36
|
-
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.
|
|
37
47
|
Ctrl+C stops a foreground agent. Background mode must be requested explicitly.
|
|
38
48
|
Use --stop to disconnect a background agent.`);
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
export function parseArgs(args) {
|
|
42
|
-
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 };
|
|
43
53
|
const values = new Set(['--url', '--cwd', '--timeout', '--max-output']);
|
|
44
54
|
let action = 'start';
|
|
45
55
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -53,6 +63,7 @@ export function parseArgs(args) {
|
|
|
53
63
|
}
|
|
54
64
|
if (flag === '--background' || flag === '--detach') { options.background = true; continue; }
|
|
55
65
|
if (flag === '--no-reconnect') { options.reconnect = false; continue; }
|
|
66
|
+
if (flag === '--accept-shell-access') { options.acceptShellAccess = true; continue; }
|
|
56
67
|
if (!values.has(flag) || i + 1 >= args.length) throw new Error(`Unknown or incomplete argument: ${flag}`);
|
|
57
68
|
const value = args[++i];
|
|
58
69
|
if (flag === '--url') options.url = value;
|
|
@@ -61,7 +72,7 @@ export function parseArgs(args) {
|
|
|
61
72
|
if (flag === '--max-output') options.maxOutput = Number(value);
|
|
62
73
|
}
|
|
63
74
|
if (action !== 'start') return { action };
|
|
64
|
-
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');
|
|
65
76
|
if (!Number.isSafeInteger(options.maxOutput) || options.maxOutput < 1024 || options.maxOutput > 786432) throw new Error('--max-output must be between 1024 and 786432 bytes');
|
|
66
77
|
if (!existsSync(options.cwd) || !statSync(options.cwd).isDirectory()) throw new Error('--cwd must be an existing directory');
|
|
67
78
|
const url = new URL(options.url);
|
|
@@ -70,17 +81,22 @@ export function parseArgs(args) {
|
|
|
70
81
|
return { ...options, action };
|
|
71
82
|
}
|
|
72
83
|
|
|
73
|
-
async function confirmOnce() {
|
|
84
|
+
async function confirmOnce(terminal, explicitApproval = false) {
|
|
74
85
|
if (hasConsent()) return true;
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
|
|
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');
|
|
92
|
+
terminal.question();
|
|
78
93
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
79
94
|
try {
|
|
80
|
-
const answer = await rl.question('
|
|
81
|
-
|
|
82
|
-
saveJson(consentFile, { approved: true, at: new Date().toISOString() });
|
|
83
|
-
|
|
95
|
+
const answer = await rl.question('Your choice [y/N]: ');
|
|
96
|
+
const approved = ['y', 'yes'].includes(answer.trim().toLowerCase());
|
|
97
|
+
if (approved) saveJson(consentFile, { approved: true, at: new Date().toISOString() });
|
|
98
|
+
terminal.consent(approved);
|
|
99
|
+
return approved;
|
|
84
100
|
} finally { rl.close(); }
|
|
85
101
|
}
|
|
86
102
|
|
|
@@ -91,14 +107,23 @@ async function worker() {
|
|
|
91
107
|
const started = process.env.ZBACK_WORKER_NONCE;
|
|
92
108
|
if (!started) throw new Error('Worker must be launched through zback');
|
|
93
109
|
const key = agentKey();
|
|
110
|
+
const terminal = createTerminal();
|
|
111
|
+
const reporter = createEventReporter(terminal);
|
|
112
|
+
let lastId = null;
|
|
94
113
|
const mark = data => {
|
|
95
114
|
const previous = readJson(stateFile);
|
|
96
115
|
if (previous && previous.status !== 'stopped' && previous.pid !== process.pid && alive(previous.pid)) return;
|
|
97
116
|
saveJson(stateFile, { pid: process.pid, started, version: VERSION, updated: new Date().toISOString(), ...data });
|
|
117
|
+
if (data.status === 'connected') {
|
|
118
|
+
reporter.clearProblem();
|
|
119
|
+
if (!lastId) terminal.connected(data.id, mcpEndpoint(settings.url));
|
|
120
|
+
else terminal.restored();
|
|
121
|
+
lastId = data.id;
|
|
122
|
+
}
|
|
98
123
|
};
|
|
99
124
|
mark({ status: 'connecting', id: null });
|
|
100
|
-
try { await connectAgent(settings, mark, key); }
|
|
101
|
-
finally { if (sameSession(process.pid, started)) mark({ status: 'stopped', id:
|
|
125
|
+
try { await connectAgent(settings, mark, key, reporter.event); }
|
|
126
|
+
finally { if (sameSession(process.pid, started)) mark({ status: 'stopped', id: lastId }); }
|
|
102
127
|
}
|
|
103
128
|
|
|
104
129
|
function mcpEndpoint(url) {
|
|
@@ -118,7 +143,7 @@ function showConnection(id, url) {
|
|
|
118
143
|
function showStatus() {
|
|
119
144
|
const state = activeSession();
|
|
120
145
|
if (!state) { console.log('Status: stopped'); return false; }
|
|
121
|
-
console.log(`Status: ${state
|
|
146
|
+
console.log(`Status: ${connectionStatus(state)}\nPID: ${state.pid}\nUpdated: ${state.updated || 'unknown'}`);
|
|
122
147
|
if (state.id) showConnection(state.id, readJson(settingsFile)?.url || DEFAULT_URL);
|
|
123
148
|
return true;
|
|
124
149
|
}
|
|
@@ -138,6 +163,7 @@ function recentLog() {
|
|
|
138
163
|
async function follow() {
|
|
139
164
|
if (!stdin.isTTY || !stdout.isTTY) return;
|
|
140
165
|
console.log('Monitoring background agent. Ctrl+C closes this view; the agent stays active.');
|
|
166
|
+
const terminal = createTerminal();
|
|
141
167
|
let position = existsSync(logFile) ? statSync(logFile).size : 0;
|
|
142
168
|
await new Promise(resolve => {
|
|
143
169
|
const readNew = () => {
|
|
@@ -156,7 +182,32 @@ async function follow() {
|
|
|
156
182
|
} finally { closeSync(fd); }
|
|
157
183
|
} catch (error) { console.error(`Log monitor: ${error.message}`); }
|
|
158
184
|
};
|
|
159
|
-
|
|
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);
|
|
160
211
|
watchFile(logFile, { interval: 400 }, readNew);
|
|
161
212
|
process.once('SIGINT', stop);
|
|
162
213
|
process.once('SIGTERM', stop);
|
|
@@ -165,43 +216,46 @@ async function follow() {
|
|
|
165
216
|
}
|
|
166
217
|
|
|
167
218
|
async function start(options) {
|
|
219
|
+
const terminal = createTerminal();
|
|
168
220
|
const existing = activeSession();
|
|
169
221
|
if (existing) {
|
|
170
222
|
if (existing.version !== VERSION) {
|
|
171
|
-
|
|
223
|
+
terminal.error('An older ZBack agent is still running. Run zback --stop before starting this version.');
|
|
172
224
|
process.exitCode = 1;
|
|
173
225
|
return;
|
|
174
226
|
}
|
|
175
|
-
|
|
176
|
-
|
|
227
|
+
const status = connectionStatus(existing);
|
|
228
|
+
terminal.section('+', `Agent already running (${status}, PID ${existing.pid}).`);
|
|
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);
|
|
177
232
|
if (!options.background) await follow();
|
|
178
233
|
return;
|
|
179
234
|
}
|
|
180
|
-
if (!(await confirmOnce()))
|
|
235
|
+
if (!(await confirmOnce(terminal, options.acceptShellAccess))) return;
|
|
181
236
|
initStateDir();
|
|
182
237
|
const key = agentKey();
|
|
183
238
|
saveJson(settingsFile, { url: options.url, cwd: options.cwd, timeout: options.timeout, maxOutput: options.maxOutput, reconnect: options.reconnect });
|
|
239
|
+
terminal.connecting();
|
|
184
240
|
if (!options.background) {
|
|
185
241
|
const started = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
186
242
|
let lastId = null;
|
|
187
|
-
|
|
243
|
+
const reporter = createEventReporter(terminal);
|
|
188
244
|
const mark = data => {
|
|
189
245
|
saveJson(stateFile, { pid: process.pid, started, version: VERSION, updated: new Date().toISOString(), ...data });
|
|
190
246
|
if (data.status === 'connected') {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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;
|
|
247
|
+
reporter.clearProblem();
|
|
248
|
+
if (!lastId) terminal.connected(data.id, mcpEndpoint(options.url));
|
|
249
|
+
else terminal.restored();
|
|
250
|
+
lastId = data.id;
|
|
251
|
+
}
|
|
200
252
|
};
|
|
201
|
-
console.log(`Connecting to ${options.url}...`);
|
|
202
253
|
mark({ status: 'connecting', id: null });
|
|
203
|
-
try { await connectAgent(options, mark, key); }
|
|
204
|
-
finally {
|
|
254
|
+
try { await connectAgent(options, mark, key, reporter.event); }
|
|
255
|
+
finally {
|
|
256
|
+
if (sameSession(process.pid, started)) saveJson(stateFile, { pid: process.pid, started, version: VERSION, status: 'stopped', id: lastId, updated: new Date().toISOString() });
|
|
257
|
+
terminal.stopped();
|
|
258
|
+
}
|
|
205
259
|
return;
|
|
206
260
|
}
|
|
207
261
|
const fd = openSync(logFile, 'a', 0o600);
|
|
@@ -221,14 +275,13 @@ async function start(options) {
|
|
|
221
275
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
222
276
|
const state = readJson(stateFile);
|
|
223
277
|
if (state?.pid === child.pid && state.started === started && state.status === 'connected' && state.id) {
|
|
224
|
-
|
|
278
|
+
terminal.connected(state.id, mcpEndpoint(options.url));
|
|
225
279
|
connected = true;
|
|
226
280
|
break;
|
|
227
281
|
}
|
|
228
|
-
if (!alive(child.pid) || (state?.pid === child.pid && state.status === 'stopped')) throw new Error(
|
|
282
|
+
if (!alive(child.pid) || (state?.pid === child.pid && state.status === 'stopped')) throw new Error('Agent exited unexpectedly. Inspect logs with zback --logs');
|
|
229
283
|
}
|
|
230
|
-
if (!connected)
|
|
231
|
-
if (!options.background) await follow();
|
|
284
|
+
if (!connected) terminal.hint(`Agent started (PID ${child.pid}). Run zback --status to inspect the connection.`);
|
|
232
285
|
}
|
|
233
286
|
|
|
234
287
|
function stop() {
|
|
@@ -256,7 +309,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
256
309
|
if (options.action === 'follow') { showStatus(); return follow(); }
|
|
257
310
|
if (options.action === 'reset-key') return rotate();
|
|
258
311
|
return await start(options);
|
|
259
|
-
} catch (error) {
|
|
312
|
+
} catch (error) { createTerminal().error(error.message); process.exitCode = 1; }
|
|
260
313
|
}
|
|
261
314
|
|
|
262
315
|
if (process.argv[1] && realpathSync(process.argv[1]) === ENTRY) main();
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zback",
|
|
3
|
-
"version": "1.
|
|
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
|
@@ -2,7 +2,6 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
|
|
3
3
|
const children = new Set();
|
|
4
4
|
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
5
|
-
const log = message => console.log(`[${new Date().toISOString()}] ${message}`);
|
|
6
5
|
|
|
7
6
|
function killTree(child) {
|
|
8
7
|
if (!child?.pid) return;
|
|
@@ -35,7 +34,7 @@ export function execute(command, cwd, timeoutMs, maxOutput) {
|
|
|
35
34
|
};
|
|
36
35
|
child.stdout.on('data', b => add('stdout', b));
|
|
37
36
|
child.stderr.on('data', b => add('stderr', b));
|
|
38
|
-
const timer = setTimeout(() => { timedOut = true; killTree(child); }, timeoutMs);
|
|
37
|
+
const timer = timeoutMs > 0 ? setTimeout(() => { timedOut = true; killTree(child); }, timeoutMs) : null;
|
|
39
38
|
const finish = (code, message = '') => {
|
|
40
39
|
if (finished) return;
|
|
41
40
|
finished = true;
|
|
@@ -54,90 +53,166 @@ export function execute(command, cwd, timeoutMs, maxOutput) {
|
|
|
54
53
|
});
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
export async function connectAgent(options, onState, token) {
|
|
56
|
+
export async function connectAgent(options, onState, token, onEvent = () => {}, 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;
|
|
58
67
|
let stopped = false;
|
|
59
68
|
let retries = 0;
|
|
60
|
-
let socket;
|
|
61
|
-
let cancelDelay;
|
|
69
|
+
let socket = null;
|
|
70
|
+
let cancelDelay = null;
|
|
62
71
|
let lastId = null;
|
|
63
|
-
|
|
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(
|
|
95
|
-
catch {
|
|
158
|
+
try { message = JSON.parse(data.toString()); }
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
163
|
+
fail('Incompatible backend. Deploy ZBack backend 1.2.0 or later.', 'INCOMPATIBLE_BACKEND');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (lastId && message.id !== lastId) {
|
|
167
|
+
fail('The server returned a different session identifier after reconnecting', 'SESSION_MISMATCH');
|
|
104
168
|
return;
|
|
105
169
|
}
|
|
106
170
|
welcomed = true;
|
|
107
|
-
|
|
171
|
+
reachedServer = true;
|
|
172
|
+
clearTimeout(handshakeTimer);
|
|
108
173
|
retries = 0;
|
|
109
174
|
lastId = message.id;
|
|
110
|
-
|
|
111
|
-
|
|
175
|
+
update('connected', { id: lastId });
|
|
176
|
+
heartbeatTimer = setInterval(checkConnection, heartbeatInterval);
|
|
112
177
|
return;
|
|
113
178
|
}
|
|
114
179
|
if (!welcomed || message.type !== 'command' || typeof message.requestId !== 'string') return;
|
|
115
180
|
queue = queue.then(async () => {
|
|
116
|
-
if (stopped || current.readyState !==
|
|
181
|
+
if (stopped || current.readyState !== WebSocketClient.OPEN) return;
|
|
117
182
|
const command = message.command;
|
|
118
183
|
if (typeof command !== 'string' || !command.trim() || command.length > 4096 || command.includes('\0')) return;
|
|
119
|
-
const
|
|
120
|
-
|
|
184
|
+
const positiveLimits = [options.timeout, message.timeoutMs].filter(value => Number.isInteger(value) && value > 0);
|
|
185
|
+
const timeout = positiveLimits.length ? Math.min(...positiveLimits) : 0;
|
|
121
186
|
const result = await execute(command, options.cwd, timeout, options.maxOutput);
|
|
122
|
-
|
|
123
|
-
if (current.readyState ===
|
|
124
|
-
|
|
187
|
+
emit({ type: 'command-result', command, result });
|
|
188
|
+
if (!stopped && current.readyState === WebSocketClient.OPEN) {
|
|
189
|
+
current.send(JSON.stringify({ type: 'result', requestId: message.requestId, ...result }));
|
|
190
|
+
}
|
|
191
|
+
}).catch(error => emit({ type: 'agent-error', problem: `Request processing failed: ${error.message}` }));
|
|
192
|
+
};
|
|
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}` : ''})`);
|
|
125
197
|
};
|
|
126
|
-
current.
|
|
127
|
-
current.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}, { once: true });
|
|
132
|
-
current.addEventListener('error', () => { if (!welcomed) { clearTimeout(timer); reject(new Error('WebSocket connection failed')); } }, { once: true });
|
|
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);
|
|
133
203
|
});
|
|
134
204
|
} catch (error) {
|
|
135
|
-
if (!stopped && error.code
|
|
136
|
-
if (!stopped
|
|
205
|
+
if (!stopped && ['INCOMPATIBLE_BACKEND', 'SESSION_MISMATCH'].includes(error.code)) throw error;
|
|
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();
|
|
137
213
|
}
|
|
138
|
-
killRunningCommands();
|
|
139
214
|
if (stopped || !options.reconnect) break;
|
|
140
|
-
update('reconnecting'
|
|
215
|
+
update('reconnecting');
|
|
141
216
|
const delay = Math.min(30000, 1000 * 2 ** Math.min(retries++, 5));
|
|
142
217
|
await new Promise(resolve => {
|
|
143
218
|
const timer = setTimeout(() => { cancelDelay = null; resolve(); }, delay);
|
|
@@ -147,6 +222,6 @@ export async function connectAgent(options, onState, token) {
|
|
|
147
222
|
} finally {
|
|
148
223
|
process.removeListener('SIGINT', shutdown);
|
|
149
224
|
process.removeListener('SIGTERM', shutdown);
|
|
150
|
-
update('stopped'
|
|
225
|
+
update('stopped');
|
|
151
226
|
}
|
|
152
227
|
}
|
package/src/terminal.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const ANSI = Object.freeze({ yellow: '\x1b[33m', red: '\x1b[31m', gray: '\x1b[90m', white: '\x1b[37m', reset: '\x1b[0m' });
|
|
2
|
+
|
|
3
|
+
// Never allow server-supplied text to inject terminal control sequences.
|
|
4
|
+
export function terminalText(value, maxLength = 240) {
|
|
5
|
+
const text = String(value ?? 'unknown')
|
|
6
|
+
.replace(/\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)?)/g, '')
|
|
7
|
+
.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ')
|
|
8
|
+
.replace(/\s+/g, ' ')
|
|
9
|
+
.trim();
|
|
10
|
+
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createTerminal({ output = process.stdout, color = Boolean(output.isTTY && !process.env.NO_COLOR) } = {}) {
|
|
14
|
+
const tint = (s, name) => color ? `${ANSI[name]}${s}${ANSI.reset}` : s;
|
|
15
|
+
const write = line => output.write(`${line}\n`);
|
|
16
|
+
const section = (symbol, text, tone = 'yellow') => write(`${tint(`[${symbol}]`, tone)} ${tint(text, 'white')}`);
|
|
17
|
+
const detail = line => write(`${tint(' -', 'gray')} ${tint(line, 'white')}`);
|
|
18
|
+
const hint = line => write(`${tint('>', 'gray')} ${tint(line, 'gray')}`);
|
|
19
|
+
const gap = () => write('');
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
section, detail, hint, gap,
|
|
23
|
+
question() {
|
|
24
|
+
section('?', 'Shell access authorization');
|
|
25
|
+
write(tint('Do you allow ZBack to provide remote shell access to holders of your private session ID?', 'white'));
|
|
26
|
+
write(`${tint('[Y]', 'yellow')} ${tint('Yes, I accept', 'white')} ${tint('[N]', 'yellow')} ${tint('No, I do not accept', 'white')}`);
|
|
27
|
+
hint('Your decision is saved. Individual commands will not trigger additional prompts.');
|
|
28
|
+
},
|
|
29
|
+
consent(approved) {
|
|
30
|
+
hint(approved ? 'Selected: Yes. Remote shell access is authorized.' : 'Selected: No. Remote shell access remains disabled.');
|
|
31
|
+
gap();
|
|
32
|
+
},
|
|
33
|
+
connecting() {
|
|
34
|
+
section('+', 'Connecting to the server, hang on a minute.');
|
|
35
|
+
hint('This may take a moment depending on your internet connection.');
|
|
36
|
+
gap();
|
|
37
|
+
},
|
|
38
|
+
connected(id, endpoint) {
|
|
39
|
+
section('+', 'Setting up our environment');
|
|
40
|
+
detail(`Session Identifier: "${terminalText(id, 90)}"`);
|
|
41
|
+
detail(`MCP Server: "${terminalText(endpoint, 512)}"`);
|
|
42
|
+
detail('MCP Tools: "zback_status", "zback_execute"');
|
|
43
|
+
hint('Enjoy being able to have a model manage your environment. Keep your ID private.');
|
|
44
|
+
gap();
|
|
45
|
+
},
|
|
46
|
+
restored() {
|
|
47
|
+
section('+', 'Connection restored.');
|
|
48
|
+
hint('Your session identifier has not changed.');
|
|
49
|
+
gap();
|
|
50
|
+
},
|
|
51
|
+
error(problem, retrying = false) {
|
|
52
|
+
section('×', 'Oops, an error has occurred.', 'red');
|
|
53
|
+
detail(`Problem: "${terminalText(problem, 240)}"`);
|
|
54
|
+
hint(retrying ? 'This error is being handled. Reconnecting automatically, please wait.' : 'The connection cannot continue. Check the problem above.');
|
|
55
|
+
gap();
|
|
56
|
+
},
|
|
57
|
+
stopped() {
|
|
58
|
+
section('+', 'Disconnected.');
|
|
59
|
+
},
|
|
60
|
+
command(result, totals) {
|
|
61
|
+
if (totals.count === 1) section('+', 'History of executed commands');
|
|
62
|
+
const code = Number.isInteger(result.code) ? result.code : result.timedOut ? 'timeout' : 'unknown';
|
|
63
|
+
detail(`${JSON.stringify(terminalText(result.command))} was executed with exit code ${code}.`);
|
|
64
|
+
const countWord = totals.count === 1 ? 'command was' : 'commands were';
|
|
65
|
+
const errorWord = totals.errors === 1 ? 'an error' : 'errors';
|
|
66
|
+
hint(`Currently, ${totals.count} ${countWord} executed, ${totals.errors} with ${errorWord} and ${totals.successes} successfully.`);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createCommandTracker(terminal) {
|
|
72
|
+
const totals = { count: 0, successes: 0, errors: 0 };
|
|
73
|
+
return event => {
|
|
74
|
+
if (event.type !== 'command-result') return;
|
|
75
|
+
totals.count++;
|
|
76
|
+
if (event.result.ok) totals.successes++;
|
|
77
|
+
else totals.errors++;
|
|
78
|
+
terminal.command({ command: event.command, ...event.result }, totals);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createEventReporter(terminal) {
|
|
83
|
+
const reportCommand = createCommandTracker(terminal);
|
|
84
|
+
let activeProblem = null;
|
|
85
|
+
return {
|
|
86
|
+
clearProblem() { activeProblem = null; },
|
|
87
|
+
event(event) {
|
|
88
|
+
if (event.type === 'command-result') return reportCommand(event);
|
|
89
|
+
if (event.type === 'connection-lost' || event.type === 'connection-error') {
|
|
90
|
+
// A dropped connection may lead to many retries; report the incident once.
|
|
91
|
+
if (activeProblem !== null) return;
|
|
92
|
+
activeProblem = event.problem;
|
|
93
|
+
return terminal.error(event.problem, true);
|
|
94
|
+
}
|
|
95
|
+
if (event.type === 'agent-error' || event.type === 'protocol-warning') terminal.error(event.problem, false);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|