zback 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DISCLOSURE +1 -1
- package/README.md +11 -9
- package/bin/zback.js +78 -26
- package/package.json +2 -2
- package/src/agent.js +35 -19
- package/src/runtime.js +1 -1
- package/src/terminal.js +98 -0
package/DISCLOSURE
CHANGED
|
@@ -2,7 +2,7 @@ ZBack is an opt-in remote shell administration tool for devices owned or explici
|
|
|
2
2
|
|
|
3
3
|
Security-sensitive functionality:
|
|
4
4
|
- A user manually starts the ZBack command and approves remote shell administration on the first run. The decision is stored in a local user-scoped file; subsequent commands do not require repeated prompts.
|
|
5
|
-
-
|
|
5
|
+
- The agent runs in the foreground by default and stops on Ctrl+C. An explicitly requested background worker may persist after the terminal detaches and can be stopped with `zback --stop`.
|
|
6
6
|
- Remote shell commands execute with the operating-system permissions of the account that launched the agent.
|
|
7
7
|
- The worker connects outbound to the configured secure WebSocket endpoint. It does not open an inbound shell listener.
|
|
8
8
|
- The server exposes an MCP endpoint that routes authorized commands to connected workers when the caller presents the private session capability.
|
package/README.md
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
# ZBack CLI
|
|
2
2
|
|
|
3
|
-
ZBack is
|
|
3
|
+
ZBack is an opt-in remote administration agent for systems you own or are authorized to manage. The package uses Node.js 22 or later, has **no third-party runtime dependencies** and runs no installation scripts.
|
|
4
4
|
|
|
5
5
|
> **Security:** ZBack grants remote shell access with your local user privileges to anyone who possesses its private session ID. Treat that ID as a password. Do not publish it, paste it in issue reports, or share screenshots containing it. Do not run ZBack as root or as an account with unnecessary permissions.
|
|
6
6
|
|
|
7
7
|
## Install and start
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
npm install -g zback@1.2.
|
|
10
|
+
npm install -g zback@1.2.2
|
|
11
11
|
zback
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
On the first run, ZBack
|
|
14
|
+
On the first run, ZBack presents a [Y]/[N] shell authorization question once. It stores that decision locally, then creates a private agent credential. There are no prompts for each command.
|
|
15
|
+
|
|
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.
|
|
15
17
|
|
|
16
18
|
The default server is `wss://zb.soymaycol.icu/ws`. To use your own backend:
|
|
17
19
|
|
|
@@ -25,21 +27,21 @@ For development only:
|
|
|
25
27
|
zback --url ws://127.0.0.1:8787/ws
|
|
26
28
|
```
|
|
27
29
|
|
|
28
|
-
The client
|
|
30
|
+
The client requires **backend 1.2.0 or later**. The client rejects older servers rather than accepting unstable legacy session IDs. Deploy the updated backend before connecting a remote client.
|
|
29
31
|
|
|
30
32
|
## Terminal and process lifecycle
|
|
31
33
|
|
|
32
34
|
| Command | Behavior |
|
|
33
35
|
| --- | --- |
|
|
34
|
-
| `zback` |
|
|
35
|
-
| `zback --background` |
|
|
36
|
+
| `zback` | Keep the agent in the foreground; display the session ID, MCP endpoint, colored connection notices, and command execution history |
|
|
37
|
+
| `zback --background` | Explicitly start a detached agent |
|
|
36
38
|
| `zback --status` | Show connection status, process ID and private session ID |
|
|
37
39
|
| `zback --logs` | Display recent connection and command activity |
|
|
38
|
-
| `zback --follow` |
|
|
40
|
+
| `zback --follow` | Watch new events from an existing background agent without replaying old logs |
|
|
39
41
|
| `zback --stop` | Stop the background agent |
|
|
40
42
|
| `zback --reset-key` | Change the device identity after the agent is stopped |
|
|
41
43
|
|
|
42
|
-
`Ctrl+C`
|
|
44
|
+
`Ctrl+C` in the default foreground mode **stops the agent**. In background mode, `Ctrl+C` detaches the log display only. Use `zback --stop` to disconnect a background agent. Before upgrading from version 1.1.x or an earlier running version, stop its existing background worker with `zback --stop`. The server session ID stays the same over ordinary network reconnections and backend restarts, as long as the private local agent credential remains unchanged.
|
|
43
45
|
|
|
44
46
|
After a device restart, ZBack does **not** silently install an autostart service. Launch it again when needed. Android/Termux process and battery restrictions can still terminate background tasks; a Node.js program cannot override the operating system's lifecycle.
|
|
45
47
|
|
|
@@ -56,7 +58,7 @@ Files are under `~/.local/state/zback` on Linux/Termux and `%LOCALAPPDATA%/zback
|
|
|
56
58
|
|
|
57
59
|
## MCP
|
|
58
60
|
|
|
59
|
-
The companion backend serves `POST /mcp` with `zback_status` and `zback_execute`.
|
|
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.
|
|
60
62
|
|
|
61
63
|
## Security disclosure and review
|
|
62
64
|
|
package/bin/zback.js
CHANGED
|
@@ -6,18 +6,19 @@ 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.2';
|
|
13
14
|
const DEFAULT_URL = 'wss://zb.soymaycol.icu/ws';
|
|
14
15
|
|
|
15
16
|
function help() {
|
|
16
17
|
console.log(`ZBack ${VERSION} | Authorized remote shell administration
|
|
17
18
|
|
|
18
19
|
Usage:
|
|
19
|
-
zback Start the
|
|
20
|
-
zback --background
|
|
20
|
+
zback Start in the foreground and display live events
|
|
21
|
+
zback --background Run as a background agent
|
|
21
22
|
zback --status Show agent status, PID and private session ID
|
|
22
23
|
zback --logs Show recent agent events
|
|
23
24
|
zback --follow Follow live events from a running agent
|
|
@@ -34,8 +35,8 @@ Options:
|
|
|
34
35
|
|
|
35
36
|
ZBack runs shell commands with your account privileges. Initial interactive
|
|
36
37
|
consent is required once, not for every command. Keep the session ID private.
|
|
37
|
-
Ctrl+C
|
|
38
|
-
Use --stop to
|
|
38
|
+
Ctrl+C stops a foreground agent. Background mode must be requested explicitly.
|
|
39
|
+
Use --stop to disconnect a background agent.`);
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export function parseArgs(args) {
|
|
@@ -70,17 +71,17 @@ export function parseArgs(args) {
|
|
|
70
71
|
return { ...options, action };
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
async function confirmOnce() {
|
|
74
|
+
async function confirmOnce(terminal) {
|
|
74
75
|
if (hasConsent()) return true;
|
|
75
76
|
if (!stdin.isTTY || !stdout.isTTY) throw new Error('First-run shell authorization requires an interactive terminal');
|
|
76
|
-
|
|
77
|
-
console.log('These commands run as your current user. Do not share your session ID.');
|
|
77
|
+
terminal.question();
|
|
78
78
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
79
79
|
try {
|
|
80
|
-
const answer = await rl.question('
|
|
81
|
-
|
|
82
|
-
saveJson(consentFile, { approved: true, at: new Date().toISOString() });
|
|
83
|
-
|
|
80
|
+
const answer = await rl.question('Your choice [y/N]: ');
|
|
81
|
+
const approved = ['y', 'yes'].includes(answer.trim().toLowerCase());
|
|
82
|
+
if (approved) saveJson(consentFile, { approved: true, at: new Date().toISOString() });
|
|
83
|
+
terminal.consent(approved);
|
|
84
|
+
return approved;
|
|
84
85
|
} finally { rl.close(); }
|
|
85
86
|
}
|
|
86
87
|
|
|
@@ -91,20 +92,44 @@ async function worker() {
|
|
|
91
92
|
const started = process.env.ZBACK_WORKER_NONCE;
|
|
92
93
|
if (!started) throw new Error('Worker must be launched through zback');
|
|
93
94
|
const key = agentKey();
|
|
95
|
+
const terminal = createTerminal();
|
|
96
|
+
const reporter = createEventReporter(terminal);
|
|
97
|
+
let lastId = null;
|
|
94
98
|
const mark = data => {
|
|
95
99
|
const previous = readJson(stateFile);
|
|
96
100
|
if (previous && previous.status !== 'stopped' && previous.pid !== process.pid && alive(previous.pid)) return;
|
|
97
|
-
saveJson(stateFile, { pid: process.pid, started, updated: new Date().toISOString(), ...data });
|
|
101
|
+
saveJson(stateFile, { pid: process.pid, started, version: VERSION, updated: new Date().toISOString(), ...data });
|
|
102
|
+
if (data.status === 'connected') {
|
|
103
|
+
reporter.clearProblem();
|
|
104
|
+
if (!lastId) terminal.connected(data.id, mcpEndpoint(settings.url));
|
|
105
|
+
else terminal.restored();
|
|
106
|
+
lastId = data.id;
|
|
107
|
+
}
|
|
98
108
|
};
|
|
99
109
|
mark({ status: 'connecting', id: null });
|
|
100
|
-
try { await connectAgent(settings, mark, key); }
|
|
101
|
-
finally { if (sameSession(process.pid, started)) mark({ status: 'stopped', id:
|
|
110
|
+
try { await connectAgent(settings, mark, key, reporter.event); }
|
|
111
|
+
finally { if (sameSession(process.pid, started)) mark({ status: 'stopped', id: lastId }); }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function mcpEndpoint(url) {
|
|
115
|
+
const endpoint = new URL(url);
|
|
116
|
+
endpoint.protocol = endpoint.protocol === 'wss:' ? 'https:' : 'http:';
|
|
117
|
+
endpoint.pathname = '/mcp';
|
|
118
|
+
return endpoint.toString();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function showConnection(id, url) {
|
|
122
|
+
console.log(`Session ID: ${id}`);
|
|
123
|
+
console.log(`MCP endpoint: ${mcpEndpoint(url)}`);
|
|
124
|
+
console.log('MCP tools: zback_status, zback_execute (provide the ID as the id argument)');
|
|
125
|
+
console.log('Keep the session ID private; it grants remote shell access.');
|
|
102
126
|
}
|
|
103
127
|
|
|
104
128
|
function showStatus() {
|
|
105
129
|
const state = activeSession();
|
|
106
130
|
if (!state) { console.log('Status: stopped'); return false; }
|
|
107
|
-
console.log(`Status: ${state.status}\nPID: ${state.pid}
|
|
131
|
+
console.log(`Status: ${state.status}\nPID: ${state.pid}\nUpdated: ${state.updated || 'unknown'}`);
|
|
132
|
+
if (state.id) showConnection(state.id, readJson(settingsFile)?.url || DEFAULT_URL);
|
|
108
133
|
return true;
|
|
109
134
|
}
|
|
110
135
|
|
|
@@ -122,8 +147,7 @@ function recentLog() {
|
|
|
122
147
|
|
|
123
148
|
async function follow() {
|
|
124
149
|
if (!stdin.isTTY || !stdout.isTTY) return;
|
|
125
|
-
console.log('
|
|
126
|
-
recentLog();
|
|
150
|
+
console.log('Monitoring background agent. Ctrl+C closes this view; the agent stays active.');
|
|
127
151
|
let position = existsSync(logFile) ? statSync(logFile).size : 0;
|
|
128
152
|
await new Promise(resolve => {
|
|
129
153
|
const readNew = () => {
|
|
@@ -151,16 +175,45 @@ async function follow() {
|
|
|
151
175
|
}
|
|
152
176
|
|
|
153
177
|
async function start(options) {
|
|
178
|
+
const terminal = createTerminal();
|
|
154
179
|
const existing = activeSession();
|
|
155
180
|
if (existing) {
|
|
156
|
-
|
|
181
|
+
if (existing.version !== VERSION) {
|
|
182
|
+
terminal.error('An older ZBack agent is still running. Run zback --stop before starting this version.');
|
|
183
|
+
process.exitCode = 1;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
terminal.section('+', `Agent already running (${existing.status}, PID ${existing.pid}).`);
|
|
187
|
+
if (existing.id) terminal.connected(existing.id, mcpEndpoint(readJson(settingsFile)?.url || DEFAULT_URL));
|
|
157
188
|
if (!options.background) await follow();
|
|
158
189
|
return;
|
|
159
190
|
}
|
|
160
|
-
if (!(await confirmOnce()))
|
|
191
|
+
if (!(await confirmOnce(terminal))) return;
|
|
161
192
|
initStateDir();
|
|
162
|
-
agentKey();
|
|
193
|
+
const key = agentKey();
|
|
163
194
|
saveJson(settingsFile, { url: options.url, cwd: options.cwd, timeout: options.timeout, maxOutput: options.maxOutput, reconnect: options.reconnect });
|
|
195
|
+
terminal.connecting();
|
|
196
|
+
if (!options.background) {
|
|
197
|
+
const started = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
198
|
+
let lastId = null;
|
|
199
|
+
const reporter = createEventReporter(terminal);
|
|
200
|
+
const mark = data => {
|
|
201
|
+
saveJson(stateFile, { pid: process.pid, started, version: VERSION, updated: new Date().toISOString(), ...data });
|
|
202
|
+
if (data.status === 'connected') {
|
|
203
|
+
reporter.clearProblem();
|
|
204
|
+
if (!lastId) terminal.connected(data.id, mcpEndpoint(options.url));
|
|
205
|
+
else terminal.restored();
|
|
206
|
+
lastId = data.id;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
mark({ status: 'connecting', id: null });
|
|
210
|
+
try { await connectAgent(options, mark, key, reporter.event); }
|
|
211
|
+
finally {
|
|
212
|
+
if (sameSession(process.pid, started)) saveJson(stateFile, { pid: process.pid, started, version: VERSION, status: 'stopped', id: lastId, updated: new Date().toISOString() });
|
|
213
|
+
terminal.stopped();
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
164
217
|
const fd = openSync(logFile, 'a', 0o600);
|
|
165
218
|
const started = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
166
219
|
let child;
|
|
@@ -178,14 +231,13 @@ async function start(options) {
|
|
|
178
231
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
179
232
|
const state = readJson(stateFile);
|
|
180
233
|
if (state?.pid === child.pid && state.started === started && state.status === 'connected' && state.id) {
|
|
181
|
-
|
|
234
|
+
terminal.connected(state.id, mcpEndpoint(options.url));
|
|
182
235
|
connected = true;
|
|
183
236
|
break;
|
|
184
237
|
}
|
|
185
|
-
if (!alive(child.pid) || (state?.pid === child.pid && state.status === 'stopped')) throw new Error(
|
|
238
|
+
if (!alive(child.pid) || (state?.pid === child.pid && state.status === 'stopped')) throw new Error('Agent exited unexpectedly. Inspect logs with zback --logs');
|
|
186
239
|
}
|
|
187
|
-
if (!connected)
|
|
188
|
-
if (!options.background) await follow();
|
|
240
|
+
if (!connected) terminal.hint(`Agent started (PID ${child.pid}). Run zback --status to inspect the connection.`);
|
|
189
241
|
}
|
|
190
242
|
|
|
191
243
|
function stop() {
|
|
@@ -213,7 +265,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
213
265
|
if (options.action === 'follow') { showStatus(); return follow(); }
|
|
214
266
|
if (options.action === 'reset-key') return rotate();
|
|
215
267
|
return await start(options);
|
|
216
|
-
} catch (error) {
|
|
268
|
+
} catch (error) { createTerminal().error(error.message); process.exitCode = 1; }
|
|
217
269
|
}
|
|
218
270
|
|
|
219
271
|
if (process.argv[1] && realpathSync(process.argv[1]) === ENTRY) main();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zback",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Opt-in remote shell administration with a persistent WebSocket agent
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "Opt-in foreground remote shell administration with a persistent WebSocket agent and private session credential",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"zback": "bin/zback.js"
|
package/src/agent.js
CHANGED
|
@@ -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;
|
|
@@ -54,16 +53,15 @@ 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 = () => {}) {
|
|
58
57
|
let stopped = false;
|
|
59
58
|
let retries = 0;
|
|
60
59
|
let socket;
|
|
61
60
|
let cancelDelay;
|
|
62
|
-
let
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
};
|
|
61
|
+
let lastId = null;
|
|
62
|
+
let lastError = null;
|
|
63
|
+
const update = (status, data = {}) => onState({ status, ...data });
|
|
64
|
+
const emit = event => { try { onEvent(event); } catch { /* UI failures must not interrupt command execution. */ } };
|
|
67
65
|
const shutdown = () => {
|
|
68
66
|
stopped = true;
|
|
69
67
|
if (cancelDelay) cancelDelay();
|
|
@@ -75,7 +73,7 @@ export async function connectAgent(options, onState, token) {
|
|
|
75
73
|
try {
|
|
76
74
|
while (!stopped) {
|
|
77
75
|
try {
|
|
78
|
-
update(retries ? 'reconnecting' : 'connecting');
|
|
76
|
+
update(retries ? 'reconnecting' : 'connecting', { id: lastId });
|
|
79
77
|
const current = new WebSocket(options.url);
|
|
80
78
|
socket = current;
|
|
81
79
|
await new Promise((resolve, reject) => {
|
|
@@ -94,14 +92,31 @@ export async function connectAgent(options, onState, token) {
|
|
|
94
92
|
const messageListener = event => {
|
|
95
93
|
let message;
|
|
96
94
|
try { message = JSON.parse(event.data); }
|
|
97
|
-
catch {
|
|
98
|
-
if (message.type === 'welcome'
|
|
95
|
+
catch { emit({ type: 'protocol-warning', problem: 'Received an invalid server message' }); return; }
|
|
96
|
+
if (message.type === 'welcome') {
|
|
99
97
|
if (welcomed) return;
|
|
98
|
+
if (message.protocolVersion !== 2 || typeof message.id !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(message.id)) {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
current.close(1000, 'Incompatible protocol');
|
|
101
|
+
const mismatch = new Error('Incompatible backend. Deploy ZBack backend 1.2.0 or later; this server issues legacy session IDs.');
|
|
102
|
+
mismatch.code = 'INCOMPATIBLE_BACKEND';
|
|
103
|
+
reject(mismatch);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (lastId && message.id !== lastId) {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
current.close(1000, 'Session identifier changed');
|
|
109
|
+
const mismatch = new Error('The server returned a different session identifier after reconnecting');
|
|
110
|
+
mismatch.code = 'SESSION_MISMATCH';
|
|
111
|
+
reject(mismatch);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
100
114
|
welcomed = true;
|
|
101
115
|
clearTimeout(timer);
|
|
102
116
|
retries = 0;
|
|
117
|
+
lastId = message.id;
|
|
118
|
+
lastError = null;
|
|
103
119
|
update('connected', { id: message.id });
|
|
104
|
-
log('Connected and ready for authorized requests');
|
|
105
120
|
return;
|
|
106
121
|
}
|
|
107
122
|
if (!welcomed || message.type !== 'command' || typeof message.requestId !== 'string') return;
|
|
@@ -110,26 +125,27 @@ export async function connectAgent(options, onState, token) {
|
|
|
110
125
|
const command = message.command;
|
|
111
126
|
if (typeof command !== 'string' || !command.trim() || command.length > 4096 || command.includes('\0')) return;
|
|
112
127
|
const timeout = Math.min(options.timeout, Number.isInteger(message.timeoutMs) && message.timeoutMs > 0 ? message.timeoutMs : options.timeout);
|
|
113
|
-
log(`Running authorized request ${message.requestId}`);
|
|
114
128
|
const result = await execute(command, options.cwd, timeout, options.maxOutput);
|
|
115
|
-
|
|
129
|
+
emit({ type: 'command-result', command, result });
|
|
116
130
|
if (current.readyState === WebSocket.OPEN && !stopped) current.send(JSON.stringify({ type: 'result', requestId: message.requestId, ...result }));
|
|
117
|
-
}).catch(error =>
|
|
131
|
+
}).catch(error => emit({ type: 'agent-error', problem: `Request processing failed: ${error.message}` }));
|
|
118
132
|
};
|
|
119
133
|
current.addEventListener('message', messageListener);
|
|
120
134
|
current.addEventListener('close', event => {
|
|
121
135
|
clearTimeout(timer);
|
|
122
136
|
if (!welcomed) reject(new Error(`Backend rejected connection (WebSocket ${event.code}: ${event.reason || 'no reason'})`));
|
|
123
|
-
else {
|
|
137
|
+
else { if (!stopped) emit({ type: 'connection-lost', problem: 'Connection lost' }); resolve(); }
|
|
124
138
|
}, { once: true });
|
|
125
139
|
current.addEventListener('error', () => { if (!welcomed) { clearTimeout(timer); reject(new Error('WebSocket connection failed')); } }, { once: true });
|
|
126
140
|
});
|
|
127
|
-
} catch (error) {
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (!stopped && ['INCOMPATIBLE_BACKEND', 'SESSION_MISMATCH'].includes(error.code)) throw error;
|
|
143
|
+
if (!stopped && error.message !== lastError) { emit({ type: 'connection-error', problem: error.message }); lastError = error.message; }
|
|
144
|
+
}
|
|
128
145
|
killRunningCommands();
|
|
129
146
|
if (stopped || !options.reconnect) break;
|
|
130
|
-
update('reconnecting', { id:
|
|
147
|
+
update('reconnecting', { id: lastId });
|
|
131
148
|
const delay = Math.min(30000, 1000 * 2 ** Math.min(retries++, 5));
|
|
132
|
-
log(`Retrying connection in ${Math.ceil(delay / 1000)}s`);
|
|
133
149
|
await new Promise(resolve => {
|
|
134
150
|
const timer = setTimeout(() => { cancelDelay = null; resolve(); }, delay);
|
|
135
151
|
cancelDelay = () => { clearTimeout(timer); cancelDelay = null; resolve(); };
|
|
@@ -138,6 +154,6 @@ export async function connectAgent(options, onState, token) {
|
|
|
138
154
|
} finally {
|
|
139
155
|
process.removeListener('SIGINT', shutdown);
|
|
140
156
|
process.removeListener('SIGTERM', shutdown);
|
|
141
|
-
update('stopped', { id:
|
|
157
|
+
update('stopped', { id: lastId });
|
|
142
158
|
}
|
|
143
159
|
}
|
package/src/runtime.js
CHANGED
|
@@ -46,7 +46,7 @@ export function alive(pid) {
|
|
|
46
46
|
try {
|
|
47
47
|
if (readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1]?.[0] === 'Z') return false;
|
|
48
48
|
const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
|
49
|
-
if (
|
|
49
|
+
if (!/(?:^|[\x00/\\])zback(?:\.js)?(?:\x00|$)/.test(cmd)) return false;
|
|
50
50
|
} catch {}
|
|
51
51
|
}
|
|
52
52
|
return true;
|
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
|
+
}
|