ragent-cli 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1237 -891
- 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);
|
|
@@ -1275,9 +1030,19 @@ var SessionStreamer = class {
|
|
|
1275
1030
|
const cleanEnv = { ...process.env };
|
|
1276
1031
|
delete cleanEnv.TMUX;
|
|
1277
1032
|
delete cleanEnv.TMUX_PANE;
|
|
1278
|
-
const
|
|
1033
|
+
const streamsBase = (0, import_node_path.join)((0, import_node_os.homedir)(), ".config", "ragent", "streams");
|
|
1034
|
+
try {
|
|
1035
|
+
(0, import_node_fs.mkdirSync)(streamsBase, { recursive: true });
|
|
1036
|
+
} catch {
|
|
1037
|
+
}
|
|
1038
|
+
let tmpDir;
|
|
1039
|
+
try {
|
|
1040
|
+
tmpDir = (0, import_node_fs.mkdtempSync)((0, import_node_path.join)(streamsBase, "s-"));
|
|
1041
|
+
} catch {
|
|
1042
|
+
tmpDir = (0, import_node_fs.mkdtempSync)((0, import_node_path.join)((0, import_node_os.tmpdir)(), "ragent-stream-"));
|
|
1043
|
+
}
|
|
1279
1044
|
const fifoPath = (0, import_node_path.join)(tmpDir, "pane.fifo");
|
|
1280
|
-
(0,
|
|
1045
|
+
(0, import_node_child_process2.execFileSync)("mkfifo", ["-m", "600", fifoPath]);
|
|
1281
1046
|
const stream = {
|
|
1282
1047
|
sessionId,
|
|
1283
1048
|
streamType: "tmux-pipe",
|
|
@@ -1288,13 +1053,14 @@ var SessionStreamer = class {
|
|
|
1288
1053
|
stopped: false,
|
|
1289
1054
|
initializing: true,
|
|
1290
1055
|
initBuffer: [],
|
|
1056
|
+
cleanEnv,
|
|
1291
1057
|
ptyProc: null
|
|
1292
1058
|
};
|
|
1293
1059
|
this.active.set(sessionId, stream);
|
|
1294
1060
|
try {
|
|
1295
|
-
(0,
|
|
1061
|
+
(0, import_node_child_process2.execFileSync)(
|
|
1296
1062
|
"tmux",
|
|
1297
|
-
["pipe-pane", "-O", "-t", paneTarget, `cat > ${fifoPath}`],
|
|
1063
|
+
["pipe-pane", "-O", "-t", paneTarget, `cat > ${shellQuote(fifoPath)}`],
|
|
1298
1064
|
{ env: cleanEnv, timeout: 5e3 }
|
|
1299
1065
|
);
|
|
1300
1066
|
} catch (error) {
|
|
@@ -1303,7 +1069,7 @@ var SessionStreamer = class {
|
|
|
1303
1069
|
console.warn(`[rAgent] Failed pipe-pane for ${sessionId}: ${message}`);
|
|
1304
1070
|
return false;
|
|
1305
1071
|
}
|
|
1306
|
-
const catProc = (0,
|
|
1072
|
+
const catProc = (0, import_node_child_process2.spawn)("cat", [fifoPath], { env: cleanEnv, stdio: ["ignore", "pipe", "ignore"] });
|
|
1307
1073
|
stream.catProc = catProc;
|
|
1308
1074
|
catProc.stdout.on("data", (chunk) => {
|
|
1309
1075
|
if (stream.stopped) return;
|
|
@@ -1323,9 +1089,20 @@ var SessionStreamer = class {
|
|
|
1323
1089
|
this.onStreamStopped?.(sessionId);
|
|
1324
1090
|
}
|
|
1325
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
|
+
}
|
|
1326
1103
|
this.sendFn(sessionId, "\x1B[2J\x1B[H");
|
|
1327
1104
|
try {
|
|
1328
|
-
const initial = (0,
|
|
1105
|
+
const initial = (0, import_node_child_process2.execFileSync)(
|
|
1329
1106
|
"tmux",
|
|
1330
1107
|
["capture-pane", "-t", paneTarget, "-p", "-e"],
|
|
1331
1108
|
{ env: cleanEnv, timeout: 5e3, encoding: "utf-8" }
|
|
@@ -1336,7 +1113,7 @@ var SessionStreamer = class {
|
|
|
1336
1113
|
} catch {
|
|
1337
1114
|
}
|
|
1338
1115
|
try {
|
|
1339
|
-
const cursorInfo = (0,
|
|
1116
|
+
const cursorInfo = (0, import_node_child_process2.execFileSync)(
|
|
1340
1117
|
"tmux",
|
|
1341
1118
|
["display-message", "-t", paneTarget, "-p", "#{cursor_x} #{cursor_y}"],
|
|
1342
1119
|
{ env: cleanEnv, timeout: 5e3, encoding: "utf-8" }
|
|
@@ -1352,12 +1129,7 @@ var SessionStreamer = class {
|
|
|
1352
1129
|
} catch {
|
|
1353
1130
|
}
|
|
1354
1131
|
stream.initializing = false;
|
|
1355
|
-
|
|
1356
|
-
for (const buffered of stream.initBuffer) {
|
|
1357
|
-
this.sendFn(sessionId, buffered);
|
|
1358
|
-
}
|
|
1359
|
-
stream.initBuffer = [];
|
|
1360
|
-
}
|
|
1132
|
+
stream.initBuffer = [];
|
|
1361
1133
|
console.log(`[rAgent] Started streaming: ${sessionId} (pane: ${paneTarget})`);
|
|
1362
1134
|
return true;
|
|
1363
1135
|
} catch (error) {
|
|
@@ -1373,7 +1145,7 @@ var SessionStreamer = class {
|
|
|
1373
1145
|
const sessionName = parseScreenSession(sessionId);
|
|
1374
1146
|
if (!sessionName) return false;
|
|
1375
1147
|
try {
|
|
1376
|
-
const proc =
|
|
1148
|
+
const proc = pty.spawn("screen", ["-x", sessionName], {
|
|
1377
1149
|
name: "xterm-256color",
|
|
1378
1150
|
cols: 80,
|
|
1379
1151
|
rows: 30,
|
|
@@ -1422,7 +1194,7 @@ var SessionStreamer = class {
|
|
|
1422
1194
|
const sessionName = parseZellijSession(sessionId);
|
|
1423
1195
|
if (!sessionName) return false;
|
|
1424
1196
|
try {
|
|
1425
|
-
const proc =
|
|
1197
|
+
const proc = pty.spawn("zellij", ["attach", sessionName], {
|
|
1426
1198
|
name: "xterm-256color",
|
|
1427
1199
|
cols: 80,
|
|
1428
1200
|
rows: 30,
|
|
@@ -1471,7 +1243,10 @@ var SessionStreamer = class {
|
|
|
1471
1243
|
stream.stopped = true;
|
|
1472
1244
|
if (stream.streamType === "tmux-pipe") {
|
|
1473
1245
|
try {
|
|
1474
|
-
(0,
|
|
1246
|
+
(0, import_node_child_process2.execFileSync)("tmux", ["pipe-pane", "-t", stream.paneTarget], {
|
|
1247
|
+
timeout: 5e3,
|
|
1248
|
+
env: stream.cleanEnv
|
|
1249
|
+
});
|
|
1475
1250
|
} catch {
|
|
1476
1251
|
}
|
|
1477
1252
|
if (stream.catProc && !stream.catProc.killed) {
|
|
@@ -1496,10 +1271,680 @@ var SessionStreamer = class {
|
|
|
1496
1271
|
}
|
|
1497
1272
|
}
|
|
1498
1273
|
}
|
|
1499
|
-
};
|
|
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
|
+
}
|
|
1500
1945
|
|
|
1501
1946
|
// src/provisioner.ts
|
|
1502
|
-
var
|
|
1947
|
+
var import_child_process3 = require("child_process");
|
|
1503
1948
|
var DANGEROUS_PATTERN = /rm\s+-r[fe]*\s+\/|mkfs|dd\s+if=|:()\s*\{|>\s*\/dev\/sd/i;
|
|
1504
1949
|
function shellQuote2(s) {
|
|
1505
1950
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
@@ -1507,7 +1952,7 @@ function shellQuote2(s) {
|
|
|
1507
1952
|
var SESSION_NAME_RE = /^[a-zA-Z0-9_-]+$/;
|
|
1508
1953
|
var MAX_SESSION_NAME = 128;
|
|
1509
1954
|
function runCommand(cmd, timeout = 12e4) {
|
|
1510
|
-
return (0,
|
|
1955
|
+
return (0, import_child_process3.execSync)(cmd, {
|
|
1511
1956
|
encoding: "utf8",
|
|
1512
1957
|
timeout,
|
|
1513
1958
|
maxBuffer: 10 * 1024 * 1024,
|
|
@@ -1518,7 +1963,7 @@ function runCommand(cmd, timeout = 12e4) {
|
|
|
1518
1963
|
function commandExists2(cmd) {
|
|
1519
1964
|
if (!/^[a-zA-Z0-9._+-]+$/.test(cmd)) return false;
|
|
1520
1965
|
try {
|
|
1521
|
-
(0,
|
|
1966
|
+
(0, import_child_process3.execFileSync)("sh", ["-c", `command -v -- ${cmd} >/dev/null 2>&1`], { stdio: "ignore" });
|
|
1522
1967
|
return true;
|
|
1523
1968
|
} catch {
|
|
1524
1969
|
return false;
|
|
@@ -1668,7 +2113,7 @@ function startAgent(request, onProgress) {
|
|
|
1668
2113
|
}
|
|
1669
2114
|
tmuxArgs.push(fullCmd);
|
|
1670
2115
|
try {
|
|
1671
|
-
(0,
|
|
2116
|
+
(0, import_child_process3.execFileSync)("tmux", tmuxArgs, { stdio: "ignore" });
|
|
1672
2117
|
onProgress({
|
|
1673
2118
|
type: "provision-progress",
|
|
1674
2119
|
provisionId: request.provisionId,
|
|
@@ -1703,6 +2148,250 @@ async function executeProvision(request, onProgress) {
|
|
|
1703
2148
|
return startAgent(request, emit);
|
|
1704
2149
|
}
|
|
1705
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
|
+
|
|
1706
2395
|
// src/agent.ts
|
|
1707
2396
|
function pidFilePath(hostId) {
|
|
1708
2397
|
return path2.join(CONFIG_DIR, `agent-${hostId}.pid`);
|
|
@@ -1751,7 +2440,7 @@ function resolveRunOptions(commandOptions) {
|
|
|
1751
2440
|
const config = loadConfig();
|
|
1752
2441
|
const portal = commandOptions.portal || config.portal || DEFAULT_PORTAL;
|
|
1753
2442
|
const hostId = sanitizeHostId(commandOptions.id || config.hostId || inferHostId());
|
|
1754
|
-
const hostName = commandOptions.name || config.hostName ||
|
|
2443
|
+
const hostName = commandOptions.name || config.hostName || os6.hostname();
|
|
1755
2444
|
const command = commandOptions.command || config.command || "bash";
|
|
1756
2445
|
const agentToken = commandOptions.agentToken || config.agentToken || "";
|
|
1757
2446
|
return { portal, hostId, hostName, command, agentToken };
|
|
@@ -1772,400 +2461,93 @@ async function runAgent(rawOptions) {
|
|
|
1772
2461
|
}
|
|
1773
2462
|
} catch {
|
|
1774
2463
|
}
|
|
1775
|
-
let shouldRun = true;
|
|
1776
|
-
let reconnectRequested = false;
|
|
1777
|
-
let reconnectDelay = DEFAULT_RECONNECT_DELAY_MS;
|
|
1778
|
-
let activeSocket = null;
|
|
1779
|
-
let activeGroups = { privateGroup: "", registryGroup: "" };
|
|
1780
|
-
let wsHeartbeatTimer = null;
|
|
1781
|
-
let httpHeartbeatTimer = null;
|
|
1782
|
-
let wsPingTimer = null;
|
|
1783
|
-
let wsPongTimeout = null;
|
|
1784
|
-
let suppressNextShellRespawn = false;
|
|
1785
|
-
let lastSentFingerprint = "";
|
|
1786
|
-
let lastHttpHeartbeatAt = 0;
|
|
1787
2464
|
const outputBuffer = new OutputBuffer();
|
|
1788
|
-
let ptyProcess = null;
|
|
1789
2465
|
const ptySessionId = `pty:${options.hostId}`;
|
|
1790
|
-
const sendOutput = (chunk) => {
|
|
1791
|
-
const ws = activeSocket;
|
|
1792
|
-
if (!ws || ws.readyState !== import_ws2.default.OPEN || !activeGroups.privateGroup) {
|
|
1793
|
-
outputBuffer.push(chunk);
|
|
1794
|
-
return;
|
|
1795
|
-
}
|
|
1796
|
-
sendToGroup(ws, activeGroups.privateGroup, { type: "output", data: chunk, sessionId: ptySessionId });
|
|
1797
|
-
};
|
|
1798
2466
|
const sessionStreamer = new SessionStreamer(
|
|
1799
2467
|
(sessionId, data) => {
|
|
1800
|
-
|
|
1801
|
-
if (
|
|
1802
|
-
|
|
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
|
+
}
|
|
1803
2475
|
},
|
|
1804
2476
|
(sessionId) => {
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
sendToGroup(ws, activeGroups.privateGroup, { type: "stream-stopped", sessionId });
|
|
2477
|
+
if (!conn.isReady()) return;
|
|
2478
|
+
sendToGroup(conn.activeSocket, conn.activeGroups.privateGroup, { type: "stream-stopped", sessionId });
|
|
1808
2479
|
}
|
|
1809
2480
|
);
|
|
1810
|
-
const
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
} catch {
|
|
1816
|
-
}
|
|
1817
|
-
ptyProcess = null;
|
|
1818
|
-
};
|
|
1819
|
-
const spawnOrRespawnShell = () => {
|
|
1820
|
-
ptyProcess = spawnConnectorShell(options.command, sendOutput, () => {
|
|
1821
|
-
if (suppressNextShellRespawn) {
|
|
1822
|
-
suppressNextShellRespawn = false;
|
|
1823
|
-
return;
|
|
1824
|
-
}
|
|
1825
|
-
if (!shouldRun) return;
|
|
1826
|
-
console.warn("[rAgent] Shell exited. Restarting shell process.");
|
|
1827
|
-
setTimeout(() => {
|
|
1828
|
-
if (shouldRun) spawnOrRespawnShell();
|
|
1829
|
-
}, 200);
|
|
1830
|
-
});
|
|
1831
|
-
};
|
|
1832
|
-
const restartLocalShell = () => {
|
|
1833
|
-
killCurrentShell();
|
|
1834
|
-
spawnOrRespawnShell();
|
|
1835
|
-
};
|
|
1836
|
-
spawnOrRespawnShell();
|
|
1837
|
-
const cleanupSocket = (opts = {}) => {
|
|
1838
|
-
if (opts.stopStreams) sessionStreamer.stopAll();
|
|
1839
|
-
if (wsPingTimer) {
|
|
1840
|
-
clearInterval(wsPingTimer);
|
|
1841
|
-
wsPingTimer = null;
|
|
1842
|
-
}
|
|
1843
|
-
if (wsPongTimeout) {
|
|
1844
|
-
clearTimeout(wsPongTimeout);
|
|
1845
|
-
wsPongTimeout = null;
|
|
1846
|
-
}
|
|
1847
|
-
if (wsHeartbeatTimer) {
|
|
1848
|
-
clearInterval(wsHeartbeatTimer);
|
|
1849
|
-
wsHeartbeatTimer = null;
|
|
1850
|
-
}
|
|
1851
|
-
if (httpHeartbeatTimer) {
|
|
1852
|
-
clearInterval(httpHeartbeatTimer);
|
|
1853
|
-
httpHeartbeatTimer = null;
|
|
1854
|
-
}
|
|
1855
|
-
if (activeSocket) {
|
|
1856
|
-
activeSocket.removeAllListeners();
|
|
1857
|
-
try {
|
|
1858
|
-
activeSocket.close();
|
|
1859
|
-
} catch {
|
|
1860
|
-
}
|
|
1861
|
-
activeSocket = null;
|
|
1862
|
-
}
|
|
1863
|
-
activeGroups = { privateGroup: "", registryGroup: "" };
|
|
1864
|
-
};
|
|
1865
|
-
let prevCpuSnapshot = null;
|
|
1866
|
-
function takeCpuSnapshot() {
|
|
1867
|
-
const cpus2 = os5.cpus();
|
|
1868
|
-
let idle = 0;
|
|
1869
|
-
let total = 0;
|
|
1870
|
-
for (const cpu of cpus2) {
|
|
1871
|
-
for (const type of Object.keys(cpu.times)) {
|
|
1872
|
-
total += cpu.times[type];
|
|
1873
|
-
}
|
|
1874
|
-
idle += cpu.times.idle;
|
|
1875
|
-
}
|
|
1876
|
-
return { idle, total };
|
|
1877
|
-
}
|
|
1878
|
-
const collectVitals = () => {
|
|
1879
|
-
const currentSnapshot = takeCpuSnapshot();
|
|
1880
|
-
let cpuUsage = 0;
|
|
1881
|
-
if (prevCpuSnapshot) {
|
|
1882
|
-
const idleDelta = currentSnapshot.idle - prevCpuSnapshot.idle;
|
|
1883
|
-
const totalDelta = currentSnapshot.total - prevCpuSnapshot.total;
|
|
1884
|
-
cpuUsage = totalDelta > 0 ? Math.round((totalDelta - idleDelta) / totalDelta * 100) : 0;
|
|
1885
|
-
}
|
|
1886
|
-
prevCpuSnapshot = currentSnapshot;
|
|
1887
|
-
const totalMem = os5.totalmem();
|
|
1888
|
-
const freeMem = os5.freemem();
|
|
1889
|
-
const memUsedPct = totalMem > 0 ? Math.round((totalMem - freeMem) / totalMem * 100) : 0;
|
|
1890
|
-
let diskUsedPct = 0;
|
|
1891
|
-
try {
|
|
1892
|
-
const { execSync: execSync2 } = require("child_process");
|
|
1893
|
-
const dfOutput = execSync2("df -P / | tail -1", { encoding: "utf8", timeout: 5e3 });
|
|
1894
|
-
const parts = dfOutput.trim().split(/\s+/);
|
|
1895
|
-
if (parts.length >= 5) {
|
|
1896
|
-
diskUsedPct = parseInt(parts[4].replace("%", ""), 10) || 0;
|
|
1897
|
-
}
|
|
1898
|
-
} catch {
|
|
1899
|
-
}
|
|
1900
|
-
return { cpu: cpuUsage, memUsedPct, diskUsedPct };
|
|
1901
|
-
};
|
|
1902
|
-
const announceToRegistry = async (type = "heartbeat") => {
|
|
1903
|
-
const ws = activeSocket;
|
|
1904
|
-
if (!ws || ws.readyState !== import_ws2.default.OPEN || !activeGroups.registryGroup) return;
|
|
1905
|
-
const sessions = await collectSessionInventory(options.hostId, options.command);
|
|
1906
|
-
const vitals = collectVitals();
|
|
1907
|
-
sendToGroup(ws, activeGroups.registryGroup, {
|
|
1908
|
-
type,
|
|
1909
|
-
hostId: options.hostId,
|
|
1910
|
-
hostName: options.hostName,
|
|
1911
|
-
environment: os5.hostname(),
|
|
1912
|
-
sessions,
|
|
1913
|
-
vitals,
|
|
1914
|
-
agentVersion: CURRENT_VERSION,
|
|
1915
|
-
lastSeenAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1916
|
-
});
|
|
1917
|
-
};
|
|
1918
|
-
const syncInventory = async (force = false) => {
|
|
1919
|
-
const sessions = await collectSessionInventory(options.hostId, options.command);
|
|
1920
|
-
const fingerprint = sessionInventoryFingerprint(sessions);
|
|
1921
|
-
const changed = fingerprint !== lastSentFingerprint;
|
|
1922
|
-
const checkpointDue = Date.now() - lastHttpHeartbeatAt > HTTP_HEARTBEAT_MS;
|
|
1923
|
-
if (changed || force) {
|
|
1924
|
-
await announceToRegistry("inventory");
|
|
1925
|
-
lastSentFingerprint = fingerprint;
|
|
1926
|
-
}
|
|
1927
|
-
if (changed || checkpointDue || force) {
|
|
1928
|
-
await postHeartbeat({
|
|
1929
|
-
portal: options.portal,
|
|
1930
|
-
agentToken: options.agentToken,
|
|
1931
|
-
hostId: options.hostId,
|
|
1932
|
-
hostName: options.hostName,
|
|
1933
|
-
command: options.command
|
|
1934
|
-
});
|
|
1935
|
-
lastHttpHeartbeatAt = Date.now();
|
|
2481
|
+
const conn = new ConnectionManager(sessionStreamer, outputBuffer);
|
|
2482
|
+
const sendOutput = (chunk) => {
|
|
2483
|
+
if (!conn.isReady()) {
|
|
2484
|
+
outputBuffer.push(chunk);
|
|
2485
|
+
return;
|
|
1936
2486
|
}
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
case "restart-shell":
|
|
1943
|
-
restartLocalShell();
|
|
1944
|
-
await syncInventory();
|
|
1945
|
-
return;
|
|
1946
|
-
case "restart-agent":
|
|
1947
|
-
case "disconnect":
|
|
1948
|
-
reconnectRequested = true;
|
|
1949
|
-
sessionStreamer.stopAll();
|
|
1950
|
-
if (activeSocket && activeSocket.readyState === import_ws2.default.OPEN) {
|
|
1951
|
-
activeSocket.close();
|
|
1952
|
-
}
|
|
1953
|
-
return;
|
|
1954
|
-
case "stop-agent":
|
|
1955
|
-
shouldRun = false;
|
|
1956
|
-
sessionStreamer.stopAll();
|
|
1957
|
-
requestStopSelfService();
|
|
1958
|
-
if (activeSocket && activeSocket.readyState === import_ws2.default.OPEN) {
|
|
1959
|
-
activeSocket.close();
|
|
1960
|
-
}
|
|
1961
|
-
return;
|
|
1962
|
-
case "stop-session":
|
|
1963
|
-
if (!sessionId) return;
|
|
1964
|
-
if (sessionId.startsWith("pty:")) {
|
|
1965
|
-
restartLocalShell();
|
|
1966
|
-
await syncInventory();
|
|
1967
|
-
return;
|
|
1968
|
-
}
|
|
1969
|
-
if (sessionId.startsWith("tmux:")) {
|
|
1970
|
-
try {
|
|
1971
|
-
await stopTmuxPaneBySessionId(sessionId);
|
|
1972
|
-
console.log(`[rAgent] Closed remote session ${sessionId}.`);
|
|
1973
|
-
} catch (error) {
|
|
1974
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1975
|
-
console.warn(
|
|
1976
|
-
`[rAgent] Failed to close ${sessionId}: ${message}`
|
|
1977
|
-
);
|
|
1978
|
-
}
|
|
1979
|
-
await syncInventory();
|
|
1980
|
-
}
|
|
1981
|
-
return;
|
|
1982
|
-
case "stop-detached": {
|
|
1983
|
-
const killed = await stopAllDetachedTmuxSessions();
|
|
1984
|
-
console.log(`[rAgent] Killed ${killed} detached tmux session(s).`);
|
|
1985
|
-
await syncInventory();
|
|
1986
|
-
return;
|
|
1987
|
-
}
|
|
1988
|
-
case "start-agent": {
|
|
1989
|
-
const sessionName = typeof payload?.sessionName === "string" && payload.sessionName.trim().length > 0 ? payload.sessionName.trim() : `agent-${Date.now().toString(36)}`;
|
|
1990
|
-
const cmd = typeof payload?.command === "string" && payload.command.trim().length > 0 ? payload.command.trim() : null;
|
|
1991
|
-
if (!cmd) {
|
|
1992
|
-
console.warn("[rAgent] start-agent: no command provided, ignoring.");
|
|
1993
|
-
return;
|
|
1994
|
-
}
|
|
1995
|
-
if (sessionName.length > 128 || !/^[a-zA-Z0-9_-]+$/.test(sessionName)) {
|
|
1996
|
-
console.warn("[rAgent] start-agent: invalid session name, ignoring.");
|
|
1997
|
-
return;
|
|
1998
|
-
}
|
|
1999
|
-
const dangerous = /rm\s+-r[fe]*\s+\/|mkfs|dd\s+if=|:()\s*\{|>\s*\/dev\/sd/i;
|
|
2000
|
-
if (dangerous.test(cmd)) {
|
|
2001
|
-
console.warn(`[rAgent] start-agent: rejected dangerous command: ${cmd}`);
|
|
2002
|
-
return;
|
|
2003
|
-
}
|
|
2004
|
-
const workingDir = typeof payload?.workingDir === "string" && payload.workingDir.trim().length > 0 ? payload.workingDir.trim() : void 0;
|
|
2005
|
-
const envVars = payload?.envVars && typeof payload.envVars === "object" ? payload.envVars : void 0;
|
|
2006
|
-
const tmuxArgs = ["new-session", "-d", "-s", sessionName];
|
|
2007
|
-
if (workingDir) {
|
|
2008
|
-
tmuxArgs.push("-c", workingDir);
|
|
2009
|
-
}
|
|
2010
|
-
let fullCmd = cmd;
|
|
2011
|
-
if (envVars) {
|
|
2012
|
-
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(" ");
|
|
2013
|
-
if (entries) fullCmd = `${entries} ${cmd}`;
|
|
2014
|
-
}
|
|
2015
|
-
tmuxArgs.push(fullCmd);
|
|
2016
|
-
try {
|
|
2017
|
-
const { execFileSync: execFileSync3 } = await import("child_process");
|
|
2018
|
-
execFileSync3("tmux", tmuxArgs, { stdio: "ignore" });
|
|
2019
|
-
console.log(`[rAgent] Started agent session "${sessionName}": ${cmd}`);
|
|
2020
|
-
} catch (error) {
|
|
2021
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
2022
|
-
console.error(`[rAgent] Failed to start agent session "${sessionName}": ${message}`);
|
|
2023
|
-
}
|
|
2024
|
-
await syncInventory(true);
|
|
2025
|
-
return;
|
|
2026
|
-
}
|
|
2027
|
-
case "stream-session": {
|
|
2028
|
-
if (!sessionId) return;
|
|
2029
|
-
if (sessionId.startsWith("process:")) {
|
|
2030
|
-
const ws2 = activeSocket;
|
|
2031
|
-
if (ws2 && ws2.readyState === import_ws2.default.OPEN && activeGroups.privateGroup) {
|
|
2032
|
-
sendToGroup(ws2, activeGroups.privateGroup, {
|
|
2033
|
-
type: "stream-error",
|
|
2034
|
-
sessionId,
|
|
2035
|
-
error: "This agent is running outside a terminal multiplexer. Stop and relaunch via Start Agent to enable live streaming."
|
|
2036
|
-
});
|
|
2037
|
-
}
|
|
2038
|
-
return;
|
|
2039
|
-
}
|
|
2040
|
-
if (sessionId.startsWith("tmux:") || sessionId.startsWith("screen:") || sessionId.startsWith("zellij:")) {
|
|
2041
|
-
const started = sessionStreamer.startStream(sessionId);
|
|
2042
|
-
const ws2 = activeSocket;
|
|
2043
|
-
if (ws2 && ws2.readyState === import_ws2.default.OPEN && activeGroups.privateGroup) {
|
|
2044
|
-
if (started) {
|
|
2045
|
-
sendToGroup(ws2, activeGroups.privateGroup, {
|
|
2046
|
-
type: "stream-started",
|
|
2047
|
-
sessionId
|
|
2048
|
-
});
|
|
2049
|
-
} else {
|
|
2050
|
-
sendToGroup(ws2, activeGroups.privateGroup, {
|
|
2051
|
-
type: "stream-error",
|
|
2052
|
-
sessionId,
|
|
2053
|
-
error: "Failed to attach to session. It may no longer exist."
|
|
2054
|
-
});
|
|
2055
|
-
}
|
|
2056
|
-
}
|
|
2057
|
-
return;
|
|
2058
|
-
}
|
|
2059
|
-
const ws = activeSocket;
|
|
2060
|
-
if (ws && ws.readyState === import_ws2.default.OPEN && activeGroups.privateGroup) {
|
|
2061
|
-
sendToGroup(ws, activeGroups.privateGroup, {
|
|
2062
|
-
type: "stream-error",
|
|
2063
|
-
sessionId,
|
|
2064
|
-
error: "Live streaming is not yet supported for this session type."
|
|
2065
|
-
});
|
|
2066
|
-
}
|
|
2067
|
-
return;
|
|
2068
|
-
}
|
|
2069
|
-
case "stop-stream": {
|
|
2070
|
-
if (!sessionId) return;
|
|
2071
|
-
sessionStreamer.stopStream(sessionId);
|
|
2072
|
-
return;
|
|
2073
|
-
}
|
|
2074
|
-
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 });
|
|
2075
2492
|
}
|
|
2076
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();
|
|
2077
2498
|
const onSignal = () => {
|
|
2078
|
-
shouldRun = false;
|
|
2079
|
-
|
|
2080
|
-
|
|
2499
|
+
dispatcher.shouldRun = false;
|
|
2500
|
+
conn.cleanup({ stopStreams: true });
|
|
2501
|
+
shell.stop();
|
|
2081
2502
|
releasePidLock(lockPath);
|
|
2082
2503
|
};
|
|
2083
2504
|
process.once("SIGTERM", onSignal);
|
|
2084
2505
|
process.once("SIGINT", onSignal);
|
|
2085
2506
|
try {
|
|
2086
|
-
while (shouldRun) {
|
|
2087
|
-
reconnectRequested = false;
|
|
2507
|
+
while (dispatcher.shouldRun) {
|
|
2508
|
+
dispatcher.reconnectRequested = false;
|
|
2088
2509
|
try {
|
|
2089
2510
|
options.agentToken = await refreshTokenIfNeeded({
|
|
2090
2511
|
portal: options.portal,
|
|
2091
2512
|
agentToken: options.agentToken
|
|
2092
2513
|
});
|
|
2514
|
+
inventory.updateOptions(options);
|
|
2515
|
+
dispatcher.updateOptions(options);
|
|
2093
2516
|
const negotiated = await negotiateAgent({
|
|
2094
2517
|
portal: options.portal,
|
|
2095
2518
|
agentToken: options.agentToken
|
|
2096
2519
|
});
|
|
2097
|
-
|
|
2520
|
+
const groups = {
|
|
2098
2521
|
privateGroup: negotiated.groups.privateGroup,
|
|
2099
2522
|
registryGroup: negotiated.groups.registryGroup
|
|
2100
2523
|
};
|
|
2101
2524
|
await new Promise((resolve) => {
|
|
2102
|
-
const ws = new
|
|
2103
|
-
|
|
2104
|
-
"json.webpubsub.azure.v1"
|
|
2105
|
-
);
|
|
2106
|
-
activeSocket = ws;
|
|
2525
|
+
const ws = new import_ws5.default(negotiated.url, "json.webpubsub.azure.v1");
|
|
2526
|
+
conn.setConnection(ws, groups, negotiated.sessionKey ?? null);
|
|
2107
2527
|
ws.on("open", async () => {
|
|
2108
2528
|
console.log("[rAgent] Connector connected to relay.");
|
|
2109
|
-
|
|
2110
|
-
ws.send(
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
group: activeGroups.privateGroup
|
|
2114
|
-
})
|
|
2115
|
-
);
|
|
2116
|
-
ws.send(
|
|
2117
|
-
JSON.stringify({
|
|
2118
|
-
type: "joinGroup",
|
|
2119
|
-
group: activeGroups.registryGroup
|
|
2120
|
-
})
|
|
2121
|
-
);
|
|
2122
|
-
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, {
|
|
2123
2533
|
type: "register",
|
|
2124
2534
|
hostName: options.hostName,
|
|
2125
|
-
environment:
|
|
2535
|
+
environment: os6.platform()
|
|
2126
2536
|
});
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
}
|
|
2140
|
-
await syncInventory(true);
|
|
2141
|
-
wsHeartbeatTimer = setInterval(async () => {
|
|
2142
|
-
if (!activeSocket || activeSocket.readyState !== import_ws2.default.OPEN)
|
|
2143
|
-
return;
|
|
2144
|
-
await announceToRegistry("heartbeat");
|
|
2145
|
-
}, WS_HEARTBEAT_MS);
|
|
2146
|
-
httpHeartbeatTimer = setInterval(async () => {
|
|
2147
|
-
if (!activeSocket || activeSocket.readyState !== import_ws2.default.OPEN)
|
|
2148
|
-
return;
|
|
2149
|
-
await syncInventory();
|
|
2150
|
-
}, HTTP_HEARTBEAT_MS);
|
|
2151
|
-
wsPingTimer = setInterval(() => {
|
|
2152
|
-
if (!activeSocket || activeSocket.readyState !== import_ws2.default.OPEN) return;
|
|
2153
|
-
activeSocket.ping();
|
|
2154
|
-
wsPongTimeout = setTimeout(() => {
|
|
2155
|
-
console.warn("[rAgent] No pong received within 10s \u2014 closing stale connection.");
|
|
2156
|
-
try {
|
|
2157
|
-
activeSocket?.terminate();
|
|
2158
|
-
} catch {
|
|
2159
|
-
}
|
|
2160
|
-
}, 1e4);
|
|
2161
|
-
}, 2e4);
|
|
2162
|
-
});
|
|
2163
|
-
ws.on("pong", () => {
|
|
2164
|
-
if (wsPongTimeout) {
|
|
2165
|
-
clearTimeout(wsPongTimeout);
|
|
2166
|
-
wsPongTimeout = null;
|
|
2167
|
-
}
|
|
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
|
+
);
|
|
2168
2549
|
});
|
|
2550
|
+
ws.on("pong", () => conn.onPong());
|
|
2169
2551
|
ws.on("message", async (data) => {
|
|
2170
2552
|
let msg;
|
|
2171
2553
|
try {
|
|
@@ -2173,69 +2555,37 @@ async function runAgent(rawOptions) {
|
|
|
2173
2555
|
} catch {
|
|
2174
2556
|
return;
|
|
2175
2557
|
}
|
|
2176
|
-
if (msg.type === "message" && msg.group ===
|
|
2558
|
+
if (msg.type === "message" && msg.group === groups.privateGroup) {
|
|
2177
2559
|
const payload = msg.data || {};
|
|
2178
|
-
if (payload.type === "input"
|
|
2179
|
-
|
|
2180
|
-
if (
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
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);
|
|
2186
2573
|
}
|
|
2187
2574
|
} else if (payload.type === "resize" && Number.isInteger(payload.cols) && Number.isInteger(payload.rows)) {
|
|
2188
2575
|
const sid = typeof payload.sessionId === "string" ? payload.sessionId.trim() : "";
|
|
2189
|
-
|
|
2190
|
-
try {
|
|
2191
|
-
if (ptyProcess)
|
|
2192
|
-
ptyProcess.resize(
|
|
2193
|
-
payload.cols,
|
|
2194
|
-
payload.rows
|
|
2195
|
-
);
|
|
2196
|
-
} catch (error) {
|
|
2197
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
2198
|
-
if (!message.includes("EBADF")) {
|
|
2199
|
-
console.warn(`[rAgent] Resize failed: ${message}`);
|
|
2200
|
-
}
|
|
2201
|
-
}
|
|
2202
|
-
}
|
|
2576
|
+
dispatcher.handleResize(payload.cols, payload.rows, sid);
|
|
2203
2577
|
} else if (payload.type === "control" && typeof payload.action === "string") {
|
|
2204
|
-
await handleControlAction(payload);
|
|
2578
|
+
await dispatcher.handleControlAction(payload);
|
|
2205
2579
|
} else if (payload.type === "start-agent") {
|
|
2206
|
-
await handleControlAction({ ...payload, action: "start-agent" });
|
|
2580
|
+
await dispatcher.handleControlAction({ ...payload, action: "start-agent" });
|
|
2207
2581
|
} else if (payload.type === "provision") {
|
|
2208
|
-
|
|
2209
|
-
if (provReq.provisionId && provReq.manifest) {
|
|
2210
|
-
console.log(`[rAgent] Provision request: ${provReq.manifest.name} (${provReq.provisionId})`);
|
|
2211
|
-
const sendProgress = (progress) => {
|
|
2212
|
-
const currentWs = activeSocket;
|
|
2213
|
-
if (currentWs && currentWs.readyState === import_ws2.default.OPEN && activeGroups.registryGroup) {
|
|
2214
|
-
sendToGroup(currentWs, activeGroups.registryGroup, {
|
|
2215
|
-
...progress,
|
|
2216
|
-
hostId: options.hostId
|
|
2217
|
-
});
|
|
2218
|
-
}
|
|
2219
|
-
};
|
|
2220
|
-
try {
|
|
2221
|
-
await executeProvision(provReq, sendProgress);
|
|
2222
|
-
await syncInventory(true);
|
|
2223
|
-
} catch (error) {
|
|
2224
|
-
const errMsg = error instanceof Error ? error.message : String(error);
|
|
2225
|
-
sendProgress({
|
|
2226
|
-
type: "provision-progress",
|
|
2227
|
-
provisionId: provReq.provisionId,
|
|
2228
|
-
step: "error",
|
|
2229
|
-
message: `Provision failed: ${errMsg}`
|
|
2230
|
-
});
|
|
2231
|
-
}
|
|
2232
|
-
}
|
|
2582
|
+
await dispatcher.handleProvision(payload);
|
|
2233
2583
|
}
|
|
2234
2584
|
}
|
|
2235
|
-
if (msg.type === "message" && msg.group ===
|
|
2585
|
+
if (msg.type === "message" && msg.group === groups.registryGroup) {
|
|
2236
2586
|
const payload = msg.data || {};
|
|
2237
2587
|
if (payload.type === "ping") {
|
|
2238
|
-
await announceToRegistry("announce");
|
|
2588
|
+
await inventory.announceToRegistry(ws, groups.registryGroup, "announce");
|
|
2239
2589
|
}
|
|
2240
2590
|
}
|
|
2241
2591
|
});
|
|
@@ -2243,10 +2593,8 @@ async function runAgent(rawOptions) {
|
|
|
2243
2593
|
console.error("[rAgent] WebSocket error:", error.message);
|
|
2244
2594
|
});
|
|
2245
2595
|
ws.on("close", () => {
|
|
2246
|
-
console.log(
|
|
2247
|
-
|
|
2248
|
-
);
|
|
2249
|
-
cleanupSocket();
|
|
2596
|
+
console.log("[rAgent] Relay disconnected. Output will be buffered until reconnect.");
|
|
2597
|
+
conn.cleanup();
|
|
2250
2598
|
resolve();
|
|
2251
2599
|
});
|
|
2252
2600
|
});
|
|
@@ -2256,28 +2604,26 @@ async function runAgent(rawOptions) {
|
|
|
2256
2604
|
console.error(
|
|
2257
2605
|
"[rAgent] Connector token is invalid or revoked. Stopping. Re-connect with: ragent connect --token <token>"
|
|
2258
2606
|
);
|
|
2259
|
-
shouldRun = false;
|
|
2607
|
+
dispatcher.shouldRun = false;
|
|
2260
2608
|
break;
|
|
2261
2609
|
}
|
|
2262
2610
|
const message = error instanceof Error ? error.message : String(error);
|
|
2263
2611
|
console.error(`[rAgent] Relay connect failed: ${message}`);
|
|
2264
2612
|
}
|
|
2265
|
-
if (!shouldRun) break;
|
|
2266
|
-
if (reconnectRequested) {
|
|
2613
|
+
if (!dispatcher.shouldRun) break;
|
|
2614
|
+
if (dispatcher.reconnectRequested) {
|
|
2267
2615
|
console.log("[rAgent] Reconnecting to relay...");
|
|
2268
2616
|
await wait(300);
|
|
2269
2617
|
continue;
|
|
2270
2618
|
}
|
|
2271
|
-
const jitteredDelay = reconnectDelay * (0.5 + Math.random());
|
|
2272
|
-
console.log(
|
|
2273
|
-
`[rAgent] Disconnected. Reconnecting in ${Math.round(jitteredDelay / 1e3)}s...`
|
|
2274
|
-
);
|
|
2619
|
+
const jitteredDelay = conn.reconnectDelay * (0.5 + Math.random());
|
|
2620
|
+
console.log(`[rAgent] Disconnected. Reconnecting in ${Math.round(jitteredDelay / 1e3)}s...`);
|
|
2275
2621
|
await wait(jitteredDelay);
|
|
2276
|
-
reconnectDelay = Math.min(reconnectDelay * 1.5, MAX_RECONNECT_DELAY_MS);
|
|
2622
|
+
conn.reconnectDelay = Math.min(conn.reconnectDelay * 1.5, MAX_RECONNECT_DELAY_MS);
|
|
2277
2623
|
}
|
|
2278
2624
|
} finally {
|
|
2279
|
-
|
|
2280
|
-
|
|
2625
|
+
conn.cleanup({ stopStreams: true });
|
|
2626
|
+
shell.stop();
|
|
2281
2627
|
releasePidLock(lockPath);
|
|
2282
2628
|
process.removeListener("SIGTERM", onSignal);
|
|
2283
2629
|
process.removeListener("SIGINT", onSignal);
|
|
@@ -2372,7 +2718,7 @@ function printCommandArt(title, subtitle = "remote agent control") {
|
|
|
2372
2718
|
async function connectMachine(opts) {
|
|
2373
2719
|
const portal = opts.portal || DEFAULT_PORTAL;
|
|
2374
2720
|
const hostId = sanitizeHostId(opts.id || inferHostId());
|
|
2375
|
-
const hostName = opts.name ||
|
|
2721
|
+
const hostName = opts.name || os7.hostname();
|
|
2376
2722
|
const command = opts.command || "bash";
|
|
2377
2723
|
if (!opts.token) {
|
|
2378
2724
|
throw new Error("Connection token is required.");
|
|
@@ -2446,12 +2792,12 @@ function registerRunCommand(program2) {
|
|
|
2446
2792
|
}
|
|
2447
2793
|
|
|
2448
2794
|
// src/commands/doctor.ts
|
|
2449
|
-
var
|
|
2795
|
+
var os8 = __toESM(require("os"));
|
|
2450
2796
|
async function runDoctor(opts) {
|
|
2451
2797
|
const options = resolveRunOptions(opts);
|
|
2452
2798
|
const checks = [];
|
|
2453
|
-
const platformOk =
|
|
2454
|
-
checks.push({ name: "platform", ok: platformOk, detail:
|
|
2799
|
+
const platformOk = os8.platform() === "linux";
|
|
2800
|
+
checks.push({ name: "platform", ok: platformOk, detail: os8.platform() });
|
|
2455
2801
|
checks.push({
|
|
2456
2802
|
name: "node",
|
|
2457
2803
|
ok: Number(process.versions.node.split(".")[0]) >= 20,
|