edge-ai-client-ts 1.0.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/CHANGELOG.md +72 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/bin/ec-ts.js +18 -0
- package/dist/buffer/disk-queue.d.ts +140 -0
- package/dist/buffer/disk-queue.js +370 -0
- package/dist/cli/devices.d.ts +1 -0
- package/dist/cli/devices.js +61 -0
- package/dist/cli/enroll.d.ts +2 -0
- package/dist/cli/enroll.js +89 -0
- package/dist/cli/index.d.ts +10 -0
- package/dist/cli/index.js +116 -0
- package/dist/cli/messages.d.ts +1 -0
- package/dist/cli/messages.js +59 -0
- package/dist/cli/run.d.ts +5 -0
- package/dist/cli/run.js +112 -0
- package/dist/cli/status.d.ts +1 -0
- package/dist/cli/status.js +56 -0
- package/dist/cli/whoami.d.ts +2 -0
- package/dist/cli/whoami.js +41 -0
- package/dist/config/cmd-gate.d.ts +65 -0
- package/dist/config/cmd-gate.js +128 -0
- package/dist/config/settings.d.ts +209 -0
- package/dist/config/settings.js +627 -0
- package/dist/crypto/aes-gcm.d.ts +38 -0
- package/dist/crypto/aes-gcm.js +90 -0
- package/dist/crypto/hmac.d.ts +31 -0
- package/dist/crypto/hmac.js +52 -0
- package/dist/crypto/tls-guard.d.ts +36 -0
- package/dist/crypto/tls-guard.js +54 -0
- package/dist/daemon/manager.d.ts +82 -0
- package/dist/daemon/manager.js +461 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +63 -0
- package/dist/logging/file-logger.d.ts +21 -0
- package/dist/logging/file-logger.js +71 -0
- package/dist/network/ws-client.d.ts +221 -0
- package/dist/network/ws-client.js +1134 -0
- package/dist/session/fail-fast.d.ts +70 -0
- package/dist/session/fail-fast.js +122 -0
- package/dist/session/manager.d.ts +136 -0
- package/dist/session/manager.js +291 -0
- package/dist/session/persistence.d.ts +103 -0
- package/dist/session/persistence.js +194 -0
- package/dist/session/pi-rpc.d.ts +164 -0
- package/dist/session/pi-rpc.js +412 -0
- package/dist/session/sftp.d.ts +64 -0
- package/dist/session/sftp.js +335 -0
- package/dist/session/shell-frame.d.ts +77 -0
- package/dist/session/shell-frame.js +199 -0
- package/dist/session/shell.d.ts +124 -0
- package/dist/session/shell.js +300 -0
- package/docs/CONFIGURATION.md +169 -0
- package/docs/INSTALLATION.md +164 -0
- package/docs/PROTOCOL.md +248 -0
- package/docs/TROUBLESHOOTING.md +177 -0
- package/package.json +79 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon/service manager — mirrors `edge-client/src/daemon/manager.rs`.
|
|
3
|
+
*
|
|
4
|
+
* Installs and manages the edge-client daemon as a user-scoped background
|
|
5
|
+
* service on Linux (systemd --user), macOS (launchd LaunchAgent), and Windows
|
|
6
|
+
* (Task Scheduler). NO root/admin required anywhere — Standard User only.
|
|
7
|
+
*
|
|
8
|
+
* Linux: writes ~/.config/systemd/user/${serviceName}.service + `systemctl --user enable --now`
|
|
9
|
+
* macOS: writes ~/Library/LaunchAgents/${serviceName}.plist + `launchctl bootstrap gui/$UID`
|
|
10
|
+
* Win: writes %LOCALAPPDATA%\\edge-client-ts\\tasks\\${serviceName}.xml + `schtasks /Create`
|
|
11
|
+
*
|
|
12
|
+
* The exec command is `node ${binaryPath}` so a `bin/ec-ts.js` shim works
|
|
13
|
+
* (or, on packaged installs, the resolved entry file).
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from 'node:fs/promises';
|
|
16
|
+
import * as fssync from 'node:fs';
|
|
17
|
+
import * as os from 'node:os';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import { execFile } from 'node:child_process';
|
|
20
|
+
import { promisify } from 'node:util';
|
|
21
|
+
const execFileP = promisify(execFile);
|
|
22
|
+
// ──────────────────────────── service manager ──────────────────────
|
|
23
|
+
export class ServiceManager {
|
|
24
|
+
static detectPlatform() {
|
|
25
|
+
switch (process.platform) {
|
|
26
|
+
case 'linux':
|
|
27
|
+
return 'linux';
|
|
28
|
+
case 'darwin':
|
|
29
|
+
return 'darwin';
|
|
30
|
+
case 'win32':
|
|
31
|
+
return 'win32';
|
|
32
|
+
case 'freebsd':
|
|
33
|
+
return 'freebsd';
|
|
34
|
+
default:
|
|
35
|
+
return 'other';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Default service name. */
|
|
39
|
+
static defaultServiceName() {
|
|
40
|
+
return 'edge-client-ts';
|
|
41
|
+
}
|
|
42
|
+
/** Default install dir per platform (user-scoped, no root). */
|
|
43
|
+
static defaultInstallDir(platform = ServiceManager.detectPlatform()) {
|
|
44
|
+
if (platform === 'win32') {
|
|
45
|
+
return path.join(process.env.LOCALAPPDATA ?? os.homedir(), 'edge-client-ts', 'bin');
|
|
46
|
+
}
|
|
47
|
+
return path.join(os.homedir(), '.local', 'bin');
|
|
48
|
+
}
|
|
49
|
+
// ────────────── paths ──────────────
|
|
50
|
+
/** Resolve the service-unit file path for the current platform. */
|
|
51
|
+
getServiceFilePath(platform, serviceName) {
|
|
52
|
+
switch (platform) {
|
|
53
|
+
case 'linux':
|
|
54
|
+
return path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config'), 'systemd', 'user', `${serviceName}.service`);
|
|
55
|
+
case 'darwin':
|
|
56
|
+
return path.join(os.homedir(), 'Library', 'LaunchAgents', `${serviceName}.plist`);
|
|
57
|
+
case 'win32': {
|
|
58
|
+
const dir = path.join(process.env.LOCALAPPDATA ?? os.homedir(), 'edge-client-ts', 'tasks');
|
|
59
|
+
return path.join(dir, `${serviceName}.xml`);
|
|
60
|
+
}
|
|
61
|
+
default:
|
|
62
|
+
throw new Error(`edge-client-ts does not support service install on platform '${platform}'`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// ────────────── generators ──────────────
|
|
66
|
+
/** Linux systemd --user unit file contents. */
|
|
67
|
+
generateSystemdUnit(opts, serviceName) {
|
|
68
|
+
// SECURITY (C1, security-review 2026-07-11): validate every
|
|
69
|
+
// interpolated value. Newlines / control chars in `binaryPath`,
|
|
70
|
+
// `configDir`, or any `userEnv` value would let an attacker inject
|
|
71
|
+
// additional systemd directives (e.g. ExecStart=/bin/pwned).
|
|
72
|
+
// Throws on the first unsafe char.
|
|
73
|
+
validateServiceName(serviceName);
|
|
74
|
+
const binaryPath = escapeSystemdValue(opts.binaryPath, 'binaryPath');
|
|
75
|
+
const configDir = escapeSystemdValue(opts.configDir, 'configDir');
|
|
76
|
+
const envLines = Object.entries(opts.userEnv ?? {})
|
|
77
|
+
.map(([k, v]) => {
|
|
78
|
+
validateEnvName(k);
|
|
79
|
+
const safeV = escapeSystemdValue(v, `userEnv.${k}`);
|
|
80
|
+
return `Environment=${k}=${safeV}`;
|
|
81
|
+
})
|
|
82
|
+
.join('\n');
|
|
83
|
+
return `[Unit]
|
|
84
|
+
Description=Edge-client-ts daemon (Edge AI Agent fleet)
|
|
85
|
+
After=network-online.target
|
|
86
|
+
Wants=network-online.target
|
|
87
|
+
|
|
88
|
+
[Service]
|
|
89
|
+
Type=simple
|
|
90
|
+
ExecStart=${process.execPath} ${binaryPath} run
|
|
91
|
+
Restart=on-failure
|
|
92
|
+
RestartSec=5
|
|
93
|
+
WorkingDirectory=${configDir}
|
|
94
|
+
${envLines}
|
|
95
|
+
|
|
96
|
+
[Install]
|
|
97
|
+
WantedBy=default.target
|
|
98
|
+
`;
|
|
99
|
+
}
|
|
100
|
+
/** macOS LaunchAgent plist contents. */
|
|
101
|
+
generateLaunchdPlist(opts, serviceName) {
|
|
102
|
+
validateServiceName(serviceName);
|
|
103
|
+
const envDict = Object.entries(opts.userEnv ?? {})
|
|
104
|
+
.map(([k, v]) => {
|
|
105
|
+
validateEnvName(k);
|
|
106
|
+
const safeK = escapeXmlAttr(k);
|
|
107
|
+
const safeV = escapeXmlAttr(v);
|
|
108
|
+
return ` <key>${safeK}</key>\n <string>${safeV}</string>`;
|
|
109
|
+
})
|
|
110
|
+
.join('\n');
|
|
111
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
112
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
113
|
+
<plist version="1.0">
|
|
114
|
+
<dict>
|
|
115
|
+
<key>Label</key>
|
|
116
|
+
<string>${escapeXmlAttr(serviceName)}</string>
|
|
117
|
+
<key>ProgramArguments</key>
|
|
118
|
+
<array>
|
|
119
|
+
<string>${escapeXmlAttr(process.execPath)}</string>
|
|
120
|
+
<string>${escapeXmlAttr(opts.binaryPath)}</string>
|
|
121
|
+
<string>run</string>
|
|
122
|
+
</array>
|
|
123
|
+
<key>RunAtLoad</key>
|
|
124
|
+
<true/>
|
|
125
|
+
<key>KeepAlive</key>
|
|
126
|
+
<true/>
|
|
127
|
+
<key>WorkingDirectory</key>
|
|
128
|
+
<string>${escapeXmlAttr(opts.configDir)}</string>
|
|
129
|
+
<key>EnvironmentVariables</key>
|
|
130
|
+
<dict>
|
|
131
|
+
${envDict}
|
|
132
|
+
</dict>
|
|
133
|
+
<key>StandardOutPath</key>
|
|
134
|
+
<string>${escapeXmlAttr(path.join(os.homedir(), "Library", "Logs", serviceName + ".log"))}</string>
|
|
135
|
+
<key>StandardErrorPath</key>
|
|
136
|
+
<string>${escapeXmlAttr(path.join(os.homedir(), "Library", "Logs", serviceName + ".err"))}</string>
|
|
137
|
+
</dict>
|
|
138
|
+
</plist>
|
|
139
|
+
`;
|
|
140
|
+
}
|
|
141
|
+
/** Windows Task Scheduler XML (no admin needed when created per-user). */
|
|
142
|
+
generateSchtasksXml(opts, serviceName) {
|
|
143
|
+
validateServiceName(serviceName);
|
|
144
|
+
const envLines = Object.entries(opts.userEnv ?? {})
|
|
145
|
+
.map(([k, v]) => {
|
|
146
|
+
validateEnvName(k);
|
|
147
|
+
return ` <EnvVar Name="${escapeXmlAttr(k)}" Value="${escapeXmlAttr(v)}"/>`;
|
|
148
|
+
})
|
|
149
|
+
.join('\n');
|
|
150
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
151
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
152
|
+
<RegistrationInfo>
|
|
153
|
+
<Description>Edge-client-ts daemon (Edge AI Agent fleet)</Description>
|
|
154
|
+
</RegistrationInfo>
|
|
155
|
+
<Triggers>
|
|
156
|
+
<LogonTrigger>
|
|
157
|
+
<Enabled>true</Enabled>
|
|
158
|
+
</LogonTrigger>
|
|
159
|
+
</Triggers>
|
|
160
|
+
<Principals>
|
|
161
|
+
<Principal id="Author">
|
|
162
|
+
<GroupId>S-1-5-4</GroupId>
|
|
163
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
164
|
+
</Principal>
|
|
165
|
+
</Principals>
|
|
166
|
+
<Settings>
|
|
167
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
168
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
169
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
170
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
171
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
172
|
+
<RunOnlyIfNetworkAvailable>true</RunOnlyIfNetworkAvailable>
|
|
173
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
174
|
+
<Enabled>true</Enabled>
|
|
175
|
+
<Hidden>false</Hidden>
|
|
176
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
177
|
+
<Priority>7</Priority>
|
|
178
|
+
</Settings>
|
|
179
|
+
<Actions Context="Author">
|
|
180
|
+
<Exec>
|
|
181
|
+
<Command>${escapeXmlAttr(process.execPath)}</Command>
|
|
182
|
+
<Arguments>${escapeXmlAttr(opts.binaryPath + ' run')}</Arguments>
|
|
183
|
+
<WorkingDirectory>${escapeXmlAttr(opts.configDir)}</WorkingDirectory>
|
|
184
|
+
${envLines}
|
|
185
|
+
</Exec>
|
|
186
|
+
</Actions>
|
|
187
|
+
</Task>
|
|
188
|
+
`;
|
|
189
|
+
}
|
|
190
|
+
// ────────────── install / uninstall ──────────────
|
|
191
|
+
async install(opts) {
|
|
192
|
+
const platform = ServiceManager.detectPlatform();
|
|
193
|
+
const serviceName = opts.serviceName ?? ServiceManager.defaultServiceName();
|
|
194
|
+
const unitPath = this.getServiceFilePath(platform, serviceName);
|
|
195
|
+
await fs.mkdir(path.dirname(unitPath), { recursive: true });
|
|
196
|
+
let content;
|
|
197
|
+
switch (platform) {
|
|
198
|
+
case 'linux':
|
|
199
|
+
content = this.generateSystemdUnit(opts, serviceName);
|
|
200
|
+
await fs.writeFile(unitPath, content, { mode: 0o644 });
|
|
201
|
+
await execFileP('systemctl', ['--user', 'daemon-reload']);
|
|
202
|
+
await execFileP('systemctl', ['--user', 'enable', '--now', `${serviceName}.service`]);
|
|
203
|
+
return;
|
|
204
|
+
case 'darwin':
|
|
205
|
+
content = this.generateLaunchdPlist(opts, serviceName);
|
|
206
|
+
await fs.writeFile(unitPath, content, { mode: 0o644 });
|
|
207
|
+
await execFileP('launchctl', ['bootstrap', `gui/${process.getuid?.() ?? os.userInfo().uid}`, unitPath]);
|
|
208
|
+
return;
|
|
209
|
+
case 'win32': {
|
|
210
|
+
const schtasksDir = path.dirname(unitPath);
|
|
211
|
+
await fs.mkdir(schtasksDir, { recursive: true });
|
|
212
|
+
content = this.generateSchtasksXml(opts, serviceName);
|
|
213
|
+
await fs.writeFile(unitPath, content, 'utf16le');
|
|
214
|
+
// /RU $env:USERNAME so we don't need admin (per-user task).
|
|
215
|
+
await execFileP('schtasks', [
|
|
216
|
+
'/Create',
|
|
217
|
+
'/XML', unitPath,
|
|
218
|
+
'/TN', serviceName,
|
|
219
|
+
'/RU', process.env.USERNAME ?? os.userInfo().username,
|
|
220
|
+
'/F',
|
|
221
|
+
]);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
default:
|
|
225
|
+
throw new Error(`edge-client-ts does not support service install on platform '${platform}'; run \`ec-ts run\` manually.`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async uninstall(opts) {
|
|
229
|
+
const platform = ServiceManager.detectPlatform();
|
|
230
|
+
const serviceName = opts.serviceName ?? ServiceManager.defaultServiceName();
|
|
231
|
+
const unitPath = this.getServiceFilePath(platform, serviceName);
|
|
232
|
+
switch (platform) {
|
|
233
|
+
case 'linux':
|
|
234
|
+
try {
|
|
235
|
+
await execFileP('systemctl', ['--user', 'disable', '--now', `${serviceName}.service`]);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
/* not installed */
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
case 'darwin':
|
|
242
|
+
try {
|
|
243
|
+
await execFileP('launchctl', ['bootout', `gui/${process.getuid?.() ?? os.userInfo().uid}/${serviceName}`]);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
/* not installed */
|
|
247
|
+
}
|
|
248
|
+
break;
|
|
249
|
+
case 'win32':
|
|
250
|
+
try {
|
|
251
|
+
await execFileP('schtasks', ['/Delete', '/TN', serviceName, '/F']);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
/* not installed */
|
|
255
|
+
}
|
|
256
|
+
break;
|
|
257
|
+
default:
|
|
258
|
+
throw new Error(`edge-client-ts does not support service uninstall on platform '${platform}'`);
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
await fs.unlink(unitPath);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
/* file already gone */
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async start(serviceName = ServiceManager.defaultServiceName()) {
|
|
268
|
+
switch (ServiceManager.detectPlatform()) {
|
|
269
|
+
case 'linux':
|
|
270
|
+
await execFileP('systemctl', ['--user', 'start', `${serviceName}.service`]);
|
|
271
|
+
return;
|
|
272
|
+
case 'darwin':
|
|
273
|
+
await execFileP('launchctl', ['kickstart', '-k', `gui/${process.getuid?.() ?? os.userInfo().uid}/${serviceName}`]);
|
|
274
|
+
return;
|
|
275
|
+
case 'win32':
|
|
276
|
+
await execFileP('schtasks', ['/Run', '/TN', serviceName]);
|
|
277
|
+
return;
|
|
278
|
+
default:
|
|
279
|
+
throw new Error('unsupported platform');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async stop(serviceName = ServiceManager.defaultServiceName()) {
|
|
283
|
+
switch (ServiceManager.detectPlatform()) {
|
|
284
|
+
case 'linux':
|
|
285
|
+
await execFileP('systemctl', ['--user', 'stop', `${serviceName}.service`]);
|
|
286
|
+
return;
|
|
287
|
+
case 'darwin':
|
|
288
|
+
await execFileP('launchctl', ['kill', `gui/${process.getuid?.() ?? os.userInfo().uid}/${serviceName}`]);
|
|
289
|
+
return;
|
|
290
|
+
case 'win32':
|
|
291
|
+
await execFileP('schtasks', ['/End', '/TN', serviceName]);
|
|
292
|
+
return;
|
|
293
|
+
default:
|
|
294
|
+
throw new Error('unsupported platform');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async status(serviceName = ServiceManager.defaultServiceName()) {
|
|
298
|
+
const platform = ServiceManager.detectPlatform();
|
|
299
|
+
const unitPath = this.getServiceFilePath(platform, serviceName);
|
|
300
|
+
const installed = (() => {
|
|
301
|
+
try {
|
|
302
|
+
fssync.accessSync(unitPath, fssync.constants.F_OK);
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
})();
|
|
309
|
+
let running = false;
|
|
310
|
+
let detail = '';
|
|
311
|
+
try {
|
|
312
|
+
switch (platform) {
|
|
313
|
+
case 'linux': {
|
|
314
|
+
const { stdout } = await execFileP('systemctl', ['--user', 'is-active', `${serviceName}.service`]);
|
|
315
|
+
running = stdout.trim() === 'active';
|
|
316
|
+
detail = `systemd ${running ? 'active' : 'inactive'}`;
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
case 'darwin': {
|
|
320
|
+
const { stdout } = await execFileP('launchctl', ['list']);
|
|
321
|
+
running = stdout.includes(serviceName);
|
|
322
|
+
detail = running ? 'launchd loaded' : 'launchd unloaded';
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
case 'win32': {
|
|
326
|
+
const { stdout } = await execFileP('schtasks', ['/Query', '/TN', serviceName, '/FO', 'LIST']);
|
|
327
|
+
running = stdout.includes('Running');
|
|
328
|
+
detail = running ? 'schtasks running' : 'schtasks idle';
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
default:
|
|
332
|
+
detail = 'unsupported platform';
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
catch (e) {
|
|
336
|
+
detail = `query failed: ${String(e).slice(0, 100)}`;
|
|
337
|
+
running = false;
|
|
338
|
+
}
|
|
339
|
+
return { installed, running, platform, detail };
|
|
340
|
+
}
|
|
341
|
+
async isInstalled(serviceName = ServiceManager.defaultServiceName()) {
|
|
342
|
+
const platform = ServiceManager.detectPlatform();
|
|
343
|
+
const unitPath = this.getServiceFilePath(platform, serviceName);
|
|
344
|
+
try {
|
|
345
|
+
await fs.access(unitPath);
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// ──────────────────────────── helpers ──────────────────────────────
|
|
354
|
+
/** Expand `~` / `$HOME` / `%USERPROFILE%` placeholders. */
|
|
355
|
+
export function expandUser(p) {
|
|
356
|
+
if (p.startsWith('~/') || p === '~') {
|
|
357
|
+
return path.join(os.homedir(), p.slice(p === '~' ? 1 : 2));
|
|
358
|
+
}
|
|
359
|
+
if (p.startsWith('$HOME')) {
|
|
360
|
+
return path.join(os.homedir(), p.slice(5));
|
|
361
|
+
}
|
|
362
|
+
if (p.startsWith('%USERPROFILE%')) {
|
|
363
|
+
return path.join(process.env.USERPROFILE ?? os.homedir(), p.slice(14));
|
|
364
|
+
}
|
|
365
|
+
if (p.startsWith('%LOCALAPPDATA%')) {
|
|
366
|
+
return path.join(process.env.LOCALAPPDATA ?? os.homedir(), p.slice(14));
|
|
367
|
+
}
|
|
368
|
+
return p;
|
|
369
|
+
}
|
|
370
|
+
// ──────────────────────────────────────────────────────────────────
|
|
371
|
+
// SECURITY: input validation + escaping for daemon template fields
|
|
372
|
+
// (security-review 2026-07-11 — C1: template injection).
|
|
373
|
+
//
|
|
374
|
+
// All values interpolated into systemd units, launchd plists, or
|
|
375
|
+
// schtasks XML MUST be validated by the helpers below. The historical
|
|
376
|
+
// raw-string interpolation allowed a malicious `binaryPath`,
|
|
377
|
+
// `configDir`, `serviceName`, or `userEnv` value containing a newline
|
|
378
|
+
// to inject additional directives such as:
|
|
379
|
+
//
|
|
380
|
+
// binaryPath: "/safe/path\n[Service]\nExecStart=/bin/pwned"
|
|
381
|
+
//
|
|
382
|
+
// which produces a systemd unit with TWO `ExecStart=` lines — the second
|
|
383
|
+
// one runs the attacker's command on `systemctl start`. Equivalent
|
|
384
|
+
// attacks work via plist XML injection (`</string><key>...
|
|
385
|
+
// <array><string>/bin/sh</string>...</array>`) and schtasks
|
|
386
|
+
// `</Command><Exec>...` manipulation.
|
|
387
|
+
//
|
|
388
|
+
// The helpers below throw on any un-safe input so `install()` fails
|
|
389
|
+
// fast instead of silently writing a malicious service definition.
|
|
390
|
+
// ──────────────────────────────────────────────────────────────────
|
|
391
|
+
/**
|
|
392
|
+
* Validate a string is safe to interpolate into a systemd unit file.
|
|
393
|
+
* systemd's INI-like parser treats `[section]` at the start of a line
|
|
394
|
+
* as a section boundary and `Key=` as a directive. A newline in any
|
|
395
|
+
* interpolated value therefore lets an attacker close the current
|
|
396
|
+
* directive, start a new `[Service]` section, and inject `ExecStart=`,
|
|
397
|
+
* `Environment=`, etc. We reject anything containing ASCII control
|
|
398
|
+
* characters; legitimate values never need them.
|
|
399
|
+
*/
|
|
400
|
+
export function escapeSystemdValue(value, fieldName) {
|
|
401
|
+
// eslint-disable-next-line no-control-regex
|
|
402
|
+
if (/[\x00-\x1f]/.test(value)) {
|
|
403
|
+
throw new RangeError(`daemon: ${fieldName} contains control characters (refusing to emit injection):\n${value}`);
|
|
404
|
+
}
|
|
405
|
+
return value;
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* XML-escape a string for use inside an attribute value or element
|
|
409
|
+
* text in plist/schtasks XML. The chars `<`, `>`, `&`, `"`, `'`
|
|
410
|
+
* MUST be escaped or an attacker can break out of the surrounding
|
|
411
|
+
* construct (`</string><key>ProgramArguments</key>...`) and inject
|
|
412
|
+
* arbitrary plist/schtasks directives. See C1 in security-review
|
|
413
|
+
* 2026-07-11.
|
|
414
|
+
*/
|
|
415
|
+
export function escapeXmlAttr(value) {
|
|
416
|
+
return value
|
|
417
|
+
.replace(/&/g, '&')
|
|
418
|
+
.replace(/</g, '<')
|
|
419
|
+
.replace(/>/g, '>')
|
|
420
|
+
.replace(/"/g, '"')
|
|
421
|
+
.replace(/'/g, ''')
|
|
422
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ''); // strip control chars (except \t, \n, \r)
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Validate that a service name is a valid systemd unit / plist label /
|
|
426
|
+
* schtasks task name. The charset is intentionally narrow:
|
|
427
|
+
* - alphanumerics, dash, underscore, dot
|
|
428
|
+
* - no path separators, no newlines, no leading dot
|
|
429
|
+
* Mirrors the constraints checked by `systemd-escape`, launchd
|
|
430
|
+
* `Label=`, and `schtasks` at registration time — failing here gives a
|
|
431
|
+
* clear "invalid serviceName" error instead of a confusing OS-level
|
|
432
|
+
* "unit name not valid" rejection at install.
|
|
433
|
+
*/
|
|
434
|
+
export function validateServiceName(name) {
|
|
435
|
+
if (typeof name !== 'string' || name.length === 0 || name.length > 64) {
|
|
436
|
+
throw new RangeError(`daemon: serviceName must be 1–64 chars (got ${name.length})`);
|
|
437
|
+
}
|
|
438
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(name)) {
|
|
439
|
+
throw new RangeError(`daemon: serviceName contains invalid chars (only [A-Za-z0-9_.-] allowed): ${JSON.stringify(name)}`);
|
|
440
|
+
}
|
|
441
|
+
if (name === '.' || name === '..' || name.startsWith('.')) {
|
|
442
|
+
throw new RangeError(`daemon: serviceName cannot be "." or start with "." (got ${JSON.stringify(name)})`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Validate that an env var name is a portable POSIX identifier. systemd,
|
|
447
|
+
* launchd, and schtasks all follow the same rules:
|
|
448
|
+
* - [A-Za-z_][A-Za-z0-9_]*
|
|
449
|
+
* - no leading digit
|
|
450
|
+
* - empty refused
|
|
451
|
+
* A name like `RELAY;ExecStart=/bin/sh` would otherwise inject
|
|
452
|
+
* additional directives.
|
|
453
|
+
*/
|
|
454
|
+
export function validateEnvName(name) {
|
|
455
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
456
|
+
throw new RangeError('daemon: userEnv key must be a non-empty string');
|
|
457
|
+
}
|
|
458
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
459
|
+
throw new RangeError(`daemon: userEnv key '${name}' must match /^[A-Za-z_][A-Za-z0-9_]*$/`);
|
|
460
|
+
}
|
|
461
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @edgeai/edge-client-ts — TypeScript (Model 1) parallel edge-client.
|
|
3
|
+
*
|
|
4
|
+
* Public entry point. Re-exports every module the binary / library users need.
|
|
5
|
+
*
|
|
6
|
+
* Layout mirrors the Rust edge-client:
|
|
7
|
+
* config/ settings + cmd-gate schema
|
|
8
|
+
* crypto/ AES-256-GCM, HMAC-SHA256, TLS cert-pinning
|
|
9
|
+
* logging/ file logger
|
|
10
|
+
* network/ WS client (`/edge` JSON stream)
|
|
11
|
+
* buffer/ disk-backed offline queue
|
|
12
|
+
* session/ PTY shell (with 17-byte frame codec), SFTP, pi RPC, fail-fast,
|
|
13
|
+
* session manager, persistence
|
|
14
|
+
* cli/ dispatch + run/enroll/whoami/status/devices/messages subcommands
|
|
15
|
+
* daemon/ per-OS service install (systemd-user / launchd / schtasks)
|
|
16
|
+
*/
|
|
17
|
+
export * from './config/settings.js';
|
|
18
|
+
export * from './config/cmd-gate.js';
|
|
19
|
+
export * from './crypto/aes-gcm.js';
|
|
20
|
+
export * from './crypto/hmac.js';
|
|
21
|
+
export * from './crypto/tls-guard.js';
|
|
22
|
+
export { FileLogger } from './logging/file-logger.js';
|
|
23
|
+
export { EdgeWsClient, type EdgeWsClientOptions, type EdgeWsClientEventMap, type RegisterMessage, type StreamMessage, type AckMessage, type HeartbeatMessage, type SystemMetrics, type JsonObject, type BufferDrainable, } from './network/ws-client.js';
|
|
24
|
+
export { DiskBufferQueue, BufferFullError, type QueueEntry } from './buffer/disk-queue.js';
|
|
25
|
+
export { SHELL_HEADER_LEN, SESSION_ID_LEN, RESIZE_PAYLOAD_LEN, FrameType, frameTypeFromByte, newSessionId, sessionIdFromString, sessionIdToString, dataFrame, resizeFrame, closeFrame, encodeFrame, decodeFrame, decodeResizePayload, type ShellFrame, } from './session/shell-frame.js';
|
|
26
|
+
export { FORBIDDEN_KEYWORDS, INSPECTABLE_EVENT_TYPES, FAIL_FAST_ERROR_CONTENT, ENV_FAIL_FAST, failFastEnabled, detectForbidden, buildSyntheticErrorEvent, } from './session/fail-fast.js';
|
|
27
|
+
export { ShellManager, type DataHandler, type ExitHandler } from './session/shell.js';
|
|
28
|
+
export { SftpForwarder } from './session/sftp.js';
|
|
29
|
+
export { PiProcess, hasLimits, systemdRunAvailable, resetSystemdRunCache, wrapWithCgroup, promptCommand, abortCommand, setModelCommand, setThinkingLevelCommand, compactCommand, getAvailableModelsCommand, newPiSessionId, extractSessionId, eventMsgId, type RpcCommand, type PiEvent, type ResourcesConfig, } from './session/pi-rpc.js';
|
|
30
|
+
export { SessionManager, type SessionManagerOptions, type HeartbeatMetadata } from './session/manager.js';
|
|
31
|
+
export { SessionIdStore, persistSessionState, loadSessionState, type SessionState } from './session/persistence.js';
|
|
32
|
+
export { dispatch } from './cli/index.js';
|
|
33
|
+
export { runDaemon } from './cli/run.js';
|
|
34
|
+
export { enroll } from './cli/enroll.js';
|
|
35
|
+
export { whoami } from './cli/whoami.js';
|
|
36
|
+
export { status } from './cli/status.js';
|
|
37
|
+
export { listDevices } from './cli/devices.js';
|
|
38
|
+
export { listMessages } from './cli/messages.js';
|
|
39
|
+
export { ServiceManager, expandUser, type Platform, type ServiceInstallOpts, type ServiceStatus, } from './daemon/manager.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @edgeai/edge-client-ts — TypeScript (Model 1) parallel edge-client.
|
|
3
|
+
*
|
|
4
|
+
* Public entry point. Re-exports every module the binary / library users need.
|
|
5
|
+
*
|
|
6
|
+
* Layout mirrors the Rust edge-client:
|
|
7
|
+
* config/ settings + cmd-gate schema
|
|
8
|
+
* crypto/ AES-256-GCM, HMAC-SHA256, TLS cert-pinning
|
|
9
|
+
* logging/ file logger
|
|
10
|
+
* network/ WS client (`/edge` JSON stream)
|
|
11
|
+
* buffer/ disk-backed offline queue
|
|
12
|
+
* session/ PTY shell (with 17-byte frame codec), SFTP, pi RPC, fail-fast,
|
|
13
|
+
* session manager, persistence
|
|
14
|
+
* cli/ dispatch + run/enroll/whoami/status/devices/messages subcommands
|
|
15
|
+
* daemon/ per-OS service install (systemd-user / launchd / schtasks)
|
|
16
|
+
*/
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Config
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
export * from './config/settings.js';
|
|
21
|
+
export * from './config/cmd-gate.js';
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Crypto
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
export * from './crypto/aes-gcm.js';
|
|
26
|
+
export * from './crypto/hmac.js';
|
|
27
|
+
export * from './crypto/tls-guard.js';
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Logging
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
export { FileLogger } from './logging/file-logger.js';
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Network
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
export { EdgeWsClient, } from './network/ws-client.js';
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Buffer (offline queue)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
export { DiskBufferQueue, BufferFullError } from './buffer/disk-queue.js';
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Session
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
export { SHELL_HEADER_LEN, SESSION_ID_LEN, RESIZE_PAYLOAD_LEN, FrameType, frameTypeFromByte, newSessionId, sessionIdFromString, sessionIdToString, dataFrame, resizeFrame, closeFrame, encodeFrame, decodeFrame, decodeResizePayload, } from './session/shell-frame.js';
|
|
44
|
+
export { FORBIDDEN_KEYWORDS, INSPECTABLE_EVENT_TYPES, FAIL_FAST_ERROR_CONTENT, ENV_FAIL_FAST, failFastEnabled, detectForbidden, buildSyntheticErrorEvent, } from './session/fail-fast.js';
|
|
45
|
+
export { ShellManager } from './session/shell.js';
|
|
46
|
+
export { SftpForwarder } from './session/sftp.js';
|
|
47
|
+
export { PiProcess, hasLimits, systemdRunAvailable, resetSystemdRunCache, wrapWithCgroup, promptCommand, abortCommand, setModelCommand, setThinkingLevelCommand, compactCommand, getAvailableModelsCommand, newPiSessionId, extractSessionId, eventMsgId, } from './session/pi-rpc.js';
|
|
48
|
+
export { SessionManager } from './session/manager.js';
|
|
49
|
+
export { SessionIdStore, persistSessionState, loadSessionState } from './session/persistence.js';
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// CLI dispatch (used by bin/ec-ts.js)
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
export { dispatch } from './cli/index.js';
|
|
54
|
+
export { runDaemon } from './cli/run.js';
|
|
55
|
+
export { enroll } from './cli/enroll.js';
|
|
56
|
+
export { whoami } from './cli/whoami.js';
|
|
57
|
+
export { status } from './cli/status.js';
|
|
58
|
+
export { listDevices } from './cli/devices.js';
|
|
59
|
+
export { listMessages } from './cli/messages.js';
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Daemon (service install)
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
export { ServiceManager, expandUser, } from './daemon/manager.js';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based logger — mirrors `edge-client/src/logging/file_logger.rs`.
|
|
3
|
+
*
|
|
4
|
+
* Writes timestamped log lines to a daily log file under `logDir`
|
|
5
|
+
* (e.g. `edge-client-2026-07-11.log`). Thread-safe via a mutex
|
|
6
|
+
* (in Node, a simple serialization queue).
|
|
7
|
+
*/
|
|
8
|
+
export declare class FileLogger {
|
|
9
|
+
private readonly logDir;
|
|
10
|
+
private stream;
|
|
11
|
+
private writeQueue;
|
|
12
|
+
constructor(logDir: string);
|
|
13
|
+
/** Write a formatted log line at the given level. */
|
|
14
|
+
log(level: string, message: string): Promise<void>;
|
|
15
|
+
info(message: string): Promise<void>;
|
|
16
|
+
warn(message: string): Promise<void>;
|
|
17
|
+
error(message: string): Promise<void>;
|
|
18
|
+
/** Flush + close the underlying stream. */
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
private writeRaw;
|
|
21
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based logger — mirrors `edge-client/src/logging/file_logger.rs`.
|
|
3
|
+
*
|
|
4
|
+
* Writes timestamped log lines to a daily log file under `logDir`
|
|
5
|
+
* (e.g. `edge-client-2026-07-11.log`). Thread-safe via a mutex
|
|
6
|
+
* (in Node, a simple serialization queue).
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
export class FileLogger {
|
|
11
|
+
logDir;
|
|
12
|
+
stream;
|
|
13
|
+
writeQueue = Promise.resolve();
|
|
14
|
+
constructor(logDir) {
|
|
15
|
+
this.logDir = logDir;
|
|
16
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
17
|
+
const today = formatDate(new Date());
|
|
18
|
+
const logFile = path.join(logDir, `edge-client-${today}.log`);
|
|
19
|
+
this.stream = fs.createWriteStream(logFile, { flags: 'a' });
|
|
20
|
+
}
|
|
21
|
+
/** Write a formatted log line at the given level. */
|
|
22
|
+
log(level, message) {
|
|
23
|
+
const timestamp = formatTimestamp(new Date());
|
|
24
|
+
const line = `[${timestamp}] [${level}] ${message}\n`;
|
|
25
|
+
return this.writeRaw(line);
|
|
26
|
+
}
|
|
27
|
+
info(message) {
|
|
28
|
+
return this.log('INFO', message);
|
|
29
|
+
}
|
|
30
|
+
warn(message) {
|
|
31
|
+
return this.log('WARN', message);
|
|
32
|
+
}
|
|
33
|
+
error(message) {
|
|
34
|
+
return this.log('ERROR', message);
|
|
35
|
+
}
|
|
36
|
+
/** Flush + close the underlying stream. */
|
|
37
|
+
close() {
|
|
38
|
+
return this.writeQueue.then(() => {
|
|
39
|
+
this.stream?.end();
|
|
40
|
+
this.stream = undefined;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
writeRaw(line) {
|
|
44
|
+
this.writeQueue = this.writeQueue.then(() => {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
if (this.stream === undefined) {
|
|
47
|
+
resolve();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
this.stream.write(line, () => resolve());
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
return this.writeQueue;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Format a date as `YYYY-MM-DD` (for the log filename). */
|
|
57
|
+
function formatDate(d) {
|
|
58
|
+
const y = d.getFullYear();
|
|
59
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
60
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
61
|
+
return `${y}-${m}-${day}`;
|
|
62
|
+
}
|
|
63
|
+
/** Format a date as `YYYY-MM-DD HH:MM:SS.mmm` (for log line timestamps). */
|
|
64
|
+
function formatTimestamp(d) {
|
|
65
|
+
const date = formatDate(d);
|
|
66
|
+
const h = String(d.getHours()).padStart(2, '0');
|
|
67
|
+
const min = String(d.getMinutes()).padStart(2, '0');
|
|
68
|
+
const s = String(d.getSeconds()).padStart(2, '0');
|
|
69
|
+
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
|
70
|
+
return `${date} ${h}:${min}:${s}.${ms}`;
|
|
71
|
+
}
|