zback 1.1.1 → 1.3.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 ADDED
@@ -0,0 +1,14 @@
1
+ ZBack is an opt-in remote shell administration tool for devices owned or explicitly authorized by the operator. It supports sending a shell command to a local Node.js agent through a WebSocket relay using a private, high-entropy session capability and the Model Context Protocol (MCP).
2
+
3
+ Security-sensitive functionality:
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
+ - A background Node.js worker may persist after the invoking terminal detaches. It can be stopped with `zback --stop`.
6
+ - Remote shell commands execute with the operating-system permissions of the account that launched the agent.
7
+ - The worker connects outbound to the configured secure WebSocket endpoint. It does not open an inbound shell listener.
8
+ - The server exposes an MCP endpoint that routes authorized commands to connected workers when the caller presents the private session capability.
9
+ - A randomly generated credential stored with restrictive file permissions stabilizes the session identifier across reconnects. The identifier is a bearer credential and must be kept secret.
10
+ - The operator can inspect activity with `zback`, `zback --status`, `zback --logs`, and rotate the credential with `zback --reset-key` after stopping the agent.
11
+
12
+ The package has no install-time scripts, no hidden persistence or system service installation, no credential collection, no code obfuscation, and no automatic execution on installation. Remote shell access is a powerful capability and should only be enabled on systems the operator is authorized to manage.
13
+
14
+ The separately deployed ZBack backend should be exposed via HTTPS/WSS, behind a TLS-terminating reverse proxy. Public access does not imply authorization to use another person's session. Access is controlled by possession of the private session ID.
package/README.md CHANGED
@@ -1,40 +1,69 @@
1
1
  # ZBack CLI
2
2
 
3
- Global command for Node.js 22+. No npm dependencies, login, API key, or password.
3
+ ZBack is a deliberately 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
- ## Install
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
- ```bash
8
- npm install -g .
7
+ ## Install and start
8
+
9
+ ```sh
10
+ npm install -g zback@1.2.0
9
11
  zback
10
12
  ```
11
13
 
12
- The first time, ZBack asks exactly once for permission to access **your own shell**. Answer `y` to run it as a detached background process. When connected, it prints:
14
+ On the first run, ZBack explains the remote-shell capability and asks for authorization once. It stores that decision locally, then creates a private agent credential. There are no prompts for each command.
13
15
 
14
- ```
15
- Okay, here's your session ID "..."
16
+ The default server is `wss://zb.soymaycol.icu/ws`. To use your own backend:
17
+
18
+ ```sh
19
+ zback --url wss://your-domain.example/ws
16
20
  ```
17
21
 
18
- Closing the terminal does not normally stop the agent. Later calls to `zback` show its current session ID without requesting permission again. To view the connection state or terminate the worker:
22
+ For development only:
19
23
 
20
- ```bash
21
- zback --status
22
- zback --stop
24
+ ```sh
25
+ zback --url ws://127.0.0.1:8787/ws
23
26
  ```
24
27
 
25
- Advanced options at first launch (or after stopping it):
28
+ The client and backend must both be **version 1.2.0** because the connection handshake changed. The backend must be deployed before a remote client can connect.
29
+
30
+ ## Terminal and process lifecycle
31
+
32
+ | Command | Behavior |
33
+ | --- | --- |
34
+ | `zback` | Start if needed and attach the terminal to a live event display |
35
+ | `zback --background` | Start the agent without keeping a live terminal display |
36
+ | `zback --status` | Show connection status, process ID and private session ID |
37
+ | `zback --logs` | Display recent connection and command activity |
38
+ | `zback --follow` | Attach to live events from the existing background agent |
39
+ | `zback --stop` | Stop the background agent |
40
+ | `zback --reset-key` | Change the device identity after the agent is stopped |
41
+
42
+ `Ctrl+C` while viewing events **detaches the display**, but the agent stays running. Use `zback --stop` to disconnect. The server session ID stays the same over ordinary network reconnections and backend restarts, as long as the private local agent credential remains unchanged.
26
43
 
27
- ```bash
28
- zback --url wss://zb.soymaycol.icu/ws --cwd /path/to/project
29
- zback --timeout 120000 --max-output 131072
44
+ 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
+
46
+ ## Configuration
47
+
48
+ ```sh
49
+ zback --cwd ./workspace --timeout 300000 --max-output 524288
50
+ zback --url wss://your-domain.example/ws --background
30
51
  ```
31
52
 
32
- Local development: `zback --url ws://127.0.0.1:8787/ws`.
53
+ The client validates TLS on remote connections. Only loopback addresses are allowed for unencrypted `ws://`. Server resource protection and command timeouts are operational safety controls, not per-user paid quotas.
54
+
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.
33
56
 
34
- The agent automatically reconnects, potentially receiving a **new session ID**; check `zback --status` if the previous ID stops working. There are no per-command approvals after initial permission. The session ID grants anyone who possesses it remote shell access for the lifetime of that session; **keep it private**. TLS (`wss://`) is required remotely.
57
+ ## MCP
35
58
 
36
- Status, consent, and log files are stored in the OS user state directory (`~/.local/state/zback` on most Linux systems or `%LOCALAPPDATA%/zback` on Windows). They are local and not published by the backend. Optionally set `ZBACK_STATE_DIR` to change this location.
59
+ The companion backend serves `POST /mcp` with `zback_status` and `zback_execute`. The client prints a **private** session ID for passing to these tools. 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.
37
60
 
38
- Closing a terminal normally leaves the detached worker running. Force-stopping an Android app, battery restrictions, shutdown or network loss may still terminate or interrupt it; this CLI does not auto-start itself on device boot.
61
+ ## Security disclosure and review
39
62
 
40
- Test with `npm test`.
63
+ The npm package declares its remote administration capabilities as dual-use software in `contentPolicy` and the root `DISCLOSURE` file. It does not attempt to conceal shell execution, evade security scanning or override npm's decisions. Review the source before running, and follow npm's official manual appeal process if a publication is flagged.
64
+
65
+ ## Tests
66
+
67
+ ```sh
68
+ npm test
69
+ ```
package/bin/zback.js CHANGED
@@ -1,34 +1,57 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
- import { openSync } from 'node:fs';
3
+ import { closeSync, existsSync, openSync, readSync, realpathSync, statSync, watchFile, unwatchFile } from 'node:fs';
4
4
  import { resolve } from 'node:path';
5
5
  import { createInterface } from 'node:readline/promises';
6
6
  import { stdin, stdout } from 'node:process';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { existsSync, statSync } from 'node:fs';
9
8
  import { connectAgent, execute } from '../src/agent.js';
10
- import { activeSession, alive, consentFile, hasConsent, initStateDir, logFile, readJson, sameSession, saveJson, settingsFile, stateFile } from '../src/runtime.js';
9
+ import { activeSession, agentKey, consentFile, hasConsent, initStateDir, logFile, readJson, resetKey, sameSession, saveJson, settingsFile, stateFile, alive } from '../src/runtime.js';
11
10
 
12
11
  const ENTRY = fileURLToPath(import.meta.url);
12
+ const VERSION = '1.2.0';
13
13
  const DEFAULT_URL = 'wss://zb.soymaycol.icu/ws';
14
14
 
15
15
  function help() {
16
- console.log(`ZBack 1.1.0\n\nUsage: zback [options]\n\n --url <wss://.../ws> WebSocket endpoint (default: ${DEFAULT_URL})\n --cwd <directory> Directory for shell commands\n --timeout <ms> Command timeout (1000-300000)\n --max-output <bytes> Maximum command output (1024-262144)\n --no-reconnect Disable automatic reconnection\n --status Show background agent status and session ID\n --stop Stop background agent\n --help Display help\n\nThe first launch asks for shell access permission only once.\nThe agent runs in the background until stopped.`);
16
+ console.log(`ZBack ${VERSION} | Authorized remote shell administration
17
+
18
+ Usage:
19
+ zback Start the agent and display live events
20
+ zback --background Start without attaching the terminal
21
+ zback --status Show agent status, PID and private session ID
22
+ zback --logs Show recent agent events
23
+ zback --follow Follow live events from a running agent
24
+ zback --stop Stop the agent
25
+ zback --reset-key Rotate local agent identity while stopped
26
+
27
+ Options:
28
+ --url <wss://.../ws> Server endpoint (default: ${DEFAULT_URL})
29
+ --cwd <directory> Working directory for commands
30
+ --timeout <ms> Command timeout (1000-3600000)
31
+ --max-output <bytes> Captured output (1024-786432)
32
+ --no-reconnect Stop after a connection is lost
33
+ --help Display this help
34
+
35
+ ZBack runs shell commands with your account privileges. Initial interactive
36
+ consent is required once, not for every command. Keep the session ID private.
37
+ Ctrl+C detaches the live display; the agent continues running.
38
+ Use --stop to actually disconnect the agent.`);
17
39
  }
18
40
 
19
41
  export function parseArgs(args) {
20
- const options = { url: DEFAULT_URL, cwd: process.cwd(), timeout: 30000, maxOutput: 65536, reconnect: true };
42
+ const options = { url: DEFAULT_URL, cwd: process.cwd(), timeout: 120000, maxOutput: 262144, reconnect: true, background: false };
21
43
  const values = new Set(['--url', '--cwd', '--timeout', '--max-output']);
22
44
  let action = 'start';
23
45
  for (let i = 0; i < args.length; i++) {
24
46
  const flag = args[i];
25
47
  if (flag === '--help' || flag === '-h') return { help: true };
26
48
  if (flag === '--worker') return { worker: true };
27
- if (flag === '--status' || flag === '--stop') {
28
- if (action !== 'start') throw new Error('Specify one action');
49
+ if (['--status', '--stop', '--logs', '--follow', '--reset-key'].includes(flag)) {
50
+ if (action !== 'start') throw new Error('Choose only one action');
29
51
  action = flag.slice(2);
30
52
  continue;
31
53
  }
54
+ if (flag === '--background' || flag === '--detach') { options.background = true; continue; }
32
55
  if (flag === '--no-reconnect') { options.reconnect = false; continue; }
33
56
  if (!values.has(flag) || i + 1 >= args.length) throw new Error(`Unknown or incomplete argument: ${flag}`);
34
57
  const value = args[++i];
@@ -38,21 +61,23 @@ export function parseArgs(args) {
38
61
  if (flag === '--max-output') options.maxOutput = Number(value);
39
62
  }
40
63
  if (action !== 'start') return { action };
41
- if (!Number.isSafeInteger(options.timeout) || options.timeout < 1000 || options.timeout > 300000) throw new Error('--timeout must be between 1000 and 300000 ms');
42
- if (!Number.isSafeInteger(options.maxOutput) || options.maxOutput < 1024 || options.maxOutput > 262144) throw new Error('--max-output must be between 1024 and 262144 bytes');
64
+ if (!Number.isSafeInteger(options.timeout) || options.timeout < 1000 || options.timeout > 3600000) throw new Error('--timeout must be between 1000 and 3600000 ms');
65
+ if (!Number.isSafeInteger(options.maxOutput) || options.maxOutput < 1024 || options.maxOutput > 786432) throw new Error('--max-output must be between 1024 and 786432 bytes');
43
66
  if (!existsSync(options.cwd) || !statSync(options.cwd).isDirectory()) throw new Error('--cwd must be an existing directory');
44
67
  const url = new URL(options.url);
45
68
  if (url.protocol !== 'wss:' && !(url.protocol === 'ws:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))) throw new Error('Use wss:// for remote connections or ws:// on localhost');
46
- if (url.username || url.password || url.search || url.hash || url.pathname !== '/ws') throw new Error('Expected a WebSocket URL ending in /ws');
69
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/ws') throw new Error('Expected an endpoint ending in /ws without credentials in the URL');
47
70
  return { ...options, action };
48
71
  }
49
72
 
50
73
  async function confirmOnce() {
51
74
  if (hasConsent()) return true;
52
- if (!stdin.isTTY || !stdout.isTTY) throw new Error('An interactive terminal is required for initial permission.');
75
+ if (!stdin.isTTY || !stdout.isTTY) throw new Error('First-run shell authorization requires an interactive terminal');
76
+ console.log('ZBack permits remote shell commands through a private session credential.');
77
+ console.log('These commands run as your current user. Do not share your session ID.');
53
78
  const rl = createInterface({ input: stdin, output: stdout });
54
79
  try {
55
- const answer = await rl.question('This module accesses your shell. Do you allow it to use your shell? Permission will not be requested again. [y/N] ');
80
+ const answer = await rl.question('Authorize remote shell administration on this device? [y/N] ');
56
81
  if (!['y', 'yes'].includes(answer.trim().toLowerCase())) return false;
57
82
  saveJson(consentFile, { approved: true, at: new Date().toISOString() });
58
83
  return true;
@@ -60,38 +85,81 @@ async function confirmOnce() {
60
85
  }
61
86
 
62
87
  async function worker() {
63
- if (!hasConsent()) throw new Error('Permission has not been granted. Run zback interactively first.');
88
+ if (!hasConsent()) throw new Error('Shell access is not authorized. Run zback interactively first');
64
89
  const settings = readJson(settingsFile);
65
90
  if (!settings || !settings.url || !settings.cwd) throw new Error('Missing agent settings');
66
91
  const started = process.env.ZBACK_WORKER_NONCE;
67
- if (!started) throw new Error('Not a valid ZBack background worker');
92
+ if (!started) throw new Error('Worker must be launched through zback');
93
+ const key = agentKey();
68
94
  const mark = data => {
69
- const current = readJson(stateFile);
70
- if (current && current.status !== 'stopped' && current.pid !== process.pid && alive(current.pid)) return;
95
+ const previous = readJson(stateFile);
96
+ if (previous && previous.status !== 'stopped' && previous.pid !== process.pid && alive(previous.pid)) return;
71
97
  saveJson(stateFile, { pid: process.pid, started, updated: new Date().toISOString(), ...data });
72
98
  };
73
99
  mark({ status: 'connecting', id: null });
74
- const hardStop = () => {
75
- if (sameSession(process.pid, started)) mark({ status: 'stopped', id: null });
76
- setTimeout(() => process.exit(0), 150).unref();
77
- };
78
- process.once('SIGTERM', hardStop);
79
- process.once('SIGINT', hardStop);
80
- try { await connectAgent(settings, mark); }
81
- finally {
82
- if (sameSession(process.pid, started)) mark({ status: 'stopped', id: null });
83
- }
100
+ try { await connectAgent(settings, mark, key); }
101
+ finally { if (sameSession(process.pid, started)) mark({ status: 'stopped', id: null }); }
102
+ }
103
+
104
+ function showStatus() {
105
+ const state = activeSession();
106
+ if (!state) { console.log('Status: stopped'); return false; }
107
+ console.log(`Status: ${state.status}\nPID: ${state.pid}${state.id ? `\nSession ID: ${state.id}` : ''}\nUpdated: ${state.updated || 'unknown'}`);
108
+ return true;
109
+ }
110
+
111
+ function recentLog() {
112
+ if (!existsSync(logFile)) { console.log('No agent logs yet'); return; }
113
+ const fd = openSync(logFile, 'r');
114
+ try {
115
+ const size = statSync(logFile).size;
116
+ const readSize = Math.min(size, 12000);
117
+ const buffer = Buffer.alloc(readSize);
118
+ readSync(fd, buffer, 0, readSize, size - readSize);
119
+ console.log(buffer.toString('utf8').replace(/^.*?\n/, size > readSize ? '' : '$&').trimEnd());
120
+ } finally { closeSync(fd); }
121
+ }
122
+
123
+ async function follow() {
124
+ if (!stdin.isTTY || !stdout.isTTY) return;
125
+ console.log('Live agent events. Ctrl+C detaches; the agent stays active.');
126
+ recentLog();
127
+ let position = existsSync(logFile) ? statSync(logFile).size : 0;
128
+ await new Promise(resolve => {
129
+ const readNew = () => {
130
+ try {
131
+ if (!existsSync(logFile)) return;
132
+ const size = statSync(logFile).size;
133
+ if (size < position) position = 0;
134
+ if (size === position) return;
135
+ const fd = openSync(logFile, 'r');
136
+ try {
137
+ const bytes = Math.min(size - position, 512 * 1024);
138
+ const buffer = Buffer.alloc(bytes);
139
+ readSync(fd, buffer, 0, bytes, position);
140
+ position += bytes;
141
+ stdout.write(buffer.toString('utf8'));
142
+ } finally { closeSync(fd); }
143
+ } catch (error) { console.error(`Log monitor: ${error.message}`); }
144
+ };
145
+ const stop = () => { unwatchFile(logFile, readNew); process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop); console.log('\nDetached. Agent continues in the background.'); resolve(); };
146
+ watchFile(logFile, { interval: 400 }, readNew);
147
+ process.once('SIGINT', stop);
148
+ process.once('SIGTERM', stop);
149
+ readNew();
150
+ });
84
151
  }
85
152
 
86
153
  async function start(options) {
87
- const running = activeSession();
88
- if (running) {
89
- console.log(running.id ? `Okay, here's your session ID "${running.id}"` : `ZBack is ${running.status}.`);
90
- console.log('Use zback --status to inspect the agent or zback --stop to stop it.');
154
+ const existing = activeSession();
155
+ if (existing) {
156
+ console.log(existing.id ? `Session ID: ${existing.id}` : `ZBack is ${existing.status} (PID ${existing.pid}).`);
157
+ if (!options.background) await follow();
91
158
  return;
92
159
  }
93
- if (!(await confirmOnce())) { console.log('Permission declined.'); return; }
160
+ if (!(await confirmOnce())) { console.log('Authorization declined'); return; }
94
161
  initStateDir();
162
+ agentKey();
95
163
  saveJson(settingsFile, { url: options.url, cwd: options.cwd, timeout: options.timeout, maxOutput: options.maxOutput, reconnect: options.reconnect });
96
164
  const fd = openSync(logFile, 'a', 0o600);
97
165
  const started = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
@@ -103,37 +171,34 @@ async function start(options) {
103
171
  env: { ...process.env, ZBACK_WORKER_NONCE: started }
104
172
  });
105
173
  child.unref();
106
- } finally {
107
- const { closeSync } = await import('node:fs');
108
- closeSync(fd);
109
- }
110
- if (!child?.pid) throw new Error('Could not launch background process');
111
- for (let i = 0; i < 80; i++) {
112
- await new Promise(r => setTimeout(r, 100));
174
+ } finally { closeSync(fd); }
175
+ if (!child?.pid) throw new Error('Unable to start background agent');
176
+ let connected = false;
177
+ for (let i = 0; i < 60; i++) {
178
+ await new Promise(resolve => setTimeout(resolve, 100));
113
179
  const state = readJson(stateFile);
114
- if (state?.pid === child.pid && state.started === started && state.id && state.status === 'connected') {
115
- console.log(`Okay, here's your session ID "${state.id}"`);
116
- return;
117
- }
118
- if (!alive(child.pid) || (state?.pid === child.pid && state.status === 'stopped')) {
119
- throw new Error(`Background process exited. See ${logFile}`);
180
+ if (state?.pid === child.pid && state.started === started && state.status === 'connected' && state.id) {
181
+ console.log(`Session ID: ${state.id}`);
182
+ connected = true;
183
+ break;
120
184
  }
185
+ if (!alive(child.pid) || (state?.pid === child.pid && state.status === 'stopped')) throw new Error(`Agent exited unexpectedly. Inspect logs with zback --logs`);
121
186
  }
122
- console.log(`Background agent started (PID ${child.pid}); waiting for the server.`);
123
- console.log('Use zback --status to see the session ID once connected.');
124
- }
125
-
126
- function status() {
127
- const state = activeSession();
128
- if (!state) return console.log('ZBack is stopped.');
129
- console.log(`Status: ${state.status}\nPID: ${state.pid}${state.id ? `\nSession ID: ${state.id}` : ''}`);
187
+ if (!connected) console.log(`Agent started (PID ${child.pid}). Waiting for backend; use zback --status for current state.`);
188
+ if (!options.background) await follow();
130
189
  }
131
190
 
132
191
  function stop() {
133
192
  const state = activeSession();
134
- if (!state) return console.log('ZBack is not running.');
193
+ if (!state) { console.log('Agent is already stopped'); return; }
135
194
  process.kill(state.pid, 'SIGTERM');
136
- console.log('ZBack stop requested.');
195
+ console.log(`Stop requested for agent PID ${state.pid}`);
196
+ }
197
+
198
+ function rotate() {
199
+ if (activeSession()) throw new Error('Stop the agent using zback --stop before rotating its credential');
200
+ resetKey();
201
+ console.log('Agent credential rotated. A new session ID will be assigned when it connects.');
137
202
  }
138
203
 
139
204
  export { execute };
@@ -142,10 +207,13 @@ export async function main(args = process.argv.slice(2)) {
142
207
  const options = parseArgs(args);
143
208
  if (options.help) return help();
144
209
  if (options.worker) return worker();
145
- if (options.action === 'status') return status();
210
+ if (options.action === 'status') return showStatus();
146
211
  if (options.action === 'stop') return stop();
212
+ if (options.action === 'logs') return recentLog();
213
+ if (options.action === 'follow') { showStatus(); return follow(); }
214
+ if (options.action === 'reset-key') return rotate();
147
215
  return await start(options);
148
- } catch (error) { console.error(`Error: ${error.message}`); process.exitCode = 1; }
216
+ } catch (error) { console.error(`ZBack error: ${error.message}`); process.exitCode = 1; }
149
217
  }
150
218
 
151
- if (process.argv[1] && resolve(process.argv[1]) === ENTRY) main();
219
+ 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.1.1",
4
- "description": "Background WebSocket shell agent with one-time local consent",
3
+ "version": "1.3.0",
4
+ "description": "Opt-in remote shell administration with a persistent WebSocket agent, audit logs and a private session credential",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "zback": "bin/zback.js"
@@ -12,5 +12,11 @@
12
12
  "scripts": {
13
13
  "test": "node --test"
14
14
  },
15
- "license": "MIT"
15
+ "contentPolicy": {
16
+ "class": "dual-use"
17
+ },
18
+ "keywords": ["remote-administration", "websocket", "cli", "mcp", "opt-in"],
19
+ "files": ["bin/", "src/", "README.md", "DISCLOSURE", "LICENSE"],
20
+ "license": "MIT",
21
+ "author": "SoyMaycol"
16
22
  }
package/src/agent.js CHANGED
@@ -2,6 +2,7 @@ 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}`);
5
6
 
6
7
  function killTree(child) {
7
8
  if (!child?.pid) return;
@@ -24,17 +25,13 @@ export function execute(command, cwd, timeoutMs, maxOutput) {
24
25
  try {
25
26
  child = spawn(command, { cwd, shell: true, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
26
27
  children.add(child);
27
- } catch (err) {
28
- resolveResult({ ok: false, code: null, stdout: '', stderr: String(err.message) });
29
- return;
30
- }
28
+ } catch (err) { resolveResult({ ok: false, code: null, stdout: '', stderr: String(err.message) }); return; }
31
29
  const add = (kind, chunk) => {
32
30
  const remaining = maxOutput - collected;
33
31
  if (remaining <= 0) { truncated = true; return; }
34
32
  if (chunk.length > remaining) truncated = true;
35
- const part = chunk.subarray(0, remaining);
36
- collected += part.length;
37
- chunks[kind].push(part);
33
+ chunks[kind].push(chunk.subarray(0, remaining));
34
+ collected += Math.min(chunk.length, remaining);
38
35
  };
39
36
  child.stdout.on('data', b => add('stdout', b));
40
37
  child.stderr.on('data', b => add('stderr', b));
@@ -44,55 +41,103 @@ export function execute(command, cwd, timeoutMs, maxOutput) {
44
41
  finished = true;
45
42
  children.delete(child);
46
43
  clearTimeout(timer);
47
- resolveResult({ ok: code === 0 && !timedOut, code: Number.isInteger(code) ? code : null,
44
+ resolveResult({
45
+ ok: code === 0 && !timedOut,
46
+ code: Number.isInteger(code) ? code : null,
48
47
  stdout: Buffer.concat(chunks.stdout).toString('utf8'),
49
48
  stderr: Buffer.concat(chunks.stderr).toString('utf8') + message,
50
- truncated, timedOut });
49
+ truncated, timedOut
50
+ });
51
51
  };
52
52
  child.once('error', error => finish(null, `\n${error.message}`));
53
53
  child.once('close', code => finish(code));
54
54
  });
55
55
  }
56
56
 
57
- export async function connectAgent(options, onState) {
58
- let stopped = false, retries = 0, socket;
59
- const shutdown = () => { stopped = true; try { socket?.close(); } catch {} killRunningCommands(); };
57
+ export async function connectAgent(options, onState, token) {
58
+ let stopped = false;
59
+ let retries = 0;
60
+ let socket;
61
+ let cancelDelay;
62
+ let currentState = '';
63
+ const update = (status, data = {}) => {
64
+ onState({ status, ...data });
65
+ if (currentState !== status) { log(`Agent status: ${status}`); currentState = status; }
66
+ };
67
+ const shutdown = () => {
68
+ stopped = true;
69
+ if (cancelDelay) cancelDelay();
70
+ try { socket?.close(1000, 'Agent stopped'); } catch {}
71
+ killRunningCommands();
72
+ };
60
73
  process.once('SIGINT', shutdown);
61
74
  process.once('SIGTERM', shutdown);
62
- while (!stopped) {
63
- try {
64
- socket = new WebSocket(options.url);
65
- await new Promise((resolve, reject) => {
66
- socket.addEventListener('open', resolve, { once: true });
67
- socket.addEventListener('error', () => reject(new Error('Connection failed')), { once: true });
68
- });
69
- if (stopped) break;
70
- retries = 0;
71
- onState({ status: 'connected', id: null });
72
- let queue = Promise.resolve();
73
- socket.addEventListener('message', event => {
74
- let message;
75
- try { message = JSON.parse(event.data); } catch { return; }
76
- if (message.type === 'welcome' && typeof message.id === 'string') {
77
- onState({ status: 'connected', id: message.id });
78
- console.log(`[zback] Connected; session ${message.id}`);
79
- }
80
- if (message.type === 'command' && typeof message.requestId === 'string') {
81
- queue = queue.then(async () => {
82
- const command = message.command;
83
- if (typeof command !== 'string' || !command.trim() || command.length > 1000 || command.includes('\0')) return;
84
- const timeout = Math.min(options.timeout, Number.isInteger(message.timeoutMs) && message.timeoutMs > 0 ? message.timeoutMs : options.timeout);
85
- console.log(`[zback] Executing request ${message.requestId}`);
86
- const result = await execute(command, options.cwd, timeout, options.maxOutput);
87
- if (socket.readyState === WebSocket.OPEN && !stopped) socket.send(JSON.stringify({ type: 'result', requestId: message.requestId, ...result }));
88
- }).catch(error => console.error(`[zback] Request failed: ${error.message}`));
89
- }
75
+ try {
76
+ while (!stopped) {
77
+ try {
78
+ update(retries ? 'reconnecting' : 'connecting');
79
+ const current = new WebSocket(options.url);
80
+ socket = current;
81
+ await new Promise((resolve, reject) => {
82
+ const timer = setTimeout(() => { try { current.close(); } catch {} reject(new Error('Connection timed out')); }, 15000);
83
+ const success = () => { clearTimeout(timer); resolve(); };
84
+ const failure = () => { clearTimeout(timer); reject(new Error('Could not reach backend')); };
85
+ current.addEventListener('open', success, { once: true });
86
+ current.addEventListener('error', failure, { once: true });
87
+ });
88
+ if (stopped) break;
89
+ current.send(JSON.stringify({ type: 'hello', protocolVersion: 2, token }));
90
+ await new Promise((resolve, reject) => {
91
+ let welcomed = false;
92
+ const timer = setTimeout(() => { try { current.close(); } catch {} reject(new Error('Server handshake timed out')); }, 10000);
93
+ let queue = Promise.resolve();
94
+ const messageListener = event => {
95
+ let message;
96
+ try { message = JSON.parse(event.data); }
97
+ catch { log('Ignored invalid server message'); return; }
98
+ if (message.type === 'welcome' && typeof message.id === 'string') {
99
+ if (welcomed) return;
100
+ welcomed = true;
101
+ clearTimeout(timer);
102
+ retries = 0;
103
+ update('connected', { id: message.id });
104
+ log('Connected and ready for authorized requests');
105
+ return;
106
+ }
107
+ if (!welcomed || message.type !== 'command' || typeof message.requestId !== 'string') return;
108
+ queue = queue.then(async () => {
109
+ if (stopped || current.readyState !== WebSocket.OPEN) return;
110
+ const command = message.command;
111
+ if (typeof command !== 'string' || !command.trim() || command.length > 4096 || command.includes('\0')) return;
112
+ 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
+ const result = await execute(command, options.cwd, timeout, options.maxOutput);
115
+ log(`Request ${message.requestId}: ${result.timedOut ? 'timed out' : result.ok ? 'completed' : 'failed'}`);
116
+ if (current.readyState === WebSocket.OPEN && !stopped) current.send(JSON.stringify({ type: 'result', requestId: message.requestId, ...result }));
117
+ }).catch(error => log(`Request processing error: ${error.message}`));
118
+ };
119
+ current.addEventListener('message', messageListener);
120
+ current.addEventListener('close', event => {
121
+ clearTimeout(timer);
122
+ if (!welcomed) reject(new Error(`Backend rejected connection (WebSocket ${event.code}: ${event.reason || 'no reason'})`));
123
+ else { log(`Connection closed (${event.code}: ${event.reason || 'connection lost'})`); resolve(); }
124
+ }, { once: true });
125
+ current.addEventListener('error', () => { if (!welcomed) { clearTimeout(timer); reject(new Error('WebSocket connection failed')); } }, { once: true });
126
+ });
127
+ } catch (error) { if (!stopped) log(`Connection error: ${error.message}`); }
128
+ killRunningCommands();
129
+ if (stopped || !options.reconnect) break;
130
+ update('reconnecting', { id: null });
131
+ const delay = Math.min(30000, 1000 * 2 ** Math.min(retries++, 5));
132
+ log(`Retrying connection in ${Math.ceil(delay / 1000)}s`);
133
+ await new Promise(resolve => {
134
+ const timer = setTimeout(() => { cancelDelay = null; resolve(); }, delay);
135
+ cancelDelay = () => { clearTimeout(timer); cancelDelay = null; resolve(); };
90
136
  });
91
- await new Promise(resolve => socket.addEventListener('close', resolve, { once: true }));
92
- } catch (error) { if (!stopped) console.error(`[zback] ${error.message}`); }
93
- if (stopped || !options.reconnect) break;
94
- onState({ status: 'reconnecting', id: null });
95
- await wait(Math.min(30000, 1000 * 2 ** Math.min(retries++, 5)));
137
+ }
138
+ } finally {
139
+ process.removeListener('SIGINT', shutdown);
140
+ process.removeListener('SIGTERM', shutdown);
141
+ update('stopped', { id: null });
96
142
  }
97
- onState({ status: 'stopped', id: null });
98
143
  }
package/src/runtime.js CHANGED
@@ -1,4 +1,5 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
1
+ import { randomBytes } from 'node:crypto';
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
3
  import { homedir } from 'node:os';
3
4
  import { join } from 'node:path';
4
5
 
@@ -6,15 +7,35 @@ export const stateDir = process.env.ZBACK_STATE_DIR || join(process.env.XDG_STAT
6
7
  export const consentFile = join(stateDir, 'consent.json');
7
8
  export const stateFile = join(stateDir, 'session.json');
8
9
  export const settingsFile = join(stateDir, 'settings.json');
10
+ export const agentKeyFile = join(stateDir, 'agent.key');
9
11
  export const logFile = join(stateDir, 'zback.log');
10
12
 
11
13
  export function initStateDir() { mkdirSync(stateDir, { recursive: true, mode: 0o700 }); }
12
14
  export function readJson(file) { try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; } }
13
15
  export function saveJson(file, data) {
14
16
  initStateDir();
15
- const tmp = `${file}.${process.pid}.tmp`;
16
- writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
17
- renameSync(tmp, file);
17
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
18
+ try {
19
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
20
+ renameSync(tmp, file);
21
+ } finally { try { unlinkSync(tmp); } catch {} }
22
+ }
23
+ export function agentKey() {
24
+ initStateDir();
25
+ if (!existsSync(agentKeyFile)) {
26
+ const key = randomBytes(32).toString('base64url');
27
+ try {
28
+ const fd = openSync(agentKeyFile, 'wx', 0o600);
29
+ try { writeFileSync(fd, key + '\n'); } finally { closeSync(fd); }
30
+ } catch (error) { if (error.code !== 'EEXIST') throw error; }
31
+ }
32
+ const key = readFileSync(agentKeyFile, 'utf8').trim();
33
+ if (!/^[A-Za-z0-9_-]{43}$/.test(key)) throw new Error('Invalid agent credential file; run zback --reset-key while stopped');
34
+ return key;
35
+ }
36
+ export function resetKey() {
37
+ try { unlinkSync(agentKeyFile); } catch (error) { if (error.code !== 'ENOENT') throw error; }
38
+ return agentKey();
18
39
  }
19
40
  export function hasConsent() { return readJson(consentFile)?.approved === true; }
20
41
  export function alive(pid) {
@@ -22,11 +43,14 @@ export function alive(pid) {
22
43
  try {
23
44
  process.kill(pid, 0);
24
45
  if (process.platform === 'linux') {
25
- try { if (readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1]?.[0] === 'Z') return false; } catch {}
46
+ try {
47
+ if (readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1]?.[0] === 'Z') return false;
48
+ const cmd = readFileSync(`/proc/${pid}/cmdline`, 'utf8');
49
+ if (!cmd.includes('--worker') || !cmd.includes('zback.js')) return false;
50
+ } catch {}
26
51
  }
27
52
  return true;
28
- }
29
- catch (e) { return e.code === 'EPERM'; }
53
+ } catch (e) { return e.code === 'EPERM'; }
30
54
  }
31
55
  export function activeSession() {
32
56
  const data = readJson(stateFile);
@@ -36,4 +60,3 @@ export function sameSession(pid, started) {
36
60
  const data = readJson(stateFile);
37
61
  return data?.pid === pid && data?.started === started;
38
62
  }
39
- export function hasState() { return existsSync(stateFile); }
@@ -1,28 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { test } from 'node:test';
3
- import { parseArgs, execute } from '../bin/zback.js';
4
-
5
- test('default secure endpoint', () => {
6
- const args = parseArgs([]);
7
- assert.equal(args.url, 'wss://zb.soymaycol.icu/ws');
8
- assert.equal(args.reconnect, true);
9
- });
10
-
11
- test('unsafe remote ws URL blocked', () => {
12
- assert.throws(() => parseArgs(['--url', 'ws://example.com/ws']), /wss/);
13
- assert.equal(parseArgs(['--url', 'ws://127.0.0.1:8787/ws']).timeout, 30000);
14
- assert.throws(() => parseArgs(['--timeout', '0']), /timeout/);
15
- });
16
-
17
- test('subprocess respects success and returned output', async () => {
18
- const res = await execute('node -p "3*7"', process.cwd(), 5000, 1024);
19
- assert.equal(res.ok, true);
20
- assert.equal(res.stdout.trim(), '21');
21
- });
22
-
23
- test('subprocess output limit is enforced', async () => {
24
- const res = await execute('node -e "process.stdout.write(\'a\'.repeat(2500))"', process.cwd(), 5000, 1024);
25
- assert.equal(res.ok, true);
26
- assert.equal(res.stdout.length, 1024);
27
- assert.equal(res.truncated, true);
28
- });