ragent-cli 1.3.1 → 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/dist/index.js +1226 -890
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "ragent-cli",
|
|
34
|
-
version: "1.
|
|
34
|
+
version: "1.4.0",
|
|
35
35
|
description: "CLI agent for rAgent Live \u2014 browser-first terminal control plane for AI coding agents",
|
|
36
36
|
main: "dist/index.js",
|
|
37
37
|
bin: {
|
|
@@ -127,6 +127,7 @@ var MAX_RECONNECT_DELAY_MS = 3e4;
|
|
|
127
127
|
var OUTPUT_BUFFER_MAX_BYTES = 100 * 1024;
|
|
128
128
|
|
|
129
129
|
// src/config.ts
|
|
130
|
+
var crypto = __toESM(require("crypto"));
|
|
130
131
|
var fs = __toESM(require("fs"));
|
|
131
132
|
var os2 = __toESM(require("os"));
|
|
132
133
|
function ensureConfigDir() {
|
|
@@ -162,8 +163,35 @@ function saveConfigPatch(patch) {
|
|
|
162
163
|
function sanitizeHostId(value) {
|
|
163
164
|
return String(value || "").toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+/, "").slice(0, 64);
|
|
164
165
|
}
|
|
166
|
+
function readMachineId() {
|
|
167
|
+
try {
|
|
168
|
+
const mid = fs.readFileSync("/etc/machine-id", "utf8").trim();
|
|
169
|
+
if (mid.length >= 16) return mid;
|
|
170
|
+
} catch {
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
165
174
|
function inferHostId() {
|
|
166
|
-
|
|
175
|
+
const hostname5 = sanitizeHostId(os2.hostname().split(".")[0] || "linux-host");
|
|
176
|
+
const machineId = readMachineId();
|
|
177
|
+
if (machineId) {
|
|
178
|
+
return sanitizeHostId(`${hostname5}-${machineId.slice(0, 8)}`);
|
|
179
|
+
}
|
|
180
|
+
const idFile = `${CONFIG_DIR}/machine-id`;
|
|
181
|
+
try {
|
|
182
|
+
const stored = fs.readFileSync(idFile, "utf8").trim();
|
|
183
|
+
if (stored.length >= 8) {
|
|
184
|
+
return sanitizeHostId(`${hostname5}-${stored.slice(0, 8)}`);
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
}
|
|
188
|
+
const generated = crypto.randomUUID().replace(/-/g, "");
|
|
189
|
+
try {
|
|
190
|
+
ensureConfigDir();
|
|
191
|
+
fs.writeFileSync(idFile, generated, { encoding: "utf8", mode: 384 });
|
|
192
|
+
} catch {
|
|
193
|
+
}
|
|
194
|
+
return sanitizeHostId(`${hostname5}-${generated.slice(0, 8)}`);
|
|
167
195
|
}
|
|
168
196
|
|
|
169
197
|
// src/version.ts
|
|
@@ -229,13 +257,13 @@ async function maybeWarnUpdate() {
|
|
|
229
257
|
}
|
|
230
258
|
|
|
231
259
|
// src/commands/connect.ts
|
|
232
|
-
var
|
|
260
|
+
var os7 = __toESM(require("os"));
|
|
233
261
|
|
|
234
262
|
// src/agent.ts
|
|
235
263
|
var fs3 = __toESM(require("fs"));
|
|
236
|
-
var
|
|
264
|
+
var os6 = __toESM(require("os"));
|
|
237
265
|
var path2 = __toESM(require("path"));
|
|
238
|
-
var
|
|
266
|
+
var import_ws5 = __toESM(require("ws"));
|
|
239
267
|
|
|
240
268
|
// src/auth.ts
|
|
241
269
|
var os3 = __toESM(require("os"));
|
|
@@ -249,9 +277,14 @@ function wait(ms) {
|
|
|
249
277
|
function execAsync(command, options = {}) {
|
|
250
278
|
const timeout = options.timeout ?? 5e3;
|
|
251
279
|
const maxBuffer = options.maxBuffer ?? 1024 * 1024;
|
|
280
|
+
const allowNonZeroExit = options.allowNonZeroExit ?? false;
|
|
252
281
|
return new Promise((resolve, reject) => {
|
|
253
282
|
(0, import_node_child_process.exec)(command, { timeout, maxBuffer }, (error, stdout, stderr) => {
|
|
254
283
|
if (error) {
|
|
284
|
+
if (allowNonZeroExit) {
|
|
285
|
+
resolve(stdout || "");
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
255
288
|
reject(new Error((stderr || error.message || "").trim()));
|
|
256
289
|
return;
|
|
257
290
|
}
|
|
@@ -332,27 +365,55 @@ async function collectTmuxSessions() {
|
|
|
332
365
|
}
|
|
333
366
|
try {
|
|
334
367
|
const raw = await execAsync(
|
|
335
|
-
"tmux list-panes -a -F '#{session_name}|#{window_index}|#{pane_index}|#{pane_current_command}|#{pane_active}|#{pane_last}|#{pane_pid}'"
|
|
368
|
+
"tmux list-panes -a -F '#{session_name}|#{window_index}|#{pane_index}|#{pane_current_command}|#{pane_active}|#{pane_last}|#{pane_pid}|#{window_layout}|#{pane_current_path}|#{window_name}|#{pane_title}|#{window_flags}|#{session_group}|#{session_grouped}'"
|
|
336
369
|
);
|
|
337
370
|
const rows = raw.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
338
|
-
|
|
339
|
-
|
|
371
|
+
const results = [];
|
|
372
|
+
for (const row of rows) {
|
|
373
|
+
const parts = row.split("|");
|
|
374
|
+
const [
|
|
375
|
+
sessionName,
|
|
376
|
+
windowIndex,
|
|
377
|
+
paneIndex,
|
|
378
|
+
command,
|
|
379
|
+
activeFlag,
|
|
380
|
+
lastEpoch,
|
|
381
|
+
panePid,
|
|
382
|
+
windowLayout,
|
|
383
|
+
paneCurrentPath,
|
|
384
|
+
windowName,
|
|
385
|
+
paneTitle,
|
|
386
|
+
windowFlags,
|
|
387
|
+
sessionGroup,
|
|
388
|
+
sessionGrouped
|
|
389
|
+
] = parts;
|
|
390
|
+
if (sessionGrouped === "1" && sessionGroup && sessionGroup !== sessionName) {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
340
393
|
const id = `tmux:${sessionName}:${windowIndex}.${paneIndex}`;
|
|
341
394
|
const lastActivityAt = Number(lastEpoch) > 0 ? new Date(Number(lastEpoch) * 1e3).toISOString() : (/* @__PURE__ */ new Date()).toISOString();
|
|
342
395
|
const pids = [];
|
|
343
396
|
const pid = Number(panePid);
|
|
344
397
|
if (pid > 0) pids.push(pid);
|
|
345
|
-
|
|
398
|
+
results.push({
|
|
346
399
|
id,
|
|
347
400
|
type: "tmux",
|
|
348
401
|
name: `${sessionName}:${windowIndex}.${paneIndex}`,
|
|
349
402
|
status: activeFlag === "1" ? "active" : "detached",
|
|
350
403
|
command,
|
|
351
404
|
agentType: detectAgentType(command),
|
|
405
|
+
runningCommand: command || void 0,
|
|
406
|
+
windowLayout: windowLayout || void 0,
|
|
407
|
+
workingDir: paneCurrentPath || void 0,
|
|
408
|
+
windowName: windowName || void 0,
|
|
409
|
+
paneTitle: paneTitle || void 0,
|
|
410
|
+
isZoomed: windowFlags?.includes("Z") ?? false,
|
|
352
411
|
lastActivityAt,
|
|
353
|
-
pids
|
|
354
|
-
|
|
355
|
-
|
|
412
|
+
pids,
|
|
413
|
+
sessionGroup: sessionGroup || void 0
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
return results;
|
|
356
417
|
} catch {
|
|
357
418
|
return [];
|
|
358
419
|
}
|
|
@@ -364,12 +425,7 @@ async function collectScreenSessions() {
|
|
|
364
425
|
return [];
|
|
365
426
|
}
|
|
366
427
|
try {
|
|
367
|
-
|
|
368
|
-
try {
|
|
369
|
-
raw = await execAsync("screen -ls");
|
|
370
|
-
} catch (e) {
|
|
371
|
-
raw = e instanceof Error ? e.message : "";
|
|
372
|
-
}
|
|
428
|
+
const raw = await execAsync("screen -ls", { allowNonZeroExit: true });
|
|
373
429
|
const sessionPattern = /^\s*(\d+)\.(\S+)\s+\((Detached|Attached)\)/;
|
|
374
430
|
const sessions = [];
|
|
375
431
|
for (const line of raw.split("\n")) {
|
|
@@ -403,7 +459,7 @@ async function collectZellijSessions() {
|
|
|
403
459
|
return [];
|
|
404
460
|
}
|
|
405
461
|
try {
|
|
406
|
-
const raw = await execAsync("zellij list-sessions");
|
|
462
|
+
const raw = await execAsync("zellij list-sessions --short --no-formatting");
|
|
407
463
|
const sessions = [];
|
|
408
464
|
for (const line of raw.split("\n")) {
|
|
409
465
|
const sessionName = line.trim();
|
|
@@ -454,16 +510,18 @@ async function getProcessWorkingDir(pid) {
|
|
|
454
510
|
}
|
|
455
511
|
async function collectBareAgentProcesses(excludePids) {
|
|
456
512
|
try {
|
|
457
|
-
const raw = await execAsync("ps axo pid,ppid,comm,args --no-headers");
|
|
513
|
+
const raw = await execAsync("ps axo pid,ppid,stat,comm,args --no-headers");
|
|
458
514
|
const sessions = [];
|
|
459
515
|
const seen = /* @__PURE__ */ new Set();
|
|
460
516
|
for (const line of raw.split("\n")) {
|
|
461
517
|
const trimmed = line.trim();
|
|
462
518
|
if (!trimmed) continue;
|
|
463
519
|
const parts = trimmed.split(/\s+/);
|
|
464
|
-
if (parts.length <
|
|
520
|
+
if (parts.length < 5) continue;
|
|
465
521
|
const pid = Number(parts[0]);
|
|
466
|
-
const
|
|
522
|
+
const stat = parts[2];
|
|
523
|
+
const args = parts.slice(4).join(" ");
|
|
524
|
+
if (stat.startsWith("Z")) continue;
|
|
467
525
|
const agentType = detectAgentType(args);
|
|
468
526
|
if (!agentType) continue;
|
|
469
527
|
if (excludePids?.has(pid)) continue;
|
|
@@ -497,9 +555,20 @@ async function collectSessionInventory(hostId, command) {
|
|
|
497
555
|
collectZellijSessions()
|
|
498
556
|
]);
|
|
499
557
|
const multiplexerPids = /* @__PURE__ */ new Set();
|
|
500
|
-
for (const s of [...
|
|
558
|
+
for (const s of [...screen, ...zellij]) {
|
|
501
559
|
if (s.pids) s.pids.forEach((p) => multiplexerPids.add(p));
|
|
502
560
|
}
|
|
561
|
+
for (const s of tmux) {
|
|
562
|
+
if (s.pids) {
|
|
563
|
+
for (const pid of s.pids) {
|
|
564
|
+
multiplexerPids.add(pid);
|
|
565
|
+
const childInfo = await getChildAgentInfo(pid);
|
|
566
|
+
if (childInfo) {
|
|
567
|
+
childInfo.childPids.forEach((p) => multiplexerPids.add(p));
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
503
572
|
const bare = await collectBareAgentProcesses(multiplexerPids);
|
|
504
573
|
const ptySession = {
|
|
505
574
|
id: `pty:${hostId}`,
|
|
@@ -533,7 +602,10 @@ function detectAgentType(command) {
|
|
|
533
602
|
}
|
|
534
603
|
function sessionInventoryFingerprint(sessions) {
|
|
535
604
|
const sorted = [...sessions].sort((a, b) => a.id.localeCompare(b.id));
|
|
536
|
-
return sorted.map((s) =>
|
|
605
|
+
return sorted.map((s) => {
|
|
606
|
+
const pidStr = s.pids?.join(",") ?? "";
|
|
607
|
+
return `${s.id}|${s.type}|${s.name}|${s.status}|${s.command || ""}|${s.runningCommand || ""}|${pidStr}`;
|
|
608
|
+
}).join("\n");
|
|
537
609
|
}
|
|
538
610
|
async function getChildAgentInfo(parentPid) {
|
|
539
611
|
try {
|
|
@@ -755,431 +827,99 @@ var OutputBuffer = class {
|
|
|
755
827
|
}
|
|
756
828
|
};
|
|
757
829
|
|
|
758
|
-
// src/
|
|
759
|
-
var
|
|
760
|
-
var
|
|
761
|
-
function
|
|
762
|
-
|
|
763
|
-
return ["bash", "sh", "zsh", "fish"].includes(trimmed);
|
|
764
|
-
}
|
|
765
|
-
function spawnConnectorShell(command, onData, onExit) {
|
|
766
|
-
const shell = "bash";
|
|
767
|
-
const args = isInteractiveShell(command) ? [] : ["-lc", command];
|
|
768
|
-
const processName = isInteractiveShell(command) ? command : shell;
|
|
769
|
-
const ptyProcess = pty.spawn(processName, args, {
|
|
770
|
-
name: "xterm-color",
|
|
771
|
-
cols: 80,
|
|
772
|
-
rows: 30,
|
|
773
|
-
cwd: process.cwd(),
|
|
774
|
-
env: process.env
|
|
775
|
-
});
|
|
776
|
-
ptyProcess.onData(onData);
|
|
777
|
-
ptyProcess.onExit(onExit);
|
|
778
|
-
return ptyProcess;
|
|
779
|
-
}
|
|
780
|
-
async function stopTmuxPaneBySessionId(sessionId) {
|
|
781
|
-
if (!sessionId.startsWith("tmux:")) return false;
|
|
782
|
-
const paneTarget = sessionId.slice("tmux:".length).trim();
|
|
783
|
-
if (!paneTarget) return false;
|
|
784
|
-
await execAsync(`tmux kill-pane -t ${shellQuote(paneTarget)}`, { timeout: 5e3 });
|
|
785
|
-
return true;
|
|
830
|
+
// src/websocket.ts
|
|
831
|
+
var import_ws = __toESM(require("ws"));
|
|
832
|
+
var BACKPRESSURE_HIGH_WATER = 256 * 1024;
|
|
833
|
+
function sanitizeForJson(str) {
|
|
834
|
+
return str.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD");
|
|
786
835
|
}
|
|
787
|
-
|
|
788
|
-
if (!
|
|
789
|
-
|
|
790
|
-
if (!target) return;
|
|
791
|
-
const sessionName = target.split(":")[0].split(".")[0];
|
|
792
|
-
if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(sessionName)) {
|
|
793
|
-
console.warn(`[rAgent] Invalid tmux session name: ${sessionName}`);
|
|
836
|
+
function sendToGroup(ws, group, data) {
|
|
837
|
+
if (!group || ws.readyState !== import_ws.default.OPEN) return;
|
|
838
|
+
if (ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) {
|
|
794
839
|
return;
|
|
795
840
|
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
}
|
|
841
|
+
const sanitized = sanitizePayload(data);
|
|
842
|
+
ws.send(
|
|
843
|
+
JSON.stringify({
|
|
844
|
+
type: "sendToGroup",
|
|
845
|
+
group,
|
|
846
|
+
dataType: "json",
|
|
847
|
+
data: sanitized,
|
|
848
|
+
noEcho: true
|
|
849
|
+
})
|
|
850
|
+
);
|
|
807
851
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
)
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
const [name, attached] = line.split("|");
|
|
818
|
-
if (attached === "0" && name) {
|
|
819
|
-
try {
|
|
820
|
-
await execAsync(`tmux kill-session -t ${shellQuote(name)}`, { timeout: 5e3 });
|
|
821
|
-
killed++;
|
|
822
|
-
} catch {
|
|
823
|
-
}
|
|
824
|
-
}
|
|
852
|
+
function sanitizePayload(obj) {
|
|
853
|
+
const result = {};
|
|
854
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
855
|
+
if (typeof value === "string") {
|
|
856
|
+
result[key] = sanitizeForJson(value);
|
|
857
|
+
} else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
858
|
+
result[key] = sanitizePayload(value);
|
|
859
|
+
} else {
|
|
860
|
+
result[key] = value;
|
|
825
861
|
}
|
|
826
|
-
return killed;
|
|
827
|
-
} catch {
|
|
828
|
-
return 0;
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
// src/service.ts
|
|
833
|
-
var import_child_process = require("child_process");
|
|
834
|
-
var fs2 = __toESM(require("fs"));
|
|
835
|
-
var os4 = __toESM(require("os"));
|
|
836
|
-
function assertConfiguredAgentToken() {
|
|
837
|
-
const config = loadConfig();
|
|
838
|
-
if (!config.agentToken) {
|
|
839
|
-
throw new Error("No saved connector token. Run `ragent connect --token <token>` first.");
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
function getConfiguredServiceBackend() {
|
|
843
|
-
const config = loadConfig();
|
|
844
|
-
if (config.serviceBackend === "systemd" || config.serviceBackend === "pidfile") {
|
|
845
|
-
return config.serviceBackend;
|
|
846
|
-
}
|
|
847
|
-
if (fs2.existsSync(SERVICE_FILE)) return "systemd";
|
|
848
|
-
if (fs2.existsSync(FALLBACK_PID_FILE)) return "pidfile";
|
|
849
|
-
return null;
|
|
850
|
-
}
|
|
851
|
-
async function canUseSystemdUser() {
|
|
852
|
-
if (os4.platform() !== "linux") return false;
|
|
853
|
-
try {
|
|
854
|
-
await execAsync("systemctl --user --version", { timeout: 4e3 });
|
|
855
|
-
await execAsync("systemctl --user show-environment", { timeout: 4e3 });
|
|
856
|
-
return true;
|
|
857
|
-
} catch {
|
|
858
|
-
return false;
|
|
859
862
|
}
|
|
863
|
+
return result;
|
|
860
864
|
}
|
|
861
|
-
async function runSystemctlUser(args, options = {}) {
|
|
862
|
-
const command = `systemctl --user ${args.map(shellQuote).join(" ")}`;
|
|
863
|
-
return execAsync(command, { timeout: options.timeout ?? 1e4 });
|
|
864
|
-
}
|
|
865
|
-
function buildSystemdUnit() {
|
|
866
|
-
return `[Unit]
|
|
867
|
-
Description=rAgent Live connector
|
|
868
|
-
After=network-online.target
|
|
869
|
-
Wants=network-online.target
|
|
870
|
-
|
|
871
|
-
[Service]
|
|
872
|
-
Type=simple
|
|
873
|
-
ExecStart=${process.execPath} ${__filename} run
|
|
874
|
-
Restart=always
|
|
875
|
-
RestartSec=3
|
|
876
|
-
Environment=NODE_ENV=production
|
|
877
|
-
NoNewPrivileges=true
|
|
878
|
-
PrivateTmp=true
|
|
879
|
-
ProtectSystem=strict
|
|
880
|
-
ProtectHome=read-only
|
|
881
|
-
ReadWritePaths=%h/.config/ragent
|
|
882
865
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
866
|
+
// src/session-streamer.ts
|
|
867
|
+
var import_node_child_process2 = require("child_process");
|
|
868
|
+
var import_node_fs = require("fs");
|
|
869
|
+
var import_node_path = require("path");
|
|
870
|
+
var import_node_os = require("os");
|
|
871
|
+
var pty = __toESM(require("node-pty"));
|
|
872
|
+
var STOP_DEBOUNCE_MS = 2e3;
|
|
873
|
+
function parsePaneTarget(sessionId) {
|
|
874
|
+
if (!sessionId.startsWith("tmux:")) return null;
|
|
875
|
+
const rest = sessionId.slice("tmux:".length);
|
|
876
|
+
if (!rest) return null;
|
|
877
|
+
return rest;
|
|
886
878
|
}
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
await runSystemctlUser(["enable", SERVICE_NAME]);
|
|
894
|
-
}
|
|
895
|
-
if (opts.start) {
|
|
896
|
-
await runSystemctlUser(["restart", SERVICE_NAME]);
|
|
897
|
-
}
|
|
898
|
-
saveConfigPatch({ serviceBackend: "systemd" });
|
|
899
|
-
console.log(`[rAgent] Installed systemd user service at ${SERVICE_FILE}`);
|
|
879
|
+
function parseScreenSession(sessionId) {
|
|
880
|
+
if (!sessionId.startsWith("screen:")) return null;
|
|
881
|
+
const rest = sessionId.slice("screen:".length);
|
|
882
|
+
if (!rest) return null;
|
|
883
|
+
const name = rest.split(":")[0];
|
|
884
|
+
return name || null;
|
|
900
885
|
}
|
|
901
|
-
function
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
} catch {
|
|
907
|
-
return null;
|
|
908
|
-
}
|
|
886
|
+
function parseZellijSession(sessionId) {
|
|
887
|
+
if (!sessionId.startsWith("zellij:")) return null;
|
|
888
|
+
const rest = sessionId.slice("zellij:".length);
|
|
889
|
+
if (!rest) return null;
|
|
890
|
+
return rest.split(":")[0] || null;
|
|
909
891
|
}
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
892
|
+
var SessionStreamer = class {
|
|
893
|
+
active = /* @__PURE__ */ new Map();
|
|
894
|
+
pendingStops = /* @__PURE__ */ new Map();
|
|
895
|
+
sendFn;
|
|
896
|
+
onStreamStopped;
|
|
897
|
+
constructor(sendFn, onStreamStopped) {
|
|
898
|
+
this.sendFn = sendFn;
|
|
899
|
+
this.onStreamStopped = onStreamStopped;
|
|
900
|
+
this.cleanupStaleStreams();
|
|
917
901
|
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
console.log(`[rAgent] Started fallback background service (pid ${child.pid})`);
|
|
940
|
-
console.log(`[rAgent] Logs: ${FALLBACK_LOG_FILE}`);
|
|
941
|
-
}
|
|
942
|
-
async function stopPidfileService() {
|
|
943
|
-
const pid = readFallbackPid();
|
|
944
|
-
if (!pid || !isProcessRunning(pid)) {
|
|
945
|
-
try {
|
|
946
|
-
fs2.unlinkSync(FALLBACK_PID_FILE);
|
|
947
|
-
} catch {
|
|
948
|
-
}
|
|
949
|
-
console.log("[rAgent] Service is not running.");
|
|
950
|
-
return;
|
|
951
|
-
}
|
|
952
|
-
process.kill(pid, "SIGTERM");
|
|
953
|
-
for (let i = 0; i < 20; i += 1) {
|
|
954
|
-
if (!isProcessRunning(pid)) break;
|
|
955
|
-
await wait(150);
|
|
956
|
-
}
|
|
957
|
-
if (isProcessRunning(pid)) {
|
|
958
|
-
process.kill(pid, "SIGKILL");
|
|
959
|
-
}
|
|
960
|
-
try {
|
|
961
|
-
fs2.unlinkSync(FALLBACK_PID_FILE);
|
|
962
|
-
} catch {
|
|
963
|
-
}
|
|
964
|
-
console.log(`[rAgent] Stopped fallback background service (pid ${pid})`);
|
|
965
|
-
}
|
|
966
|
-
async function ensureServiceInstalled(opts = {}) {
|
|
967
|
-
const wantsSystemd = await canUseSystemdUser();
|
|
968
|
-
if (wantsSystemd) {
|
|
969
|
-
await installSystemdService(opts);
|
|
970
|
-
return "systemd";
|
|
971
|
-
}
|
|
972
|
-
saveConfigPatch({ serviceBackend: "pidfile" });
|
|
973
|
-
if (opts.start) {
|
|
974
|
-
await startPidfileService();
|
|
975
|
-
} else {
|
|
976
|
-
console.log(
|
|
977
|
-
"[rAgent] systemd user manager unavailable; using fallback pidfile backend."
|
|
978
|
-
);
|
|
979
|
-
}
|
|
980
|
-
return "pidfile";
|
|
981
|
-
}
|
|
982
|
-
async function startService() {
|
|
983
|
-
const backend = getConfiguredServiceBackend();
|
|
984
|
-
if (backend === "systemd") {
|
|
985
|
-
await runSystemctlUser(["start", SERVICE_NAME]);
|
|
986
|
-
console.log("[rAgent] Started service via systemd.");
|
|
987
|
-
return;
|
|
988
|
-
}
|
|
989
|
-
if (backend === "pidfile") {
|
|
990
|
-
await startPidfileService();
|
|
991
|
-
return;
|
|
992
|
-
}
|
|
993
|
-
await ensureServiceInstalled({ start: true, enable: true });
|
|
994
|
-
}
|
|
995
|
-
async function stopService() {
|
|
996
|
-
const backend = getConfiguredServiceBackend();
|
|
997
|
-
if (backend === "systemd") {
|
|
998
|
-
await runSystemctlUser(["stop", SERVICE_NAME]);
|
|
999
|
-
console.log("[rAgent] Stopped service via systemd.");
|
|
1000
|
-
return;
|
|
1001
|
-
}
|
|
1002
|
-
await stopPidfileService();
|
|
1003
|
-
}
|
|
1004
|
-
async function restartService() {
|
|
1005
|
-
const backend = getConfiguredServiceBackend();
|
|
1006
|
-
if (backend === "systemd") {
|
|
1007
|
-
await runSystemctlUser(["restart", SERVICE_NAME]);
|
|
1008
|
-
console.log("[rAgent] Restarted service via systemd.");
|
|
1009
|
-
return;
|
|
1010
|
-
}
|
|
1011
|
-
await stopPidfileService();
|
|
1012
|
-
await startPidfileService();
|
|
1013
|
-
}
|
|
1014
|
-
async function printServiceStatus() {
|
|
1015
|
-
const backend = getConfiguredServiceBackend();
|
|
1016
|
-
if (backend === "systemd") {
|
|
1017
|
-
const status = await execAsync(
|
|
1018
|
-
`systemctl --user status ${shellQuote(SERVICE_NAME)} --no-pager --lines=20`,
|
|
1019
|
-
{ timeout: 1e4 }
|
|
1020
|
-
).catch((error) => {
|
|
1021
|
-
console.log(error.message);
|
|
1022
|
-
return "";
|
|
1023
|
-
});
|
|
1024
|
-
if (status) {
|
|
1025
|
-
process.stdout.write(status);
|
|
1026
|
-
}
|
|
1027
|
-
return;
|
|
1028
|
-
}
|
|
1029
|
-
const pid = readFallbackPid();
|
|
1030
|
-
if (pid && isProcessRunning(pid)) {
|
|
1031
|
-
console.log(`[rAgent] fallback service running (pid ${pid})`);
|
|
1032
|
-
console.log(`[rAgent] logs: ${FALLBACK_LOG_FILE}`);
|
|
1033
|
-
return;
|
|
1034
|
-
}
|
|
1035
|
-
console.log("[rAgent] service is not running.");
|
|
1036
|
-
}
|
|
1037
|
-
async function printServiceLogs(opts) {
|
|
1038
|
-
const lines = Number.parseInt(String(opts.lines || 100), 10) || 100;
|
|
1039
|
-
const follow = Boolean(opts.follow);
|
|
1040
|
-
const backend = getConfiguredServiceBackend();
|
|
1041
|
-
if (backend === "systemd") {
|
|
1042
|
-
if (follow) {
|
|
1043
|
-
await new Promise((resolve) => {
|
|
1044
|
-
const child = (0, import_child_process.spawn)(
|
|
1045
|
-
"journalctl",
|
|
1046
|
-
["--user", "-u", SERVICE_NAME, "-f", "-n", String(lines)],
|
|
1047
|
-
{ stdio: "inherit" }
|
|
1048
|
-
);
|
|
1049
|
-
child.on("exit", () => resolve());
|
|
1050
|
-
});
|
|
1051
|
-
return;
|
|
1052
|
-
}
|
|
1053
|
-
const output = await execAsync(
|
|
1054
|
-
`journalctl --user -u ${shellQuote(SERVICE_NAME)} -n ${lines} --no-pager`,
|
|
1055
|
-
{ timeout: 12e3 }
|
|
1056
|
-
);
|
|
1057
|
-
process.stdout.write(output);
|
|
1058
|
-
return;
|
|
1059
|
-
}
|
|
1060
|
-
if (!fs2.existsSync(FALLBACK_LOG_FILE)) {
|
|
1061
|
-
console.log(`[rAgent] No log file found at ${FALLBACK_LOG_FILE}`);
|
|
1062
|
-
return;
|
|
1063
|
-
}
|
|
1064
|
-
if (follow) {
|
|
1065
|
-
await new Promise((resolve) => {
|
|
1066
|
-
const child = (0, import_child_process.spawn)("tail", ["-n", String(lines), "-f", FALLBACK_LOG_FILE], {
|
|
1067
|
-
stdio: "inherit"
|
|
1068
|
-
});
|
|
1069
|
-
child.on("exit", () => resolve());
|
|
1070
|
-
});
|
|
1071
|
-
return;
|
|
1072
|
-
}
|
|
1073
|
-
const content = fs2.readFileSync(FALLBACK_LOG_FILE, "utf8");
|
|
1074
|
-
const tail = content.split("\n").slice(-lines).join("\n");
|
|
1075
|
-
process.stdout.write(`${tail}${tail.endsWith("\n") ? "" : "\n"}`);
|
|
1076
|
-
}
|
|
1077
|
-
async function uninstallService() {
|
|
1078
|
-
const backend = getConfiguredServiceBackend();
|
|
1079
|
-
if (backend === "systemd") {
|
|
1080
|
-
await runSystemctlUser(["stop", SERVICE_NAME]).catch(() => void 0);
|
|
1081
|
-
await runSystemctlUser(["disable", SERVICE_NAME]).catch(() => void 0);
|
|
1082
|
-
try {
|
|
1083
|
-
fs2.unlinkSync(SERVICE_FILE);
|
|
1084
|
-
} catch {
|
|
1085
|
-
}
|
|
1086
|
-
await runSystemctlUser(["daemon-reload"]).catch(() => void 0);
|
|
1087
|
-
} else {
|
|
1088
|
-
await stopPidfileService();
|
|
1089
|
-
}
|
|
1090
|
-
const config = loadConfig();
|
|
1091
|
-
delete config.serviceBackend;
|
|
1092
|
-
saveConfig(config);
|
|
1093
|
-
console.log("[rAgent] Service uninstalled.");
|
|
1094
|
-
}
|
|
1095
|
-
function requestStopSelfService() {
|
|
1096
|
-
const backend = getConfiguredServiceBackend();
|
|
1097
|
-
if (backend === "systemd") {
|
|
1098
|
-
const child = (0, import_child_process.spawn)("systemctl", ["--user", "stop", SERVICE_NAME], {
|
|
1099
|
-
detached: true,
|
|
1100
|
-
stdio: "ignore"
|
|
1101
|
-
});
|
|
1102
|
-
child.unref();
|
|
1103
|
-
return;
|
|
1104
|
-
}
|
|
1105
|
-
if (backend === "pidfile") {
|
|
1106
|
-
try {
|
|
1107
|
-
fs2.unlinkSync(FALLBACK_PID_FILE);
|
|
1108
|
-
} catch {
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
// src/websocket.ts
|
|
1114
|
-
var import_ws = __toESM(require("ws"));
|
|
1115
|
-
var BACKPRESSURE_HIGH_WATER = 256 * 1024;
|
|
1116
|
-
function sanitizeForJson(str) {
|
|
1117
|
-
return str.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD");
|
|
1118
|
-
}
|
|
1119
|
-
function sendToGroup(ws, group, data) {
|
|
1120
|
-
if (!group || ws.readyState !== import_ws.default.OPEN) return;
|
|
1121
|
-
if (ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) {
|
|
1122
|
-
return;
|
|
1123
|
-
}
|
|
1124
|
-
const sanitized = sanitizePayload(data);
|
|
1125
|
-
ws.send(
|
|
1126
|
-
JSON.stringify({
|
|
1127
|
-
type: "sendToGroup",
|
|
1128
|
-
group,
|
|
1129
|
-
dataType: "json",
|
|
1130
|
-
data: sanitized,
|
|
1131
|
-
noEcho: true
|
|
1132
|
-
})
|
|
1133
|
-
);
|
|
1134
|
-
}
|
|
1135
|
-
function sanitizePayload(obj) {
|
|
1136
|
-
const result = {};
|
|
1137
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
1138
|
-
if (typeof value === "string") {
|
|
1139
|
-
result[key] = sanitizeForJson(value);
|
|
1140
|
-
} else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
1141
|
-
result[key] = sanitizePayload(value);
|
|
1142
|
-
} else {
|
|
1143
|
-
result[key] = value;
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
return result;
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
// src/session-streamer.ts
|
|
1150
|
-
var import_node_child_process3 = require("child_process");
|
|
1151
|
-
var import_node_fs = require("fs");
|
|
1152
|
-
var import_node_path = require("path");
|
|
1153
|
-
var import_node_os = require("os");
|
|
1154
|
-
var pty2 = __toESM(require("node-pty"));
|
|
1155
|
-
var STOP_DEBOUNCE_MS = 2e3;
|
|
1156
|
-
function parsePaneTarget(sessionId) {
|
|
1157
|
-
if (!sessionId.startsWith("tmux:")) return null;
|
|
1158
|
-
const rest = sessionId.slice("tmux:".length);
|
|
1159
|
-
if (!rest) return null;
|
|
1160
|
-
return rest;
|
|
1161
|
-
}
|
|
1162
|
-
function parseScreenSession(sessionId) {
|
|
1163
|
-
if (!sessionId.startsWith("screen:")) return null;
|
|
1164
|
-
const rest = sessionId.slice("screen:".length);
|
|
1165
|
-
if (!rest) return null;
|
|
1166
|
-
const name = rest.split(":")[0];
|
|
1167
|
-
return name || null;
|
|
1168
|
-
}
|
|
1169
|
-
function parseZellijSession(sessionId) {
|
|
1170
|
-
if (!sessionId.startsWith("zellij:")) return null;
|
|
1171
|
-
const rest = sessionId.slice("zellij:".length);
|
|
1172
|
-
if (!rest) return null;
|
|
1173
|
-
return rest.split(":")[0] || null;
|
|
1174
|
-
}
|
|
1175
|
-
var SessionStreamer = class {
|
|
1176
|
-
active = /* @__PURE__ */ new Map();
|
|
1177
|
-
pendingStops = /* @__PURE__ */ new Map();
|
|
1178
|
-
sendFn;
|
|
1179
|
-
onStreamStopped;
|
|
1180
|
-
constructor(sendFn, onStreamStopped) {
|
|
1181
|
-
this.sendFn = sendFn;
|
|
1182
|
-
this.onStreamStopped = onStreamStopped;
|
|
902
|
+
/**
|
|
903
|
+
* Remove orphaned FIFO directories left behind by a previous crash.
|
|
904
|
+
* These stale FIFOs can cause tmux pipe-pane to block if the reader is dead.
|
|
905
|
+
*/
|
|
906
|
+
cleanupStaleStreams() {
|
|
907
|
+
const streamsBase = (0, import_node_path.join)((0, import_node_os.homedir)(), ".config", "ragent", "streams");
|
|
908
|
+
try {
|
|
909
|
+
const entries = (0, import_node_fs.readdirSync)(streamsBase);
|
|
910
|
+
for (const entry of entries) {
|
|
911
|
+
if (entry.startsWith("s-")) {
|
|
912
|
+
try {
|
|
913
|
+
(0, import_node_fs.rmSync)((0, import_node_path.join)(streamsBase, entry), { recursive: true, force: true });
|
|
914
|
+
} catch {
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
if (entries.some((e) => e.startsWith("s-"))) {
|
|
919
|
+
console.log("[rAgent] Cleaned up stale stream directories from previous run.");
|
|
920
|
+
}
|
|
921
|
+
} catch {
|
|
922
|
+
}
|
|
1183
923
|
}
|
|
1184
924
|
/**
|
|
1185
925
|
* Start streaming a session. Dispatches based on session type prefix.
|
|
@@ -1223,12 +963,27 @@ var SessionStreamer = class {
|
|
|
1223
963
|
}
|
|
1224
964
|
}
|
|
1225
965
|
/**
|
|
1226
|
-
*
|
|
966
|
+
* Resize a PTY-attached stream (screen/zellij).
|
|
1227
967
|
*/
|
|
1228
|
-
|
|
968
|
+
resize(sessionId, cols, rows) {
|
|
1229
969
|
const stream = this.active.get(sessionId);
|
|
1230
|
-
if (!stream) return;
|
|
1231
|
-
|
|
970
|
+
if (!stream || stream.stopped || stream.streamType !== "pty-attach" || !stream.ptyProc) return;
|
|
971
|
+
try {
|
|
972
|
+
stream.ptyProc.resize(cols, rows);
|
|
973
|
+
} catch (error) {
|
|
974
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
975
|
+
if (!message.includes("EBADF")) {
|
|
976
|
+
console.warn(`[rAgent] Resize failed for ${sessionId}: ${message}`);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Stop streaming a session (debounced to absorb React remount cycles).
|
|
982
|
+
*/
|
|
983
|
+
stopStream(sessionId) {
|
|
984
|
+
const stream = this.active.get(sessionId);
|
|
985
|
+
if (!stream) return;
|
|
986
|
+
if (this.pendingStops.has(sessionId)) return;
|
|
1232
987
|
const timer = setTimeout(() => {
|
|
1233
988
|
this.pendingStops.delete(sessionId);
|
|
1234
989
|
const s = this.active.get(sessionId);
|
|
@@ -1287,7 +1042,7 @@ var SessionStreamer = class {
|
|
|
1287
1042
|
tmpDir = (0, import_node_fs.mkdtempSync)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "ragent-stream-"));
|
|
1288
1043
|
}
|
|
1289
1044
|
const fifoPath = (0, import_node_path.join)(tmpDir, "pane.fifo");
|
|
1290
|
-
(0,
|
|
1045
|
+
(0, import_node_child_process2.execFileSync)("mkfifo", ["-m", "600", fifoPath]);
|
|
1291
1046
|
const stream = {
|
|
1292
1047
|
sessionId,
|
|
1293
1048
|
streamType: "tmux-pipe",
|
|
@@ -1298,13 +1053,14 @@ var SessionStreamer = class {
|
|
|
1298
1053
|
stopped: false,
|
|
1299
1054
|
initializing: true,
|
|
1300
1055
|
initBuffer: [],
|
|
1056
|
+
cleanEnv,
|
|
1301
1057
|
ptyProc: null
|
|
1302
1058
|
};
|
|
1303
1059
|
this.active.set(sessionId, stream);
|
|
1304
1060
|
try {
|
|
1305
|
-
(0,
|
|
1061
|
+
(0, import_node_child_process2.execFileSync)(
|
|
1306
1062
|
"tmux",
|
|
1307
|
-
["pipe-pane", "-O", "-t", paneTarget, `cat > ${fifoPath}`],
|
|
1063
|
+
["pipe-pane", "-O", "-t", paneTarget, `cat > ${shellQuote(fifoPath)}`],
|
|
1308
1064
|
{ env: cleanEnv, timeout: 5e3 }
|
|
1309
1065
|
);
|
|
1310
1066
|
} catch (error) {
|
|
@@ -1313,7 +1069,7 @@ var SessionStreamer = class {
|
|
|
1313
1069
|
console.warn(`[rAgent] Failed pipe-pane for ${sessionId}: ${message}`);
|
|
1314
1070
|
return false;
|
|
1315
1071
|
}
|
|
1316
|
-
const catProc = (0,
|
|
1072
|
+
const catProc = (0, import_node_child_process2.spawn)("cat", [fifoPath], { env: cleanEnv, stdio: ["ignore", "pipe", "ignore"] });
|
|
1317
1073
|
stream.catProc = catProc;
|
|
1318
1074
|
catProc.stdout.on("data", (chunk) => {
|
|
1319
1075
|
if (stream.stopped) return;
|
|
@@ -1333,9 +1089,20 @@ var SessionStreamer = class {
|
|
|
1333
1089
|
this.onStreamStopped?.(sessionId);
|
|
1334
1090
|
}
|
|
1335
1091
|
});
|
|
1092
|
+
try {
|
|
1093
|
+
const scrollback = (0, import_node_child_process2.execFileSync)(
|
|
1094
|
+
"tmux",
|
|
1095
|
+
["capture-pane", "-t", paneTarget, "-p", "-e", "-S", "-5000", "-E", "-1"],
|
|
1096
|
+
{ env: cleanEnv, timeout: 1e4, encoding: "utf-8" }
|
|
1097
|
+
);
|
|
1098
|
+
if (scrollback && scrollback.length > 0) {
|
|
1099
|
+
this.sendFn(sessionId, scrollback);
|
|
1100
|
+
}
|
|
1101
|
+
} catch {
|
|
1102
|
+
}
|
|
1336
1103
|
this.sendFn(sessionId, "\x1B[2J\x1B[H");
|
|
1337
1104
|
try {
|
|
1338
|
-
const initial = (0,
|
|
1105
|
+
const initial = (0, import_node_child_process2.execFileSync)(
|
|
1339
1106
|
"tmux",
|
|
1340
1107
|
["capture-pane", "-t", paneTarget, "-p", "-e"],
|
|
1341
1108
|
{ env: cleanEnv, timeout: 5e3, encoding: "utf-8" }
|
|
@@ -1346,7 +1113,7 @@ var SessionStreamer = class {
|
|
|
1346
1113
|
} catch {
|
|
1347
1114
|
}
|
|
1348
1115
|
try {
|
|
1349
|
-
const cursorInfo = (0,
|
|
1116
|
+
const cursorInfo = (0, import_node_child_process2.execFileSync)(
|
|
1350
1117
|
"tmux",
|
|
1351
1118
|
["display-message", "-t", paneTarget, "-p", "#{cursor_x} #{cursor_y}"],
|
|
1352
1119
|
{ env: cleanEnv, timeout: 5e3, encoding: "utf-8" }
|
|
@@ -1362,12 +1129,7 @@ var SessionStreamer = class {
|
|
|
1362
1129
|
} catch {
|
|
1363
1130
|
}
|
|
1364
1131
|
stream.initializing = false;
|
|
1365
|
-
|
|
1366
|
-
for (const buffered of stream.initBuffer) {
|
|
1367
|
-
this.sendFn(sessionId, buffered);
|
|
1368
|
-
}
|
|
1369
|
-
stream.initBuffer = [];
|
|
1370
|
-
}
|
|
1132
|
+
stream.initBuffer = [];
|
|
1371
1133
|
console.log(`[rAgent] Started streaming: ${sessionId} (pane: ${paneTarget})`);
|
|
1372
1134
|
return true;
|
|
1373
1135
|
} catch (error) {
|
|
@@ -1383,7 +1145,7 @@ var SessionStreamer = class {
|
|
|
1383
1145
|
const sessionName = parseScreenSession(sessionId);
|
|
1384
1146
|
if (!sessionName) return false;
|
|
1385
1147
|
try {
|
|
1386
|
-
const proc =
|
|
1148
|
+
const proc = pty.spawn("screen", ["-x", sessionName], {
|
|
1387
1149
|
name: "xterm-256color",
|
|
1388
1150
|
cols: 80,
|
|
1389
1151
|
rows: 30,
|
|
@@ -1432,7 +1194,7 @@ var SessionStreamer = class {
|
|
|
1432
1194
|
const sessionName = parseZellijSession(sessionId);
|
|
1433
1195
|
if (!sessionName) return false;
|
|
1434
1196
|
try {
|
|
1435
|
-
const proc =
|
|
1197
|
+
const proc = pty.spawn("zellij", ["attach", sessionName], {
|
|
1436
1198
|
name: "xterm-256color",
|
|
1437
1199
|
cols: 80,
|
|
1438
1200
|
rows: 30,
|
|
@@ -1481,7 +1243,10 @@ var SessionStreamer = class {
|
|
|
1481
1243
|
stream.stopped = true;
|
|
1482
1244
|
if (stream.streamType === "tmux-pipe") {
|
|
1483
1245
|
try {
|
|
1484
|
-
(0,
|
|
1246
|
+
(0, import_node_child_process2.execFileSync)("tmux", ["pipe-pane", "-t", stream.paneTarget], {
|
|
1247
|
+
timeout: 5e3,
|
|
1248
|
+
env: stream.cleanEnv
|
|
1249
|
+
});
|
|
1485
1250
|
} catch {
|
|
1486
1251
|
}
|
|
1487
1252
|
if (stream.catProc && !stream.catProc.killed) {
|
|
@@ -1506,10 +1271,680 @@ var SessionStreamer = class {
|
|
|
1506
1271
|
}
|
|
1507
1272
|
}
|
|
1508
1273
|
}
|
|
1509
|
-
};
|
|
1274
|
+
};
|
|
1275
|
+
|
|
1276
|
+
// src/crypto-channel.ts
|
|
1277
|
+
var import_node_crypto = require("crypto");
|
|
1278
|
+
function deriveAesKey(sessionKey) {
|
|
1279
|
+
return Buffer.from(sessionKey.slice(0, 64), "hex");
|
|
1280
|
+
}
|
|
1281
|
+
function encryptPayload(data, sessionKey) {
|
|
1282
|
+
const key = deriveAesKey(sessionKey);
|
|
1283
|
+
const iv = (0, import_node_crypto.randomBytes)(12);
|
|
1284
|
+
const cipher = (0, import_node_crypto.createCipheriv)("aes-256-gcm", key, iv);
|
|
1285
|
+
const encrypted = Buffer.concat([
|
|
1286
|
+
cipher.update(data, "utf-8"),
|
|
1287
|
+
cipher.final()
|
|
1288
|
+
]);
|
|
1289
|
+
const authTag = cipher.getAuthTag();
|
|
1290
|
+
const combined = Buffer.concat([encrypted, authTag]);
|
|
1291
|
+
return {
|
|
1292
|
+
enc: combined.toString("base64"),
|
|
1293
|
+
iv: iv.toString("base64")
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
function decryptPayload(enc, iv, sessionKey) {
|
|
1297
|
+
try {
|
|
1298
|
+
const key = deriveAesKey(sessionKey);
|
|
1299
|
+
const ivBuf = Buffer.from(iv, "base64");
|
|
1300
|
+
const combined = Buffer.from(enc, "base64");
|
|
1301
|
+
if (combined.length < 16) return null;
|
|
1302
|
+
const ciphertext = combined.subarray(0, combined.length - 16);
|
|
1303
|
+
const authTag = combined.subarray(combined.length - 16);
|
|
1304
|
+
const decipher = (0, import_node_crypto.createDecipheriv)("aes-256-gcm", key, ivBuf);
|
|
1305
|
+
decipher.setAuthTag(authTag);
|
|
1306
|
+
const decrypted = Buffer.concat([
|
|
1307
|
+
decipher.update(ciphertext),
|
|
1308
|
+
decipher.final()
|
|
1309
|
+
]);
|
|
1310
|
+
return decrypted.toString("utf-8");
|
|
1311
|
+
} catch {
|
|
1312
|
+
return null;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// src/pty.ts
|
|
1317
|
+
var import_node_child_process3 = require("child_process");
|
|
1318
|
+
var pty2 = __toESM(require("node-pty"));
|
|
1319
|
+
function isInteractiveShell(command) {
|
|
1320
|
+
const trimmed = String(command).trim();
|
|
1321
|
+
return ["bash", "sh", "zsh", "fish"].includes(trimmed);
|
|
1322
|
+
}
|
|
1323
|
+
function spawnConnectorShell(command, onData, onExit) {
|
|
1324
|
+
const shell = "bash";
|
|
1325
|
+
const args = isInteractiveShell(command) ? [] : ["-lc", command];
|
|
1326
|
+
const processName = isInteractiveShell(command) ? command : shell;
|
|
1327
|
+
const ptyProcess = pty2.spawn(processName, args, {
|
|
1328
|
+
name: "xterm-color",
|
|
1329
|
+
cols: 80,
|
|
1330
|
+
rows: 30,
|
|
1331
|
+
cwd: process.cwd(),
|
|
1332
|
+
env: process.env
|
|
1333
|
+
});
|
|
1334
|
+
ptyProcess.onData(onData);
|
|
1335
|
+
ptyProcess.onExit(onExit);
|
|
1336
|
+
return ptyProcess;
|
|
1337
|
+
}
|
|
1338
|
+
async function stopTmuxPaneBySessionId(sessionId) {
|
|
1339
|
+
if (!sessionId.startsWith("tmux:")) return false;
|
|
1340
|
+
const paneTarget = sessionId.slice("tmux:".length).trim();
|
|
1341
|
+
if (!paneTarget) return false;
|
|
1342
|
+
await execAsync(`tmux kill-pane -t ${shellQuote(paneTarget)}`, { timeout: 5e3 });
|
|
1343
|
+
return true;
|
|
1344
|
+
}
|
|
1345
|
+
async function sendInputToTmux(sessionId, data) {
|
|
1346
|
+
if (!sessionId.startsWith("tmux:")) return;
|
|
1347
|
+
const target = sessionId.slice("tmux:".length).trim();
|
|
1348
|
+
if (!target) return;
|
|
1349
|
+
const sessionName = target.split(":")[0].split(".")[0];
|
|
1350
|
+
if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(sessionName)) {
|
|
1351
|
+
console.warn(`[rAgent] Invalid tmux session name: ${sessionName}`);
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
try {
|
|
1355
|
+
await new Promise((resolve, reject) => {
|
|
1356
|
+
(0, import_node_child_process3.execFile)("tmux", ["send-keys", "-t", target, "-l", "--", data], { timeout: 5e3 }, (err) => {
|
|
1357
|
+
if (err) reject(err);
|
|
1358
|
+
else resolve();
|
|
1359
|
+
});
|
|
1360
|
+
});
|
|
1361
|
+
} catch (error) {
|
|
1362
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1363
|
+
console.warn(`[rAgent] Failed to send input to ${sessionId}: ${message}`);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
async function stopAllDetachedTmuxSessions() {
|
|
1367
|
+
try {
|
|
1368
|
+
const raw = await execAsync(
|
|
1369
|
+
"tmux list-sessions -F '#{session_name}|#{session_attached}'",
|
|
1370
|
+
{ timeout: 5e3 }
|
|
1371
|
+
);
|
|
1372
|
+
const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
1373
|
+
let killed = 0;
|
|
1374
|
+
for (const line of lines) {
|
|
1375
|
+
const [name, attached] = line.split("|");
|
|
1376
|
+
if (attached === "0" && name) {
|
|
1377
|
+
try {
|
|
1378
|
+
await execAsync(`tmux kill-session -t ${shellQuote(name)}`, { timeout: 5e3 });
|
|
1379
|
+
killed++;
|
|
1380
|
+
} catch {
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
return killed;
|
|
1385
|
+
} catch {
|
|
1386
|
+
return 0;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// src/shell-manager.ts
|
|
1391
|
+
var ShellManager = class {
|
|
1392
|
+
ptyProcess = null;
|
|
1393
|
+
suppressNextRespawn = false;
|
|
1394
|
+
shouldRun = true;
|
|
1395
|
+
sendOutput;
|
|
1396
|
+
command;
|
|
1397
|
+
constructor(command, sendOutput) {
|
|
1398
|
+
this.command = command;
|
|
1399
|
+
this.sendOutput = sendOutput;
|
|
1400
|
+
}
|
|
1401
|
+
/** Start the initial shell process. */
|
|
1402
|
+
spawn() {
|
|
1403
|
+
this.spawnOrRespawn();
|
|
1404
|
+
}
|
|
1405
|
+
/** Kill current shell and spawn a new one. */
|
|
1406
|
+
restart() {
|
|
1407
|
+
this.kill();
|
|
1408
|
+
this.spawnOrRespawn();
|
|
1409
|
+
}
|
|
1410
|
+
/** Kill the current shell process (suppresses auto-respawn). */
|
|
1411
|
+
kill() {
|
|
1412
|
+
if (!this.ptyProcess) return;
|
|
1413
|
+
this.suppressNextRespawn = true;
|
|
1414
|
+
try {
|
|
1415
|
+
this.ptyProcess.kill();
|
|
1416
|
+
} catch {
|
|
1417
|
+
}
|
|
1418
|
+
this.ptyProcess = null;
|
|
1419
|
+
}
|
|
1420
|
+
/** Write input data to the PTY process. */
|
|
1421
|
+
write(data) {
|
|
1422
|
+
if (this.ptyProcess) this.ptyProcess.write(data);
|
|
1423
|
+
}
|
|
1424
|
+
/** Resize the PTY process. */
|
|
1425
|
+
resize(cols, rows) {
|
|
1426
|
+
try {
|
|
1427
|
+
if (this.ptyProcess) this.ptyProcess.resize(cols, rows);
|
|
1428
|
+
} catch (error) {
|
|
1429
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1430
|
+
if (!message.includes("EBADF")) {
|
|
1431
|
+
console.warn(`[rAgent] Resize failed: ${message}`);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
/** Signal that the agent is shutting down (prevents respawn). */
|
|
1436
|
+
stop() {
|
|
1437
|
+
this.shouldRun = false;
|
|
1438
|
+
this.kill();
|
|
1439
|
+
}
|
|
1440
|
+
spawnOrRespawn() {
|
|
1441
|
+
this.ptyProcess = spawnConnectorShell(this.command, this.sendOutput, () => {
|
|
1442
|
+
if (this.suppressNextRespawn) {
|
|
1443
|
+
this.suppressNextRespawn = false;
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
if (!this.shouldRun) return;
|
|
1447
|
+
console.warn("[rAgent] Shell exited. Restarting shell process.");
|
|
1448
|
+
setTimeout(() => {
|
|
1449
|
+
if (this.shouldRun) this.spawnOrRespawn();
|
|
1450
|
+
}, 200);
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
};
|
|
1454
|
+
|
|
1455
|
+
// src/inventory-manager.ts
|
|
1456
|
+
var os4 = __toESM(require("os"));
|
|
1457
|
+
var import_child_process = require("child_process");
|
|
1458
|
+
var import_ws2 = __toESM(require("ws"));
|
|
1459
|
+
var InventoryManager = class {
|
|
1460
|
+
lastSentFingerprint = "";
|
|
1461
|
+
lastHttpHeartbeatAt = 0;
|
|
1462
|
+
prevCpuSnapshot = null;
|
|
1463
|
+
options;
|
|
1464
|
+
constructor(options) {
|
|
1465
|
+
this.options = options;
|
|
1466
|
+
}
|
|
1467
|
+
/** Update options (e.g., after token refresh). */
|
|
1468
|
+
updateOptions(options) {
|
|
1469
|
+
this.options = options;
|
|
1470
|
+
}
|
|
1471
|
+
/** Announce to the registry group via WebSocket. */
|
|
1472
|
+
async announceToRegistry(ws, registryGroup, type = "heartbeat") {
|
|
1473
|
+
if (ws.readyState !== import_ws2.default.OPEN || !registryGroup) return;
|
|
1474
|
+
const sessions = await collectSessionInventory(this.options.hostId, this.options.command);
|
|
1475
|
+
const vitals = this.collectVitals();
|
|
1476
|
+
sendToGroup(ws, registryGroup, {
|
|
1477
|
+
type,
|
|
1478
|
+
hostId: this.options.hostId,
|
|
1479
|
+
hostName: this.options.hostName,
|
|
1480
|
+
environment: os4.hostname(),
|
|
1481
|
+
sessions,
|
|
1482
|
+
vitals,
|
|
1483
|
+
agentVersion: CURRENT_VERSION,
|
|
1484
|
+
lastSeenAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* Sync inventory: detect changes via fingerprint, send WS announcement
|
|
1489
|
+
* and HTTP heartbeat as needed.
|
|
1490
|
+
*/
|
|
1491
|
+
async syncInventory(ws, groups, force = false) {
|
|
1492
|
+
const sessions = await collectSessionInventory(this.options.hostId, this.options.command);
|
|
1493
|
+
const fingerprint = sessionInventoryFingerprint(sessions);
|
|
1494
|
+
const changed = fingerprint !== this.lastSentFingerprint;
|
|
1495
|
+
const checkpointDue = Date.now() - this.lastHttpHeartbeatAt > HTTP_HEARTBEAT_MS;
|
|
1496
|
+
if (changed || force) {
|
|
1497
|
+
if (ws && ws.readyState === import_ws2.default.OPEN) {
|
|
1498
|
+
await this.announceToRegistry(ws, groups.registryGroup, "inventory");
|
|
1499
|
+
}
|
|
1500
|
+
this.lastSentFingerprint = fingerprint;
|
|
1501
|
+
}
|
|
1502
|
+
if (changed || checkpointDue || force) {
|
|
1503
|
+
await postHeartbeat({
|
|
1504
|
+
portal: this.options.portal,
|
|
1505
|
+
agentToken: this.options.agentToken,
|
|
1506
|
+
hostId: this.options.hostId,
|
|
1507
|
+
hostName: this.options.hostName,
|
|
1508
|
+
command: this.options.command
|
|
1509
|
+
});
|
|
1510
|
+
this.lastHttpHeartbeatAt = Date.now();
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
/** Collect system vitals (CPU, memory, disk). */
|
|
1514
|
+
collectVitals() {
|
|
1515
|
+
const currentSnapshot = this.takeCpuSnapshot();
|
|
1516
|
+
let cpuUsage = 0;
|
|
1517
|
+
if (this.prevCpuSnapshot) {
|
|
1518
|
+
const idleDelta = currentSnapshot.idle - this.prevCpuSnapshot.idle;
|
|
1519
|
+
const totalDelta = currentSnapshot.total - this.prevCpuSnapshot.total;
|
|
1520
|
+
cpuUsage = totalDelta > 0 ? Math.round((totalDelta - idleDelta) / totalDelta * 100) : 0;
|
|
1521
|
+
}
|
|
1522
|
+
this.prevCpuSnapshot = currentSnapshot;
|
|
1523
|
+
const totalMem = os4.totalmem();
|
|
1524
|
+
const freeMem = os4.freemem();
|
|
1525
|
+
const memUsedPct = totalMem > 0 ? Math.round((totalMem - freeMem) / totalMem * 100) : 0;
|
|
1526
|
+
let diskUsedPct = 0;
|
|
1527
|
+
try {
|
|
1528
|
+
const dfOutput = (0, import_child_process.execSync)("df -P / | tail -1", { encoding: "utf8", timeout: 5e3 });
|
|
1529
|
+
const parts = dfOutput.trim().split(/\s+/);
|
|
1530
|
+
if (parts.length >= 5) {
|
|
1531
|
+
diskUsedPct = parseInt(parts[4].replace("%", ""), 10) || 0;
|
|
1532
|
+
}
|
|
1533
|
+
} catch {
|
|
1534
|
+
}
|
|
1535
|
+
return { cpu: cpuUsage, memUsedPct, diskUsedPct };
|
|
1536
|
+
}
|
|
1537
|
+
takeCpuSnapshot() {
|
|
1538
|
+
const cpus2 = os4.cpus();
|
|
1539
|
+
let idle = 0;
|
|
1540
|
+
let total = 0;
|
|
1541
|
+
for (const cpu of cpus2) {
|
|
1542
|
+
for (const type of Object.keys(cpu.times)) {
|
|
1543
|
+
total += cpu.times[type];
|
|
1544
|
+
}
|
|
1545
|
+
idle += cpu.times.idle;
|
|
1546
|
+
}
|
|
1547
|
+
return { idle, total };
|
|
1548
|
+
}
|
|
1549
|
+
};
|
|
1550
|
+
|
|
1551
|
+
// src/connection-manager.ts
|
|
1552
|
+
var import_ws3 = __toESM(require("ws"));
|
|
1553
|
+
var ConnectionManager = class {
|
|
1554
|
+
activeSocket = null;
|
|
1555
|
+
activeGroups = { privateGroup: "", registryGroup: "" };
|
|
1556
|
+
sessionKey = null;
|
|
1557
|
+
reconnectDelay = DEFAULT_RECONNECT_DELAY_MS;
|
|
1558
|
+
wsHeartbeatTimer = null;
|
|
1559
|
+
httpHeartbeatTimer = null;
|
|
1560
|
+
wsPingTimer = null;
|
|
1561
|
+
wsPongTimeout = null;
|
|
1562
|
+
sessionStreamer;
|
|
1563
|
+
outputBuffer;
|
|
1564
|
+
constructor(sessionStreamer, outputBuffer) {
|
|
1565
|
+
this.sessionStreamer = sessionStreamer;
|
|
1566
|
+
this.outputBuffer = outputBuffer;
|
|
1567
|
+
}
|
|
1568
|
+
/** Check if the WebSocket is open and ready. */
|
|
1569
|
+
isReady() {
|
|
1570
|
+
return this.activeSocket !== null && this.activeSocket.readyState === import_ws3.default.OPEN;
|
|
1571
|
+
}
|
|
1572
|
+
/** Set the active socket and groups from a negotiate result. */
|
|
1573
|
+
setConnection(ws, groups, sessionKey) {
|
|
1574
|
+
this.activeSocket = ws;
|
|
1575
|
+
this.activeGroups = groups;
|
|
1576
|
+
this.sessionKey = sessionKey;
|
|
1577
|
+
}
|
|
1578
|
+
/** Reset reconnect delay on successful connection. */
|
|
1579
|
+
resetReconnectDelay() {
|
|
1580
|
+
this.reconnectDelay = DEFAULT_RECONNECT_DELAY_MS;
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Start periodic timers (heartbeat, inventory sync, ping/pong).
|
|
1584
|
+
*/
|
|
1585
|
+
startTimers(onWsHeartbeat, onHttpHeartbeat) {
|
|
1586
|
+
this.wsHeartbeatTimer = setInterval(async () => {
|
|
1587
|
+
if (!this.isReady()) return;
|
|
1588
|
+
await onWsHeartbeat();
|
|
1589
|
+
}, WS_HEARTBEAT_MS);
|
|
1590
|
+
this.httpHeartbeatTimer = setInterval(async () => {
|
|
1591
|
+
if (!this.isReady()) return;
|
|
1592
|
+
await onHttpHeartbeat();
|
|
1593
|
+
}, HTTP_HEARTBEAT_MS);
|
|
1594
|
+
this.wsPingTimer = setInterval(() => {
|
|
1595
|
+
if (!this.activeSocket || this.activeSocket.readyState !== import_ws3.default.OPEN) return;
|
|
1596
|
+
this.activeSocket.ping();
|
|
1597
|
+
this.wsPongTimeout = setTimeout(() => {
|
|
1598
|
+
console.warn("[rAgent] No pong received within 10s \u2014 closing stale connection.");
|
|
1599
|
+
try {
|
|
1600
|
+
this.activeSocket?.terminate();
|
|
1601
|
+
} catch {
|
|
1602
|
+
}
|
|
1603
|
+
}, 1e4);
|
|
1604
|
+
}, 2e4);
|
|
1605
|
+
}
|
|
1606
|
+
/** Handle pong response — clear timeout. */
|
|
1607
|
+
onPong() {
|
|
1608
|
+
if (this.wsPongTimeout) {
|
|
1609
|
+
clearTimeout(this.wsPongTimeout);
|
|
1610
|
+
this.wsPongTimeout = null;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* Cleanup socket and timers.
|
|
1615
|
+
* @param opts.stopStreams - Also stop all session streams (on intentional shutdown)
|
|
1616
|
+
*/
|
|
1617
|
+
cleanup(opts = {}) {
|
|
1618
|
+
if (opts.stopStreams) this.sessionStreamer.stopAll();
|
|
1619
|
+
if (this.wsPingTimer) {
|
|
1620
|
+
clearInterval(this.wsPingTimer);
|
|
1621
|
+
this.wsPingTimer = null;
|
|
1622
|
+
}
|
|
1623
|
+
if (this.wsPongTimeout) {
|
|
1624
|
+
clearTimeout(this.wsPongTimeout);
|
|
1625
|
+
this.wsPongTimeout = null;
|
|
1626
|
+
}
|
|
1627
|
+
if (this.wsHeartbeatTimer) {
|
|
1628
|
+
clearInterval(this.wsHeartbeatTimer);
|
|
1629
|
+
this.wsHeartbeatTimer = null;
|
|
1630
|
+
}
|
|
1631
|
+
if (this.httpHeartbeatTimer) {
|
|
1632
|
+
clearInterval(this.httpHeartbeatTimer);
|
|
1633
|
+
this.httpHeartbeatTimer = null;
|
|
1634
|
+
}
|
|
1635
|
+
if (this.activeSocket) {
|
|
1636
|
+
this.activeSocket.removeAllListeners();
|
|
1637
|
+
try {
|
|
1638
|
+
this.activeSocket.close();
|
|
1639
|
+
} catch {
|
|
1640
|
+
}
|
|
1641
|
+
this.activeSocket = null;
|
|
1642
|
+
}
|
|
1643
|
+
this.activeGroups = { privateGroup: "", registryGroup: "" };
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Drain buffered output and replay through the WebSocket.
|
|
1647
|
+
*/
|
|
1648
|
+
replayBufferedOutput(sendChunk) {
|
|
1649
|
+
const buffered = this.outputBuffer.drain();
|
|
1650
|
+
if (buffered.length > 0) {
|
|
1651
|
+
console.log(
|
|
1652
|
+
`[rAgent] Replaying ${buffered.length} buffered output chunks (${buffered.reduce((sum, c) => sum + Buffer.byteLength(c, "utf8"), 0)} bytes)`
|
|
1653
|
+
);
|
|
1654
|
+
for (const chunk of buffered) {
|
|
1655
|
+
sendChunk(chunk);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
};
|
|
1660
|
+
|
|
1661
|
+
// src/control-dispatcher.ts
|
|
1662
|
+
var crypto2 = __toESM(require("crypto"));
|
|
1663
|
+
var import_ws4 = __toESM(require("ws"));
|
|
1664
|
+
|
|
1665
|
+
// src/service.ts
|
|
1666
|
+
var import_child_process2 = require("child_process");
|
|
1667
|
+
var fs2 = __toESM(require("fs"));
|
|
1668
|
+
var os5 = __toESM(require("os"));
|
|
1669
|
+
function assertConfiguredAgentToken() {
|
|
1670
|
+
const config = loadConfig();
|
|
1671
|
+
if (!config.agentToken) {
|
|
1672
|
+
throw new Error("No saved connector token. Run `ragent connect --token <token>` first.");
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
function getConfiguredServiceBackend() {
|
|
1676
|
+
const config = loadConfig();
|
|
1677
|
+
if (config.serviceBackend === "systemd" || config.serviceBackend === "pidfile") {
|
|
1678
|
+
return config.serviceBackend;
|
|
1679
|
+
}
|
|
1680
|
+
if (fs2.existsSync(SERVICE_FILE)) return "systemd";
|
|
1681
|
+
if (fs2.existsSync(FALLBACK_PID_FILE)) return "pidfile";
|
|
1682
|
+
return null;
|
|
1683
|
+
}
|
|
1684
|
+
async function canUseSystemdUser() {
|
|
1685
|
+
if (os5.platform() !== "linux") return false;
|
|
1686
|
+
try {
|
|
1687
|
+
await execAsync("systemctl --user --version", { timeout: 4e3 });
|
|
1688
|
+
await execAsync("systemctl --user show-environment", { timeout: 4e3 });
|
|
1689
|
+
return true;
|
|
1690
|
+
} catch {
|
|
1691
|
+
return false;
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
async function runSystemctlUser(args, options = {}) {
|
|
1695
|
+
const command = `systemctl --user ${args.map(shellQuote).join(" ")}`;
|
|
1696
|
+
return execAsync(command, { timeout: options.timeout ?? 1e4 });
|
|
1697
|
+
}
|
|
1698
|
+
function buildSystemdUnit() {
|
|
1699
|
+
return `[Unit]
|
|
1700
|
+
Description=rAgent Live connector
|
|
1701
|
+
After=network-online.target
|
|
1702
|
+
Wants=network-online.target
|
|
1703
|
+
|
|
1704
|
+
[Service]
|
|
1705
|
+
Type=simple
|
|
1706
|
+
ExecStart=${process.execPath} ${__filename} run
|
|
1707
|
+
Restart=always
|
|
1708
|
+
RestartSec=3
|
|
1709
|
+
Environment=NODE_ENV=production
|
|
1710
|
+
NoNewPrivileges=true
|
|
1711
|
+
PrivateTmp=true
|
|
1712
|
+
ProtectSystem=strict
|
|
1713
|
+
ProtectHome=read-only
|
|
1714
|
+
ReadWritePaths=%h/.config/ragent
|
|
1715
|
+
|
|
1716
|
+
[Install]
|
|
1717
|
+
WantedBy=default.target
|
|
1718
|
+
`;
|
|
1719
|
+
}
|
|
1720
|
+
async function installSystemdService(opts = {}) {
|
|
1721
|
+
assertConfiguredAgentToken();
|
|
1722
|
+
fs2.mkdirSync(SERVICE_DIR, { recursive: true });
|
|
1723
|
+
fs2.writeFileSync(SERVICE_FILE, buildSystemdUnit(), "utf8");
|
|
1724
|
+
await runSystemctlUser(["daemon-reload"]);
|
|
1725
|
+
if (opts.enable !== false) {
|
|
1726
|
+
await runSystemctlUser(["enable", SERVICE_NAME]);
|
|
1727
|
+
}
|
|
1728
|
+
if (opts.start) {
|
|
1729
|
+
await runSystemctlUser(["restart", SERVICE_NAME]);
|
|
1730
|
+
}
|
|
1731
|
+
saveConfigPatch({ serviceBackend: "systemd" });
|
|
1732
|
+
console.log(`[rAgent] Installed systemd user service at ${SERVICE_FILE}`);
|
|
1733
|
+
}
|
|
1734
|
+
function readFallbackPid() {
|
|
1735
|
+
try {
|
|
1736
|
+
const raw = fs2.readFileSync(FALLBACK_PID_FILE, "utf8").trim();
|
|
1737
|
+
const pid = Number.parseInt(raw, 10);
|
|
1738
|
+
return Number.isInteger(pid) ? pid : null;
|
|
1739
|
+
} catch {
|
|
1740
|
+
return null;
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
function isProcessRunning(pid) {
|
|
1744
|
+
if (!pid || !Number.isInteger(pid)) return false;
|
|
1745
|
+
try {
|
|
1746
|
+
process.kill(pid, 0);
|
|
1747
|
+
return true;
|
|
1748
|
+
} catch {
|
|
1749
|
+
return false;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
async function startPidfileService() {
|
|
1753
|
+
assertConfiguredAgentToken();
|
|
1754
|
+
const existingPid = readFallbackPid();
|
|
1755
|
+
if (existingPid && isProcessRunning(existingPid)) {
|
|
1756
|
+
console.log(`[rAgent] Service already running (pid ${existingPid})`);
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
ensureConfigDir();
|
|
1760
|
+
const logFd = fs2.openSync(FALLBACK_LOG_FILE, "a");
|
|
1761
|
+
const child = (0, import_child_process2.spawn)(process.execPath, [__filename, "run"], {
|
|
1762
|
+
detached: true,
|
|
1763
|
+
stdio: ["ignore", logFd, logFd],
|
|
1764
|
+
cwd: os5.homedir(),
|
|
1765
|
+
env: process.env
|
|
1766
|
+
});
|
|
1767
|
+
child.unref();
|
|
1768
|
+
fs2.closeSync(logFd);
|
|
1769
|
+
fs2.writeFileSync(FALLBACK_PID_FILE, `${child.pid}
|
|
1770
|
+
`, "utf8");
|
|
1771
|
+
saveConfigPatch({ serviceBackend: "pidfile" });
|
|
1772
|
+
console.log(`[rAgent] Started fallback background service (pid ${child.pid})`);
|
|
1773
|
+
console.log(`[rAgent] Logs: ${FALLBACK_LOG_FILE}`);
|
|
1774
|
+
}
|
|
1775
|
+
async function stopPidfileService() {
|
|
1776
|
+
const pid = readFallbackPid();
|
|
1777
|
+
if (!pid || !isProcessRunning(pid)) {
|
|
1778
|
+
try {
|
|
1779
|
+
fs2.unlinkSync(FALLBACK_PID_FILE);
|
|
1780
|
+
} catch {
|
|
1781
|
+
}
|
|
1782
|
+
console.log("[rAgent] Service is not running.");
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
process.kill(pid, "SIGTERM");
|
|
1786
|
+
for (let i = 0; i < 20; i += 1) {
|
|
1787
|
+
if (!isProcessRunning(pid)) break;
|
|
1788
|
+
await wait(150);
|
|
1789
|
+
}
|
|
1790
|
+
if (isProcessRunning(pid)) {
|
|
1791
|
+
process.kill(pid, "SIGKILL");
|
|
1792
|
+
}
|
|
1793
|
+
try {
|
|
1794
|
+
fs2.unlinkSync(FALLBACK_PID_FILE);
|
|
1795
|
+
} catch {
|
|
1796
|
+
}
|
|
1797
|
+
console.log(`[rAgent] Stopped fallback background service (pid ${pid})`);
|
|
1798
|
+
}
|
|
1799
|
+
async function ensureServiceInstalled(opts = {}) {
|
|
1800
|
+
const wantsSystemd = await canUseSystemdUser();
|
|
1801
|
+
if (wantsSystemd) {
|
|
1802
|
+
await installSystemdService(opts);
|
|
1803
|
+
return "systemd";
|
|
1804
|
+
}
|
|
1805
|
+
saveConfigPatch({ serviceBackend: "pidfile" });
|
|
1806
|
+
if (opts.start) {
|
|
1807
|
+
await startPidfileService();
|
|
1808
|
+
} else {
|
|
1809
|
+
console.log(
|
|
1810
|
+
"[rAgent] systemd user manager unavailable; using fallback pidfile backend."
|
|
1811
|
+
);
|
|
1812
|
+
}
|
|
1813
|
+
return "pidfile";
|
|
1814
|
+
}
|
|
1815
|
+
async function startService() {
|
|
1816
|
+
const backend = getConfiguredServiceBackend();
|
|
1817
|
+
if (backend === "systemd") {
|
|
1818
|
+
await runSystemctlUser(["start", SERVICE_NAME]);
|
|
1819
|
+
console.log("[rAgent] Started service via systemd.");
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
if (backend === "pidfile") {
|
|
1823
|
+
await startPidfileService();
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
await ensureServiceInstalled({ start: true, enable: true });
|
|
1827
|
+
}
|
|
1828
|
+
async function stopService() {
|
|
1829
|
+
const backend = getConfiguredServiceBackend();
|
|
1830
|
+
if (backend === "systemd") {
|
|
1831
|
+
await runSystemctlUser(["stop", SERVICE_NAME]);
|
|
1832
|
+
console.log("[rAgent] Stopped service via systemd.");
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
await stopPidfileService();
|
|
1836
|
+
}
|
|
1837
|
+
async function restartService() {
|
|
1838
|
+
const backend = getConfiguredServiceBackend();
|
|
1839
|
+
if (backend === "systemd") {
|
|
1840
|
+
await runSystemctlUser(["restart", SERVICE_NAME]);
|
|
1841
|
+
console.log("[rAgent] Restarted service via systemd.");
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
await stopPidfileService();
|
|
1845
|
+
await startPidfileService();
|
|
1846
|
+
}
|
|
1847
|
+
async function printServiceStatus() {
|
|
1848
|
+
const backend = getConfiguredServiceBackend();
|
|
1849
|
+
if (backend === "systemd") {
|
|
1850
|
+
const status = await execAsync(
|
|
1851
|
+
`systemctl --user status ${shellQuote(SERVICE_NAME)} --no-pager --lines=20`,
|
|
1852
|
+
{ timeout: 1e4 }
|
|
1853
|
+
).catch((error) => {
|
|
1854
|
+
console.log(error.message);
|
|
1855
|
+
return "";
|
|
1856
|
+
});
|
|
1857
|
+
if (status) {
|
|
1858
|
+
process.stdout.write(status);
|
|
1859
|
+
}
|
|
1860
|
+
return;
|
|
1861
|
+
}
|
|
1862
|
+
const pid = readFallbackPid();
|
|
1863
|
+
if (pid && isProcessRunning(pid)) {
|
|
1864
|
+
console.log(`[rAgent] fallback service running (pid ${pid})`);
|
|
1865
|
+
console.log(`[rAgent] logs: ${FALLBACK_LOG_FILE}`);
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1868
|
+
console.log("[rAgent] service is not running.");
|
|
1869
|
+
}
|
|
1870
|
+
async function printServiceLogs(opts) {
|
|
1871
|
+
const lines = Number.parseInt(String(opts.lines || 100), 10) || 100;
|
|
1872
|
+
const follow = Boolean(opts.follow);
|
|
1873
|
+
const backend = getConfiguredServiceBackend();
|
|
1874
|
+
if (backend === "systemd") {
|
|
1875
|
+
if (follow) {
|
|
1876
|
+
await new Promise((resolve) => {
|
|
1877
|
+
const child = (0, import_child_process2.spawn)(
|
|
1878
|
+
"journalctl",
|
|
1879
|
+
["--user", "-u", SERVICE_NAME, "-f", "-n", String(lines)],
|
|
1880
|
+
{ stdio: "inherit" }
|
|
1881
|
+
);
|
|
1882
|
+
child.on("exit", () => resolve());
|
|
1883
|
+
});
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
const output = await execAsync(
|
|
1887
|
+
`journalctl --user -u ${shellQuote(SERVICE_NAME)} -n ${lines} --no-pager`,
|
|
1888
|
+
{ timeout: 12e3 }
|
|
1889
|
+
);
|
|
1890
|
+
process.stdout.write(output);
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
if (!fs2.existsSync(FALLBACK_LOG_FILE)) {
|
|
1894
|
+
console.log(`[rAgent] No log file found at ${FALLBACK_LOG_FILE}`);
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
if (follow) {
|
|
1898
|
+
await new Promise((resolve) => {
|
|
1899
|
+
const child = (0, import_child_process2.spawn)("tail", ["-n", String(lines), "-f", FALLBACK_LOG_FILE], {
|
|
1900
|
+
stdio: "inherit"
|
|
1901
|
+
});
|
|
1902
|
+
child.on("exit", () => resolve());
|
|
1903
|
+
});
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
const content = fs2.readFileSync(FALLBACK_LOG_FILE, "utf8");
|
|
1907
|
+
const tail = content.split("\n").slice(-lines).join("\n");
|
|
1908
|
+
process.stdout.write(`${tail}${tail.endsWith("\n") ? "" : "\n"}`);
|
|
1909
|
+
}
|
|
1910
|
+
async function uninstallService() {
|
|
1911
|
+
const backend = getConfiguredServiceBackend();
|
|
1912
|
+
if (backend === "systemd") {
|
|
1913
|
+
await runSystemctlUser(["stop", SERVICE_NAME]).catch(() => void 0);
|
|
1914
|
+
await runSystemctlUser(["disable", SERVICE_NAME]).catch(() => void 0);
|
|
1915
|
+
try {
|
|
1916
|
+
fs2.unlinkSync(SERVICE_FILE);
|
|
1917
|
+
} catch {
|
|
1918
|
+
}
|
|
1919
|
+
await runSystemctlUser(["daemon-reload"]).catch(() => void 0);
|
|
1920
|
+
} else {
|
|
1921
|
+
await stopPidfileService();
|
|
1922
|
+
}
|
|
1923
|
+
const config = loadConfig();
|
|
1924
|
+
delete config.serviceBackend;
|
|
1925
|
+
saveConfig(config);
|
|
1926
|
+
console.log("[rAgent] Service uninstalled.");
|
|
1927
|
+
}
|
|
1928
|
+
function requestStopSelfService() {
|
|
1929
|
+
const backend = getConfiguredServiceBackend();
|
|
1930
|
+
if (backend === "systemd") {
|
|
1931
|
+
const child = (0, import_child_process2.spawn)("systemctl", ["--user", "stop", SERVICE_NAME], {
|
|
1932
|
+
detached: true,
|
|
1933
|
+
stdio: "ignore"
|
|
1934
|
+
});
|
|
1935
|
+
child.unref();
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
if (backend === "pidfile") {
|
|
1939
|
+
try {
|
|
1940
|
+
fs2.unlinkSync(FALLBACK_PID_FILE);
|
|
1941
|
+
} catch {
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1510
1945
|
|
|
1511
1946
|
// src/provisioner.ts
|
|
1512
|
-
var
|
|
1947
|
+
var import_child_process3 = require("child_process");
|
|
1513
1948
|
var DANGEROUS_PATTERN = /rm\s+-r[fe]*\s+\/|mkfs|dd\s+if=|:()\s*\{|>\s*\/dev\/sd/i;
|
|
1514
1949
|
function shellQuote2(s) {
|
|
1515
1950
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
@@ -1517,7 +1952,7 @@ function shellQuote2(s) {
|
|
|
1517
1952
|
var SESSION_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
1518
1953
|
var MAX_SESSION_NAME = 128;
|
|
1519
1954
|
function runCommand(cmd, timeout = 12e4) {
|
|
1520
|
-
return (0,
|
|
1955
|
+
return (0, import_child_process3.execSync)(cmd, {
|
|
1521
1956
|
encoding: "utf8",
|
|
1522
1957
|
timeout,
|
|
1523
1958
|
maxBuffer: 10 * 1024 * 1024,
|
|
@@ -1528,7 +1963,7 @@ function runCommand(cmd, timeout = 12e4) {
|
|
|
1528
1963
|
function commandExists2(cmd) {
|
|
1529
1964
|
if (!/^[a-zA-Z0-9._+-]+$/.test(cmd)) return false;
|
|
1530
1965
|
try {
|
|
1531
|
-
(0,
|
|
1966
|
+
(0, import_child_process3.execFileSync)("sh", ["-c", `command -v -- ${cmd} >/dev/null 2>&1`], { stdio: "ignore" });
|
|
1532
1967
|
return true;
|
|
1533
1968
|
} catch {
|
|
1534
1969
|
return false;
|
|
@@ -1678,7 +2113,7 @@ function startAgent(request, onProgress) {
|
|
|
1678
2113
|
}
|
|
1679
2114
|
tmuxArgs.push(fullCmd);
|
|
1680
2115
|
try {
|
|
1681
|
-
(0,
|
|
2116
|
+
(0, import_child_process3.execFileSync)("tmux", tmuxArgs, { stdio: "ignore" });
|
|
1682
2117
|
onProgress({
|
|
1683
2118
|
type: "provision-progress",
|
|
1684
2119
|
provisionId: request.provisionId,
|
|
@@ -1713,6 +2148,250 @@ async function executeProvision(request, onProgress) {
|
|
|
1713
2148
|
return startAgent(request, emit);
|
|
1714
2149
|
}
|
|
1715
2150
|
|
|
2151
|
+
// src/control-dispatcher.ts
|
|
2152
|
+
var ControlDispatcher = class {
|
|
2153
|
+
shell;
|
|
2154
|
+
streamer;
|
|
2155
|
+
inventory;
|
|
2156
|
+
connection;
|
|
2157
|
+
options;
|
|
2158
|
+
/** Set to true when a reconnect was requested (restart-agent, disconnect). */
|
|
2159
|
+
reconnectRequested = false;
|
|
2160
|
+
/** Set to false to stop the agent. */
|
|
2161
|
+
shouldRun = true;
|
|
2162
|
+
constructor(shell, streamer, inventory, connection, options) {
|
|
2163
|
+
this.shell = shell;
|
|
2164
|
+
this.streamer = streamer;
|
|
2165
|
+
this.inventory = inventory;
|
|
2166
|
+
this.connection = connection;
|
|
2167
|
+
this.options = options;
|
|
2168
|
+
}
|
|
2169
|
+
/** Update options (e.g., after token refresh). */
|
|
2170
|
+
updateOptions(options) {
|
|
2171
|
+
this.options = options;
|
|
2172
|
+
}
|
|
2173
|
+
/**
|
|
2174
|
+
* Verify HMAC-SHA256 signature on a control message.
|
|
2175
|
+
* Returns true if valid or no session key is available (backward compat).
|
|
2176
|
+
*/
|
|
2177
|
+
verifyMessageHmac(payload) {
|
|
2178
|
+
const sessionKey = this.connection.sessionKey;
|
|
2179
|
+
if (!sessionKey) return true;
|
|
2180
|
+
const receivedHmac = payload.hmac;
|
|
2181
|
+
if (typeof receivedHmac !== "string") {
|
|
2182
|
+
console.warn("[rAgent] Control message missing HMAC signature.");
|
|
2183
|
+
return false;
|
|
2184
|
+
}
|
|
2185
|
+
const { hmac: _, ...payloadWithoutHmac } = payload;
|
|
2186
|
+
const canonical = JSON.stringify(payloadWithoutHmac, Object.keys(payloadWithoutHmac).sort());
|
|
2187
|
+
const expected = crypto2.createHmac("sha256", sessionKey).update(canonical).digest("hex");
|
|
2188
|
+
return crypto2.timingSafeEqual(Buffer.from(receivedHmac, "hex"), Buffer.from(expected, "hex"));
|
|
2189
|
+
}
|
|
2190
|
+
/** Dispatch a control action message. */
|
|
2191
|
+
async handleControlAction(payload) {
|
|
2192
|
+
const action = typeof payload?.action === "string" ? payload.action : "";
|
|
2193
|
+
const sessionId = typeof payload?.sessionId === "string" && payload.sessionId.trim().length > 0 ? payload.sessionId.trim() : null;
|
|
2194
|
+
const dangerousActions = /* @__PURE__ */ new Set([
|
|
2195
|
+
"stop-agent",
|
|
2196
|
+
"restart-agent",
|
|
2197
|
+
"restart-shell",
|
|
2198
|
+
"stop-session",
|
|
2199
|
+
"stop-detached",
|
|
2200
|
+
"disconnect"
|
|
2201
|
+
]);
|
|
2202
|
+
if (dangerousActions.has(action) && this.connection.sessionKey) {
|
|
2203
|
+
if (!this.verifyMessageHmac(payload)) {
|
|
2204
|
+
console.warn(`[rAgent] Rejecting control action "${action}" \u2014 HMAC verification failed.`);
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
switch (action) {
|
|
2209
|
+
case "restart-shell":
|
|
2210
|
+
this.shell.restart();
|
|
2211
|
+
await this.syncInventory();
|
|
2212
|
+
return;
|
|
2213
|
+
case "restart-agent":
|
|
2214
|
+
case "disconnect":
|
|
2215
|
+
this.reconnectRequested = true;
|
|
2216
|
+
this.streamer.stopAll();
|
|
2217
|
+
if (this.connection.activeSocket?.readyState === import_ws4.default.OPEN) {
|
|
2218
|
+
this.connection.activeSocket.close();
|
|
2219
|
+
}
|
|
2220
|
+
return;
|
|
2221
|
+
case "stop-agent":
|
|
2222
|
+
this.shouldRun = false;
|
|
2223
|
+
this.streamer.stopAll();
|
|
2224
|
+
requestStopSelfService();
|
|
2225
|
+
if (this.connection.activeSocket?.readyState === import_ws4.default.OPEN) {
|
|
2226
|
+
this.connection.activeSocket.close();
|
|
2227
|
+
}
|
|
2228
|
+
return;
|
|
2229
|
+
case "stop-session":
|
|
2230
|
+
if (!sessionId) return;
|
|
2231
|
+
if (sessionId.startsWith("pty:")) {
|
|
2232
|
+
this.shell.restart();
|
|
2233
|
+
await this.syncInventory();
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
if (sessionId.startsWith("tmux:")) {
|
|
2237
|
+
try {
|
|
2238
|
+
await stopTmuxPaneBySessionId(sessionId);
|
|
2239
|
+
console.log(`[rAgent] Closed remote session ${sessionId}.`);
|
|
2240
|
+
} catch (error) {
|
|
2241
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2242
|
+
console.warn(`[rAgent] Failed to close ${sessionId}: ${message}`);
|
|
2243
|
+
}
|
|
2244
|
+
await this.syncInventory();
|
|
2245
|
+
}
|
|
2246
|
+
return;
|
|
2247
|
+
case "stop-detached": {
|
|
2248
|
+
const killed = await stopAllDetachedTmuxSessions();
|
|
2249
|
+
console.log(`[rAgent] Killed ${killed} detached tmux session(s).`);
|
|
2250
|
+
await this.syncInventory();
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
case "start-agent":
|
|
2254
|
+
await this.handleStartAgent(payload);
|
|
2255
|
+
return;
|
|
2256
|
+
case "stream-session":
|
|
2257
|
+
this.handleStreamSession(sessionId);
|
|
2258
|
+
return;
|
|
2259
|
+
case "stop-stream":
|
|
2260
|
+
if (sessionId) this.streamer.stopStream(sessionId);
|
|
2261
|
+
return;
|
|
2262
|
+
default:
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
/** Handle input routing to the correct target. */
|
|
2266
|
+
handleInput(data, sessionId) {
|
|
2267
|
+
if (!sessionId || sessionId.startsWith("pty:")) {
|
|
2268
|
+
this.shell.write(data);
|
|
2269
|
+
} else if (sessionId.startsWith("tmux:")) {
|
|
2270
|
+
sendInputToTmux(sessionId, data).catch(() => {
|
|
2271
|
+
});
|
|
2272
|
+
} else if (sessionId.startsWith("screen:") || sessionId.startsWith("zellij:")) {
|
|
2273
|
+
this.streamer.writeInput(sessionId, data);
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
/** Handle resize routing. */
|
|
2277
|
+
handleResize(cols, rows, sessionId) {
|
|
2278
|
+
if (!sessionId || sessionId.startsWith("pty:")) {
|
|
2279
|
+
this.shell.resize(cols, rows);
|
|
2280
|
+
} else if (sessionId.startsWith("screen:") || sessionId.startsWith("zellij:")) {
|
|
2281
|
+
this.streamer.resize(sessionId, cols, rows);
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
/** Handle provision request from dashboard. */
|
|
2285
|
+
async handleProvision(payload) {
|
|
2286
|
+
const provReq = payload;
|
|
2287
|
+
if (!provReq.provisionId || !provReq.manifest) return;
|
|
2288
|
+
console.log(`[rAgent] Provision request: ${provReq.manifest.name} (${provReq.provisionId})`);
|
|
2289
|
+
const sendProgress = (progress) => {
|
|
2290
|
+
const ws = this.connection.activeSocket;
|
|
2291
|
+
if (ws && ws.readyState === import_ws4.default.OPEN && this.connection.activeGroups.registryGroup) {
|
|
2292
|
+
sendToGroup(ws, this.connection.activeGroups.registryGroup, {
|
|
2293
|
+
...progress,
|
|
2294
|
+
hostId: this.options.hostId
|
|
2295
|
+
});
|
|
2296
|
+
}
|
|
2297
|
+
};
|
|
2298
|
+
try {
|
|
2299
|
+
await executeProvision(provReq, sendProgress);
|
|
2300
|
+
await this.syncInventory(true);
|
|
2301
|
+
} catch (error) {
|
|
2302
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
2303
|
+
sendProgress({
|
|
2304
|
+
type: "provision-progress",
|
|
2305
|
+
provisionId: provReq.provisionId,
|
|
2306
|
+
step: "error",
|
|
2307
|
+
message: `Provision failed: ${errMsg}`
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
async syncInventory(force = false) {
|
|
2312
|
+
await this.inventory.syncInventory(
|
|
2313
|
+
this.connection.activeSocket,
|
|
2314
|
+
this.connection.activeGroups,
|
|
2315
|
+
force
|
|
2316
|
+
);
|
|
2317
|
+
}
|
|
2318
|
+
async handleStartAgent(payload) {
|
|
2319
|
+
const sessionName = typeof payload?.sessionName === "string" && payload.sessionName.trim().length > 0 ? payload.sessionName.trim() : `agent-${Date.now().toString(36)}`;
|
|
2320
|
+
const cmd = typeof payload?.command === "string" && payload.command.trim().length > 0 ? payload.command.trim() : null;
|
|
2321
|
+
if (!cmd) {
|
|
2322
|
+
console.warn("[rAgent] start-agent: no command provided, ignoring.");
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
if (sessionName.length > 128 || !/^[a-zA-Z0-9_-]+$/.test(sessionName)) {
|
|
2326
|
+
console.warn("[rAgent] start-agent: invalid session name, ignoring.");
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
const dangerous = /rm\s+-r[fe]*\s+\/|mkfs|dd\s+if=|:()\s*\{|>\s*\/dev\/sd/i;
|
|
2330
|
+
if (dangerous.test(cmd)) {
|
|
2331
|
+
console.warn(`[rAgent] start-agent: rejected dangerous command: ${cmd}`);
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2334
|
+
const workingDir = typeof payload?.workingDir === "string" && payload.workingDir.trim().length > 0 ? payload.workingDir.trim() : void 0;
|
|
2335
|
+
const envVars = payload?.envVars && typeof payload.envVars === "object" ? payload.envVars : void 0;
|
|
2336
|
+
const tmuxArgs = ["new-session", "-d", "-s", sessionName];
|
|
2337
|
+
if (workingDir) {
|
|
2338
|
+
tmuxArgs.push("-c", workingDir);
|
|
2339
|
+
}
|
|
2340
|
+
let fullCmd = cmd;
|
|
2341
|
+
if (envVars) {
|
|
2342
|
+
const entries = Object.entries(envVars).filter(([k, v]) => /^[A-Z_][A-Z0-9_]*$/i.test(k) && typeof v === "string").map(([k, v]) => `${k}='${v.replace(/'/g, "'\\''")}'`).join(" ");
|
|
2343
|
+
if (entries) fullCmd = `${entries} ${cmd}`;
|
|
2344
|
+
}
|
|
2345
|
+
tmuxArgs.push(fullCmd);
|
|
2346
|
+
try {
|
|
2347
|
+
const { execFileSync: execFileSync3 } = await import("child_process");
|
|
2348
|
+
execFileSync3("tmux", tmuxArgs, { stdio: "ignore" });
|
|
2349
|
+
console.log(`[rAgent] Started agent session "${sessionName}": ${cmd}`);
|
|
2350
|
+
} catch (error) {
|
|
2351
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2352
|
+
console.error(`[rAgent] Failed to start agent session "${sessionName}": ${message}`);
|
|
2353
|
+
}
|
|
2354
|
+
await this.syncInventory(true);
|
|
2355
|
+
}
|
|
2356
|
+
handleStreamSession(sessionId) {
|
|
2357
|
+
if (!sessionId) return;
|
|
2358
|
+
const ws = this.connection.activeSocket;
|
|
2359
|
+
const group = this.connection.activeGroups.privateGroup;
|
|
2360
|
+
if (sessionId.startsWith("process:")) {
|
|
2361
|
+
if (ws && ws.readyState === import_ws4.default.OPEN && group) {
|
|
2362
|
+
sendToGroup(ws, group, {
|
|
2363
|
+
type: "stream-error",
|
|
2364
|
+
sessionId,
|
|
2365
|
+
error: "This agent is running outside a terminal multiplexer. Stop and relaunch via Start Agent to enable live streaming."
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2368
|
+
return;
|
|
2369
|
+
}
|
|
2370
|
+
if (sessionId.startsWith("tmux:") || sessionId.startsWith("screen:") || sessionId.startsWith("zellij:")) {
|
|
2371
|
+
const started = this.streamer.startStream(sessionId);
|
|
2372
|
+
if (ws && ws.readyState === import_ws4.default.OPEN && group) {
|
|
2373
|
+
if (started) {
|
|
2374
|
+
sendToGroup(ws, group, { type: "stream-started", sessionId });
|
|
2375
|
+
} else {
|
|
2376
|
+
sendToGroup(ws, group, {
|
|
2377
|
+
type: "stream-error",
|
|
2378
|
+
sessionId,
|
|
2379
|
+
error: "Failed to attach to session. It may no longer exist."
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
if (ws && ws.readyState === import_ws4.default.OPEN && group) {
|
|
2386
|
+
sendToGroup(ws, group, {
|
|
2387
|
+
type: "stream-error",
|
|
2388
|
+
sessionId,
|
|
2389
|
+
error: "Live streaming is not yet supported for this session type."
|
|
2390
|
+
});
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
};
|
|
2394
|
+
|
|
1716
2395
|
// src/agent.ts
|
|
1717
2396
|
function pidFilePath(hostId) {
|
|
1718
2397
|
return path2.join(CONFIG_DIR, `agent-${hostId}.pid`);
|
|
@@ -1761,7 +2440,7 @@ function resolveRunOptions(commandOptions) {
|
|
|
1761
2440
|
const config = loadConfig();
|
|
1762
2441
|
const portal = commandOptions.portal || config.portal || DEFAULT_PORTAL;
|
|
1763
2442
|
const hostId = sanitizeHostId(commandOptions.id || config.hostId || inferHostId());
|
|
1764
|
-
const hostName = commandOptions.name || config.hostName ||
|
|
2443
|
+
const hostName = commandOptions.name || config.hostName || os6.hostname();
|
|
1765
2444
|
const command = commandOptions.command || config.command || "bash";
|
|
1766
2445
|
const agentToken = commandOptions.agentToken || config.agentToken || "";
|
|
1767
2446
|
return { portal, hostId, hostName, command, agentToken };
|
|
@@ -1782,400 +2461,93 @@ async function runAgent(rawOptions) {
|
|
|
1782
2461
|
}
|
|
1783
2462
|
} catch {
|
|
1784
2463
|
}
|
|
1785
|
-
let shouldRun = true;
|
|
1786
|
-
let reconnectRequested = false;
|
|
1787
|
-
let reconnectDelay = DEFAULT_RECONNECT_DELAY_MS;
|
|
1788
|
-
let activeSocket = null;
|
|
1789
|
-
let activeGroups = { privateGroup: "", registryGroup: "" };
|
|
1790
|
-
let wsHeartbeatTimer = null;
|
|
1791
|
-
let httpHeartbeatTimer = null;
|
|
1792
|
-
let wsPingTimer = null;
|
|
1793
|
-
let wsPongTimeout = null;
|
|
1794
|
-
let suppressNextShellRespawn = false;
|
|
1795
|
-
let lastSentFingerprint = "";
|
|
1796
|
-
let lastHttpHeartbeatAt = 0;
|
|
1797
2464
|
const outputBuffer = new OutputBuffer();
|
|
1798
|
-
let ptyProcess = null;
|
|
1799
2465
|
const ptySessionId = `pty:${options.hostId}`;
|
|
1800
|
-
const sendOutput = (chunk) => {
|
|
1801
|
-
const ws = activeSocket;
|
|
1802
|
-
if (!ws || ws.readyState !== import_ws2.default.OPEN || !activeGroups.privateGroup) {
|
|
1803
|
-
outputBuffer.push(chunk);
|
|
1804
|
-
return;
|
|
1805
|
-
}
|
|
1806
|
-
sendToGroup(ws, activeGroups.privateGroup, { type: "output", data: chunk, sessionId: ptySessionId });
|
|
1807
|
-
};
|
|
1808
2466
|
const sessionStreamer = new SessionStreamer(
|
|
1809
2467
|
(sessionId, data) => {
|
|
1810
|
-
|
|
1811
|
-
if (
|
|
1812
|
-
|
|
2468
|
+
if (!conn.isReady()) return;
|
|
2469
|
+
if (conn.sessionKey) {
|
|
2470
|
+
const { enc, iv } = encryptPayload(data, conn.sessionKey);
|
|
2471
|
+
sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, { type: "output", enc, iv, sessionId });
|
|
2472
|
+
} else {
|
|
2473
|
+
sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, { type: "output", data, sessionId });
|
|
2474
|
+
}
|
|
1813
2475
|
},
|
|
1814
2476
|
(sessionId) => {
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
sendToGroup(ws, activeGroups.privateGroup, { type: "stream-stopped", sessionId });
|
|
2477
|
+
if (!conn.isReady()) return;
|
|
2478
|
+
sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, { type: "stream-stopped", sessionId });
|
|
1818
2479
|
}
|
|
1819
2480
|
);
|
|
1820
|
-
const
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
} catch {
|
|
1826
|
-
}
|
|
1827
|
-
ptyProcess = null;
|
|
1828
|
-
};
|
|
1829
|
-
const spawnOrRespawnShell = () => {
|
|
1830
|
-
ptyProcess = spawnConnectorShell(options.command, sendOutput, () => {
|
|
1831
|
-
if (suppressNextShellRespawn) {
|
|
1832
|
-
suppressNextShellRespawn = false;
|
|
1833
|
-
return;
|
|
1834
|
-
}
|
|
1835
|
-
if (!shouldRun) return;
|
|
1836
|
-
console.warn("[rAgent] Shell exited. Restarting shell process.");
|
|
1837
|
-
setTimeout(() => {
|
|
1838
|
-
if (shouldRun) spawnOrRespawnShell();
|
|
1839
|
-
}, 200);
|
|
1840
|
-
});
|
|
1841
|
-
};
|
|
1842
|
-
const restartLocalShell = () => {
|
|
1843
|
-
killCurrentShell();
|
|
1844
|
-
spawnOrRespawnShell();
|
|
1845
|
-
};
|
|
1846
|
-
spawnOrRespawnShell();
|
|
1847
|
-
const cleanupSocket = (opts = {}) => {
|
|
1848
|
-
if (opts.stopStreams) sessionStreamer.stopAll();
|
|
1849
|
-
if (wsPingTimer) {
|
|
1850
|
-
clearInterval(wsPingTimer);
|
|
1851
|
-
wsPingTimer = null;
|
|
1852
|
-
}
|
|
1853
|
-
if (wsPongTimeout) {
|
|
1854
|
-
clearTimeout(wsPongTimeout);
|
|
1855
|
-
wsPongTimeout = null;
|
|
1856
|
-
}
|
|
1857
|
-
if (wsHeartbeatTimer) {
|
|
1858
|
-
clearInterval(wsHeartbeatTimer);
|
|
1859
|
-
wsHeartbeatTimer = null;
|
|
1860
|
-
}
|
|
1861
|
-
if (httpHeartbeatTimer) {
|
|
1862
|
-
clearInterval(httpHeartbeatTimer);
|
|
1863
|
-
httpHeartbeatTimer = null;
|
|
1864
|
-
}
|
|
1865
|
-
if (activeSocket) {
|
|
1866
|
-
activeSocket.removeAllListeners();
|
|
1867
|
-
try {
|
|
1868
|
-
activeSocket.close();
|
|
1869
|
-
} catch {
|
|
1870
|
-
}
|
|
1871
|
-
activeSocket = null;
|
|
1872
|
-
}
|
|
1873
|
-
activeGroups = { privateGroup: "", registryGroup: "" };
|
|
1874
|
-
};
|
|
1875
|
-
let prevCpuSnapshot = null;
|
|
1876
|
-
function takeCpuSnapshot() {
|
|
1877
|
-
const cpus2 = os5.cpus();
|
|
1878
|
-
let idle = 0;
|
|
1879
|
-
let total = 0;
|
|
1880
|
-
for (const cpu of cpus2) {
|
|
1881
|
-
for (const type of Object.keys(cpu.times)) {
|
|
1882
|
-
total += cpu.times[type];
|
|
1883
|
-
}
|
|
1884
|
-
idle += cpu.times.idle;
|
|
1885
|
-
}
|
|
1886
|
-
return { idle, total };
|
|
1887
|
-
}
|
|
1888
|
-
const collectVitals = () => {
|
|
1889
|
-
const currentSnapshot = takeCpuSnapshot();
|
|
1890
|
-
let cpuUsage = 0;
|
|
1891
|
-
if (prevCpuSnapshot) {
|
|
1892
|
-
const idleDelta = currentSnapshot.idle - prevCpuSnapshot.idle;
|
|
1893
|
-
const totalDelta = currentSnapshot.total - prevCpuSnapshot.total;
|
|
1894
|
-
cpuUsage = totalDelta > 0 ? Math.round((totalDelta - idleDelta) / totalDelta * 100) : 0;
|
|
1895
|
-
}
|
|
1896
|
-
prevCpuSnapshot = currentSnapshot;
|
|
1897
|
-
const totalMem = os5.totalmem();
|
|
1898
|
-
const freeMem = os5.freemem();
|
|
1899
|
-
const memUsedPct = totalMem > 0 ? Math.round((totalMem - freeMem) / totalMem * 100) : 0;
|
|
1900
|
-
let diskUsedPct = 0;
|
|
1901
|
-
try {
|
|
1902
|
-
const { execSync: execSync2 } = require("child_process");
|
|
1903
|
-
const dfOutput = execSync2("df -P / | tail -1", { encoding: "utf8", timeout: 5e3 });
|
|
1904
|
-
const parts = dfOutput.trim().split(/\s+/);
|
|
1905
|
-
if (parts.length >= 5) {
|
|
1906
|
-
diskUsedPct = parseInt(parts[4].replace("%", ""), 10) || 0;
|
|
1907
|
-
}
|
|
1908
|
-
} catch {
|
|
1909
|
-
}
|
|
1910
|
-
return { cpu: cpuUsage, memUsedPct, diskUsedPct };
|
|
1911
|
-
};
|
|
1912
|
-
const announceToRegistry = async (type = "heartbeat") => {
|
|
1913
|
-
const ws = activeSocket;
|
|
1914
|
-
if (!ws || ws.readyState !== import_ws2.default.OPEN || !activeGroups.registryGroup) return;
|
|
1915
|
-
const sessions = await collectSessionInventory(options.hostId, options.command);
|
|
1916
|
-
const vitals = collectVitals();
|
|
1917
|
-
sendToGroup(ws, activeGroups.registryGroup, {
|
|
1918
|
-
type,
|
|
1919
|
-
hostId: options.hostId,
|
|
1920
|
-
hostName: options.hostName,
|
|
1921
|
-
environment: os5.hostname(),
|
|
1922
|
-
sessions,
|
|
1923
|
-
vitals,
|
|
1924
|
-
agentVersion: CURRENT_VERSION,
|
|
1925
|
-
lastSeenAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1926
|
-
});
|
|
1927
|
-
};
|
|
1928
|
-
const syncInventory = async (force = false) => {
|
|
1929
|
-
const sessions = await collectSessionInventory(options.hostId, options.command);
|
|
1930
|
-
const fingerprint = sessionInventoryFingerprint(sessions);
|
|
1931
|
-
const changed = fingerprint !== lastSentFingerprint;
|
|
1932
|
-
const checkpointDue = Date.now() - lastHttpHeartbeatAt > HTTP_HEARTBEAT_MS;
|
|
1933
|
-
if (changed || force) {
|
|
1934
|
-
await announceToRegistry("inventory");
|
|
1935
|
-
lastSentFingerprint = fingerprint;
|
|
1936
|
-
}
|
|
1937
|
-
if (changed || checkpointDue || force) {
|
|
1938
|
-
await postHeartbeat({
|
|
1939
|
-
portal: options.portal,
|
|
1940
|
-
agentToken: options.agentToken,
|
|
1941
|
-
hostId: options.hostId,
|
|
1942
|
-
hostName: options.hostName,
|
|
1943
|
-
command: options.command
|
|
1944
|
-
});
|
|
1945
|
-
lastHttpHeartbeatAt = Date.now();
|
|
2481
|
+
const conn = new ConnectionManager(sessionStreamer, outputBuffer);
|
|
2482
|
+
const sendOutput = (chunk) => {
|
|
2483
|
+
if (!conn.isReady()) {
|
|
2484
|
+
outputBuffer.push(chunk);
|
|
2485
|
+
return;
|
|
1946
2486
|
}
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
case "restart-shell":
|
|
1953
|
-
restartLocalShell();
|
|
1954
|
-
await syncInventory();
|
|
1955
|
-
return;
|
|
1956
|
-
case "restart-agent":
|
|
1957
|
-
case "disconnect":
|
|
1958
|
-
reconnectRequested = true;
|
|
1959
|
-
sessionStreamer.stopAll();
|
|
1960
|
-
if (activeSocket && activeSocket.readyState === import_ws2.default.OPEN) {
|
|
1961
|
-
activeSocket.close();
|
|
1962
|
-
}
|
|
1963
|
-
return;
|
|
1964
|
-
case "stop-agent":
|
|
1965
|
-
shouldRun = false;
|
|
1966
|
-
sessionStreamer.stopAll();
|
|
1967
|
-
requestStopSelfService();
|
|
1968
|
-
if (activeSocket && activeSocket.readyState === import_ws2.default.OPEN) {
|
|
1969
|
-
activeSocket.close();
|
|
1970
|
-
}
|
|
1971
|
-
return;
|
|
1972
|
-
case "stop-session":
|
|
1973
|
-
if (!sessionId) return;
|
|
1974
|
-
if (sessionId.startsWith("pty:")) {
|
|
1975
|
-
restartLocalShell();
|
|
1976
|
-
await syncInventory();
|
|
1977
|
-
return;
|
|
1978
|
-
}
|
|
1979
|
-
if (sessionId.startsWith("tmux:")) {
|
|
1980
|
-
try {
|
|
1981
|
-
await stopTmuxPaneBySessionId(sessionId);
|
|
1982
|
-
console.log(`[rAgent] Closed remote session ${sessionId}.`);
|
|
1983
|
-
} catch (error) {
|
|
1984
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1985
|
-
console.warn(
|
|
1986
|
-
`[rAgent] Failed to close ${sessionId}: ${message}`
|
|
1987
|
-
);
|
|
1988
|
-
}
|
|
1989
|
-
await syncInventory();
|
|
1990
|
-
}
|
|
1991
|
-
return;
|
|
1992
|
-
case "stop-detached": {
|
|
1993
|
-
const killed = await stopAllDetachedTmuxSessions();
|
|
1994
|
-
console.log(`[rAgent] Killed ${killed} detached tmux session(s).`);
|
|
1995
|
-
await syncInventory();
|
|
1996
|
-
return;
|
|
1997
|
-
}
|
|
1998
|
-
case "start-agent": {
|
|
1999
|
-
const sessionName = typeof payload?.sessionName === "string" && payload.sessionName.trim().length > 0 ? payload.sessionName.trim() : `agent-${Date.now().toString(36)}`;
|
|
2000
|
-
const cmd = typeof payload?.command === "string" && payload.command.trim().length > 0 ? payload.command.trim() : null;
|
|
2001
|
-
if (!cmd) {
|
|
2002
|
-
console.warn("[rAgent] start-agent: no command provided, ignoring.");
|
|
2003
|
-
return;
|
|
2004
|
-
}
|
|
2005
|
-
if (sessionName.length > 128 || !/^[a-zA-Z0-9_-]+$/.test(sessionName)) {
|
|
2006
|
-
console.warn("[rAgent] start-agent: invalid session name, ignoring.");
|
|
2007
|
-
return;
|
|
2008
|
-
}
|
|
2009
|
-
const dangerous = /rm\s+-r[fe]*\s+\/|mkfs|dd\s+if=|:()\s*\{|>\s*\/dev\/sd/i;
|
|
2010
|
-
if (dangerous.test(cmd)) {
|
|
2011
|
-
console.warn(`[rAgent] start-agent: rejected dangerous command: ${cmd}`);
|
|
2012
|
-
return;
|
|
2013
|
-
}
|
|
2014
|
-
const workingDir = typeof payload?.workingDir === "string" && payload.workingDir.trim().length > 0 ? payload.workingDir.trim() : void 0;
|
|
2015
|
-
const envVars = payload?.envVars && typeof payload.envVars === "object" ? payload.envVars : void 0;
|
|
2016
|
-
const tmuxArgs = ["new-session", "-d", "-s", sessionName];
|
|
2017
|
-
if (workingDir) {
|
|
2018
|
-
tmuxArgs.push("-c", workingDir);
|
|
2019
|
-
}
|
|
2020
|
-
let fullCmd = cmd;
|
|
2021
|
-
if (envVars) {
|
|
2022
|
-
const entries = Object.entries(envVars).filter(([k, v]) => /^[A-Z_][A-Z0-9_]*$/i.test(k) && typeof v === "string").map(([k, v]) => `${k}='${v.replace(/'/g, "'\\''")}'`).join(" ");
|
|
2023
|
-
if (entries) fullCmd = `${entries} ${cmd}`;
|
|
2024
|
-
}
|
|
2025
|
-
tmuxArgs.push(fullCmd);
|
|
2026
|
-
try {
|
|
2027
|
-
const { execFileSync: execFileSync3 } = await import("child_process");
|
|
2028
|
-
execFileSync3("tmux", tmuxArgs, { stdio: "ignore" });
|
|
2029
|
-
console.log(`[rAgent] Started agent session "${sessionName}": ${cmd}`);
|
|
2030
|
-
} catch (error) {
|
|
2031
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
2032
|
-
console.error(`[rAgent] Failed to start agent session "${sessionName}": ${message}`);
|
|
2033
|
-
}
|
|
2034
|
-
await syncInventory(true);
|
|
2035
|
-
return;
|
|
2036
|
-
}
|
|
2037
|
-
case "stream-session": {
|
|
2038
|
-
if (!sessionId) return;
|
|
2039
|
-
if (sessionId.startsWith("process:")) {
|
|
2040
|
-
const ws2 = activeSocket;
|
|
2041
|
-
if (ws2 && ws2.readyState === import_ws2.default.OPEN && activeGroups.privateGroup) {
|
|
2042
|
-
sendToGroup(ws2, activeGroups.privateGroup, {
|
|
2043
|
-
type: "stream-error",
|
|
2044
|
-
sessionId,
|
|
2045
|
-
error: "This agent is running outside a terminal multiplexer. Stop and relaunch via Start Agent to enable live streaming."
|
|
2046
|
-
});
|
|
2047
|
-
}
|
|
2048
|
-
return;
|
|
2049
|
-
}
|
|
2050
|
-
if (sessionId.startsWith("tmux:") || sessionId.startsWith("screen:") || sessionId.startsWith("zellij:")) {
|
|
2051
|
-
const started = sessionStreamer.startStream(sessionId);
|
|
2052
|
-
const ws2 = activeSocket;
|
|
2053
|
-
if (ws2 && ws2.readyState === import_ws2.default.OPEN && activeGroups.privateGroup) {
|
|
2054
|
-
if (started) {
|
|
2055
|
-
sendToGroup(ws2, activeGroups.privateGroup, {
|
|
2056
|
-
type: "stream-started",
|
|
2057
|
-
sessionId
|
|
2058
|
-
});
|
|
2059
|
-
} else {
|
|
2060
|
-
sendToGroup(ws2, activeGroups.privateGroup, {
|
|
2061
|
-
type: "stream-error",
|
|
2062
|
-
sessionId,
|
|
2063
|
-
error: "Failed to attach to session. It may no longer exist."
|
|
2064
|
-
});
|
|
2065
|
-
}
|
|
2066
|
-
}
|
|
2067
|
-
return;
|
|
2068
|
-
}
|
|
2069
|
-
const ws = activeSocket;
|
|
2070
|
-
if (ws && ws.readyState === import_ws2.default.OPEN && activeGroups.privateGroup) {
|
|
2071
|
-
sendToGroup(ws, activeGroups.privateGroup, {
|
|
2072
|
-
type: "stream-error",
|
|
2073
|
-
sessionId,
|
|
2074
|
-
error: "Live streaming is not yet supported for this session type."
|
|
2075
|
-
});
|
|
2076
|
-
}
|
|
2077
|
-
return;
|
|
2078
|
-
}
|
|
2079
|
-
case "stop-stream": {
|
|
2080
|
-
if (!sessionId) return;
|
|
2081
|
-
sessionStreamer.stopStream(sessionId);
|
|
2082
|
-
return;
|
|
2083
|
-
}
|
|
2084
|
-
default:
|
|
2487
|
+
if (conn.sessionKey) {
|
|
2488
|
+
const { enc, iv } = encryptPayload(chunk, conn.sessionKey);
|
|
2489
|
+
sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, { type: "output", enc, iv, sessionId: ptySessionId });
|
|
2490
|
+
} else {
|
|
2491
|
+
sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, { type: "output", data: chunk, sessionId: ptySessionId });
|
|
2085
2492
|
}
|
|
2086
2493
|
};
|
|
2494
|
+
const shell = new ShellManager(options.command, sendOutput);
|
|
2495
|
+
const inventory = new InventoryManager(options);
|
|
2496
|
+
const dispatcher = new ControlDispatcher(shell, sessionStreamer, inventory, conn, options);
|
|
2497
|
+
shell.spawn();
|
|
2087
2498
|
const onSignal = () => {
|
|
2088
|
-
shouldRun = false;
|
|
2089
|
-
|
|
2090
|
-
|
|
2499
|
+
dispatcher.shouldRun = false;
|
|
2500
|
+
conn.cleanup({ stopStreams: true });
|
|
2501
|
+
shell.stop();
|
|
2091
2502
|
releasePidLock(lockPath);
|
|
2092
2503
|
};
|
|
2093
2504
|
process.once("SIGTERM", onSignal);
|
|
2094
2505
|
process.once("SIGINT", onSignal);
|
|
2095
2506
|
try {
|
|
2096
|
-
while (shouldRun) {
|
|
2097
|
-
reconnectRequested = false;
|
|
2507
|
+
while (dispatcher.shouldRun) {
|
|
2508
|
+
dispatcher.reconnectRequested = false;
|
|
2098
2509
|
try {
|
|
2099
2510
|
options.agentToken = await refreshTokenIfNeeded({
|
|
2100
2511
|
portal: options.portal,
|
|
2101
2512
|
agentToken: options.agentToken
|
|
2102
2513
|
});
|
|
2514
|
+
inventory.updateOptions(options);
|
|
2515
|
+
dispatcher.updateOptions(options);
|
|
2103
2516
|
const negotiated = await negotiateAgent({
|
|
2104
2517
|
portal: options.portal,
|
|
2105
2518
|
agentToken: options.agentToken
|
|
2106
2519
|
});
|
|
2107
|
-
|
|
2520
|
+
const groups = {
|
|
2108
2521
|
privateGroup: negotiated.groups.privateGroup,
|
|
2109
2522
|
registryGroup: negotiated.groups.registryGroup
|
|
2110
2523
|
};
|
|
2111
2524
|
await new Promise((resolve) => {
|
|
2112
|
-
const ws = new
|
|
2113
|
-
|
|
2114
|
-
"json.webpubsub.azure.v1"
|
|
2115
|
-
);
|
|
2116
|
-
activeSocket = ws;
|
|
2525
|
+
const ws = new import_ws5.default(negotiated.url, "json.webpubsub.azure.v1");
|
|
2526
|
+
conn.setConnection(ws, groups, negotiated.sessionKey ?? null);
|
|
2117
2527
|
ws.on("open", async () => {
|
|
2118
2528
|
console.log("[rAgent] Connector connected to relay.");
|
|
2119
|
-
|
|
2120
|
-
ws.send(
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
group: activeGroups.privateGroup
|
|
2124
|
-
})
|
|
2125
|
-
);
|
|
2126
|
-
ws.send(
|
|
2127
|
-
JSON.stringify({
|
|
2128
|
-
type: "joinGroup",
|
|
2129
|
-
group: activeGroups.registryGroup
|
|
2130
|
-
})
|
|
2131
|
-
);
|
|
2132
|
-
sendToGroup(ws, activeGroups.privateGroup, {
|
|
2529
|
+
conn.resetReconnectDelay();
|
|
2530
|
+
ws.send(JSON.stringify({ type: "joinGroup", group: groups.privateGroup }));
|
|
2531
|
+
ws.send(JSON.stringify({ type: "joinGroup", group: groups.registryGroup }));
|
|
2532
|
+
sendToGroup(ws, groups.privateGroup, {
|
|
2133
2533
|
type: "register",
|
|
2134
2534
|
hostName: options.hostName,
|
|
2135
|
-
environment:
|
|
2535
|
+
environment: os6.platform()
|
|
2136
2536
|
});
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
}
|
|
2150
|
-
await syncInventory(true);
|
|
2151
|
-
wsHeartbeatTimer = setInterval(async () => {
|
|
2152
|
-
if (!activeSocket || activeSocket.readyState !== import_ws2.default.OPEN)
|
|
2153
|
-
return;
|
|
2154
|
-
await announceToRegistry("heartbeat");
|
|
2155
|
-
}, WS_HEARTBEAT_MS);
|
|
2156
|
-
httpHeartbeatTimer = setInterval(async () => {
|
|
2157
|
-
if (!activeSocket || activeSocket.readyState !== import_ws2.default.OPEN)
|
|
2158
|
-
return;
|
|
2159
|
-
await syncInventory();
|
|
2160
|
-
}, HTTP_HEARTBEAT_MS);
|
|
2161
|
-
wsPingTimer = setInterval(() => {
|
|
2162
|
-
if (!activeSocket || activeSocket.readyState !== import_ws2.default.OPEN) return;
|
|
2163
|
-
activeSocket.ping();
|
|
2164
|
-
wsPongTimeout = setTimeout(() => {
|
|
2165
|
-
console.warn("[rAgent] No pong received within 10s \u2014 closing stale connection.");
|
|
2166
|
-
try {
|
|
2167
|
-
activeSocket?.terminate();
|
|
2168
|
-
} catch {
|
|
2169
|
-
}
|
|
2170
|
-
}, 1e4);
|
|
2171
|
-
}, 2e4);
|
|
2172
|
-
});
|
|
2173
|
-
ws.on("pong", () => {
|
|
2174
|
-
if (wsPongTimeout) {
|
|
2175
|
-
clearTimeout(wsPongTimeout);
|
|
2176
|
-
wsPongTimeout = null;
|
|
2177
|
-
}
|
|
2537
|
+
conn.replayBufferedOutput((chunk) => {
|
|
2538
|
+
sendToGroup(ws, groups.privateGroup, {
|
|
2539
|
+
type: "output",
|
|
2540
|
+
data: chunk,
|
|
2541
|
+
sessionId: ptySessionId
|
|
2542
|
+
});
|
|
2543
|
+
});
|
|
2544
|
+
await inventory.syncInventory(ws, groups, true);
|
|
2545
|
+
conn.startTimers(
|
|
2546
|
+
() => inventory.announceToRegistry(ws, groups.registryGroup, "heartbeat"),
|
|
2547
|
+
() => inventory.syncInventory(ws, groups)
|
|
2548
|
+
);
|
|
2178
2549
|
});
|
|
2550
|
+
ws.on("pong", () => conn.onPong());
|
|
2179
2551
|
ws.on("message", async (data) => {
|
|
2180
2552
|
let msg;
|
|
2181
2553
|
try {
|
|
@@ -2183,69 +2555,37 @@ async function runAgent(rawOptions) {
|
|
|
2183
2555
|
} catch {
|
|
2184
2556
|
return;
|
|
2185
2557
|
}
|
|
2186
|
-
if (msg.type === "message" && msg.group ===
|
|
2558
|
+
if (msg.type === "message" && msg.group === groups.privateGroup) {
|
|
2187
2559
|
const payload = msg.data || {};
|
|
2188
|
-
if (payload.type === "input"
|
|
2189
|
-
|
|
2190
|
-
if (
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2560
|
+
if (payload.type === "input") {
|
|
2561
|
+
let inputData = null;
|
|
2562
|
+
if (typeof payload.enc === "string" && typeof payload.iv === "string" && conn.sessionKey) {
|
|
2563
|
+
inputData = decryptPayload(payload.enc, payload.iv, conn.sessionKey);
|
|
2564
|
+
if (inputData === null) {
|
|
2565
|
+
console.warn("[rAgent] Failed to decrypt input \u2014 ignoring.");
|
|
2566
|
+
}
|
|
2567
|
+
} else if (typeof payload.data === "string") {
|
|
2568
|
+
inputData = payload.data;
|
|
2569
|
+
}
|
|
2570
|
+
if (inputData !== null) {
|
|
2571
|
+
const sid = typeof payload.sessionId === "string" ? payload.sessionId.trim() : "";
|
|
2572
|
+
dispatcher.handleInput(inputData, sid);
|
|
2196
2573
|
}
|
|
2197
2574
|
} else if (payload.type === "resize" && Number.isInteger(payload.cols) && Number.isInteger(payload.rows)) {
|
|
2198
2575
|
const sid = typeof payload.sessionId === "string" ? payload.sessionId.trim() : "";
|
|
2199
|
-
|
|
2200
|
-
try {
|
|
2201
|
-
if (ptyProcess)
|
|
2202
|
-
ptyProcess.resize(
|
|
2203
|
-
payload.cols,
|
|
2204
|
-
payload.rows
|
|
2205
|
-
);
|
|
2206
|
-
} catch (error) {
|
|
2207
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
2208
|
-
if (!message.includes("EBADF")) {
|
|
2209
|
-
console.warn(`[rAgent] Resize failed: ${message}`);
|
|
2210
|
-
}
|
|
2211
|
-
}
|
|
2212
|
-
}
|
|
2576
|
+
dispatcher.handleResize(payload.cols, payload.rows, sid);
|
|
2213
2577
|
} else if (payload.type === "control" && typeof payload.action === "string") {
|
|
2214
|
-
await handleControlAction(payload);
|
|
2578
|
+
await dispatcher.handleControlAction(payload);
|
|
2215
2579
|
} else if (payload.type === "start-agent") {
|
|
2216
|
-
await handleControlAction({ ...payload, action: "start-agent" });
|
|
2580
|
+
await dispatcher.handleControlAction({ ...payload, action: "start-agent" });
|
|
2217
2581
|
} else if (payload.type === "provision") {
|
|
2218
|
-
|
|
2219
|
-
if (provReq.provisionId && provReq.manifest) {
|
|
2220
|
-
console.log(`[rAgent] Provision request: ${provReq.manifest.name} (${provReq.provisionId})`);
|
|
2221
|
-
const sendProgress = (progress) => {
|
|
2222
|
-
const currentWs = activeSocket;
|
|
2223
|
-
if (currentWs && currentWs.readyState === import_ws2.default.OPEN && activeGroups.registryGroup) {
|
|
2224
|
-
sendToGroup(currentWs, activeGroups.registryGroup, {
|
|
2225
|
-
...progress,
|
|
2226
|
-
hostId: options.hostId
|
|
2227
|
-
});
|
|
2228
|
-
}
|
|
2229
|
-
};
|
|
2230
|
-
try {
|
|
2231
|
-
await executeProvision(provReq, sendProgress);
|
|
2232
|
-
await syncInventory(true);
|
|
2233
|
-
} catch (error) {
|
|
2234
|
-
const errMsg = error instanceof Error ? error.message : String(error);
|
|
2235
|
-
sendProgress({
|
|
2236
|
-
type: "provision-progress",
|
|
2237
|
-
provisionId: provReq.provisionId,
|
|
2238
|
-
step: "error",
|
|
2239
|
-
message: `Provision failed: ${errMsg}`
|
|
2240
|
-
});
|
|
2241
|
-
}
|
|
2242
|
-
}
|
|
2582
|
+
await dispatcher.handleProvision(payload);
|
|
2243
2583
|
}
|
|
2244
2584
|
}
|
|
2245
|
-
if (msg.type === "message" && msg.group ===
|
|
2585
|
+
if (msg.type === "message" && msg.group === groups.registryGroup) {
|
|
2246
2586
|
const payload = msg.data || {};
|
|
2247
2587
|
if (payload.type === "ping") {
|
|
2248
|
-
await announceToRegistry("announce");
|
|
2588
|
+
await inventory.announceToRegistry(ws, groups.registryGroup, "announce");
|
|
2249
2589
|
}
|
|
2250
2590
|
}
|
|
2251
2591
|
});
|
|
@@ -2253,10 +2593,8 @@ async function runAgent(rawOptions) {
|
|
|
2253
2593
|
console.error("[rAgent] WebSocket error:", error.message);
|
|
2254
2594
|
});
|
|
2255
2595
|
ws.on("close", () => {
|
|
2256
|
-
console.log(
|
|
2257
|
-
|
|
2258
|
-
);
|
|
2259
|
-
cleanupSocket();
|
|
2596
|
+
console.log("[rAgent] Relay disconnected. Output will be buffered until reconnect.");
|
|
2597
|
+
conn.cleanup();
|
|
2260
2598
|
resolve();
|
|
2261
2599
|
});
|
|
2262
2600
|
});
|
|
@@ -2266,28 +2604,26 @@ async function runAgent(rawOptions) {
|
|
|
2266
2604
|
console.error(
|
|
2267
2605
|
"[rAgent] Connector token is invalid or revoked. Stopping. Re-connect with: ragent connect --token <token>"
|
|
2268
2606
|
);
|
|
2269
|
-
shouldRun = false;
|
|
2607
|
+
dispatcher.shouldRun = false;
|
|
2270
2608
|
break;
|
|
2271
2609
|
}
|
|
2272
2610
|
const message = error instanceof Error ? error.message : String(error);
|
|
2273
2611
|
console.error(`[rAgent] Relay connect failed: ${message}`);
|
|
2274
2612
|
}
|
|
2275
|
-
if (!shouldRun) break;
|
|
2276
|
-
if (reconnectRequested) {
|
|
2613
|
+
if (!dispatcher.shouldRun) break;
|
|
2614
|
+
if (dispatcher.reconnectRequested) {
|
|
2277
2615
|
console.log("[rAgent] Reconnecting to relay...");
|
|
2278
2616
|
await wait(300);
|
|
2279
2617
|
continue;
|
|
2280
2618
|
}
|
|
2281
|
-
const jitteredDelay = reconnectDelay * (0.5 + Math.random());
|
|
2282
|
-
console.log(
|
|
2283
|
-
`[rAgent] Disconnected. Reconnecting in ${Math.round(jitteredDelay / 1e3)}s...`
|
|
2284
|
-
);
|
|
2619
|
+
const jitteredDelay = conn.reconnectDelay * (0.5 + Math.random());
|
|
2620
|
+
console.log(`[rAgent] Disconnected. Reconnecting in ${Math.round(jitteredDelay / 1e3)}s...`);
|
|
2285
2621
|
await wait(jitteredDelay);
|
|
2286
|
-
reconnectDelay = Math.min(reconnectDelay * 1.5, MAX_RECONNECT_DELAY_MS);
|
|
2622
|
+
conn.reconnectDelay = Math.min(conn.reconnectDelay * 1.5, MAX_RECONNECT_DELAY_MS);
|
|
2287
2623
|
}
|
|
2288
2624
|
} finally {
|
|
2289
|
-
|
|
2290
|
-
|
|
2625
|
+
conn.cleanup({ stopStreams: true });
|
|
2626
|
+
shell.stop();
|
|
2291
2627
|
releasePidLock(lockPath);
|
|
2292
2628
|
process.removeListener("SIGTERM", onSignal);
|
|
2293
2629
|
process.removeListener("SIGINT", onSignal);
|
|
@@ -2382,7 +2718,7 @@ function printCommandArt(title, subtitle = "remote agent control") {
|
|
|
2382
2718
|
async function connectMachine(opts) {
|
|
2383
2719
|
const portal = opts.portal || DEFAULT_PORTAL;
|
|
2384
2720
|
const hostId = sanitizeHostId(opts.id || inferHostId());
|
|
2385
|
-
const hostName = opts.name ||
|
|
2721
|
+
const hostName = opts.name || os7.hostname();
|
|
2386
2722
|
const command = opts.command || "bash";
|
|
2387
2723
|
if (!opts.token) {
|
|
2388
2724
|
throw new Error("Connection token is required.");
|
|
@@ -2456,12 +2792,12 @@ function registerRunCommand(program2) {
|
|
|
2456
2792
|
}
|
|
2457
2793
|
|
|
2458
2794
|
// src/commands/doctor.ts
|
|
2459
|
-
var
|
|
2795
|
+
var os8 = __toESM(require("os"));
|
|
2460
2796
|
async function runDoctor(opts) {
|
|
2461
2797
|
const options = resolveRunOptions(opts);
|
|
2462
2798
|
const checks = [];
|
|
2463
|
-
const platformOk =
|
|
2464
|
-
checks.push({ name: "platform", ok: platformOk, detail:
|
|
2799
|
+
const platformOk = os8.platform() === "linux";
|
|
2800
|
+
checks.push({ name: "platform", ok: platformOk, detail: os8.platform() });
|
|
2465
2801
|
checks.push({
|
|
2466
2802
|
name: "node",
|
|
2467
2803
|
ok: Number(process.versions.node.split(".")[0]) >= 20,
|