1022 lines
28 KiB
JavaScript
1022 lines
28 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { execFile as execFileCallback } from "node:child_process";
|
|
import { promises as fs } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
|
|
const DEFAULT_VERSION = "0.1.0";
|
|
const DEFAULT_SHELL_COLS = 120;
|
|
const DEFAULT_SHELL_ROWS = 32;
|
|
const DEFAULT_CPU_SAMPLE_DELAY_MS = 150;
|
|
const DEFAULT_SHELL_EVENT_FLUSH_DELAY_MS = 40;
|
|
const execFile = promisify(execFileCallback);
|
|
|
|
function buildTransportHeartbeatState() {
|
|
return {
|
|
status: "ONLINE",
|
|
metadata: {
|
|
command_transport: "API_POLLING",
|
|
shell_transport: "API_POLLING",
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function loadConfig(configPath) {
|
|
const raw = await fs.readFile(configPath, "utf8");
|
|
return JSON.parse(raw);
|
|
}
|
|
|
|
export async function saveConfig(configPath, config) {
|
|
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
|
}
|
|
|
|
export function buildStatusReport(configPath, config) {
|
|
const heartbeatIntervalSeconds = Number(config.heartbeatIntervalSeconds || 15);
|
|
const claimed = Boolean(config.gatewayId && config.agentToken);
|
|
|
|
return {
|
|
command: "status",
|
|
configPath,
|
|
claimed,
|
|
state: claimed ? "CLAIMED" : "PENDING_CLAIM",
|
|
gatewayId: config.gatewayId ?? null,
|
|
apiUrl: config.apiUrl ?? null,
|
|
transport: "API_POLLING",
|
|
hostname: config.hostname || os.hostname(),
|
|
releaseChannel: config.releaseChannel || "stable",
|
|
installedVersion: config.installedVersion || DEFAULT_VERSION,
|
|
targetVersion: config.targetVersion || config.installedVersion || DEFAULT_VERSION,
|
|
heartbeatIntervalSeconds: Number.isFinite(heartbeatIntervalSeconds) && heartbeatIntervalSeconds > 0
|
|
? heartbeatIntervalSeconds
|
|
: 15,
|
|
hasInstallToken: Boolean(config.installToken),
|
|
};
|
|
}
|
|
|
|
export async function getAgentStatus(configPath) {
|
|
if (!configPath) {
|
|
throw new Error("Missing --config path");
|
|
}
|
|
|
|
const config = await loadConfig(configPath);
|
|
return buildStatusReport(configPath, config);
|
|
}
|
|
|
|
function wait(delayMs) {
|
|
return new Promise((resolve) => {
|
|
const timer = setTimeout(resolve, delayMs);
|
|
timer.unref?.();
|
|
});
|
|
}
|
|
|
|
function clampPercentage(value) {
|
|
if (!Number.isFinite(value)) {
|
|
return null;
|
|
}
|
|
|
|
return Math.max(0, Math.min(100, value));
|
|
}
|
|
|
|
function snapshotCpuTimes() {
|
|
return os.cpus().reduce(
|
|
(accumulator, cpu) => {
|
|
const total = Object.values(cpu.times).reduce((sum, current) => sum + current, 0);
|
|
return {
|
|
idle: accumulator.idle + cpu.times.idle,
|
|
total: accumulator.total + total,
|
|
};
|
|
},
|
|
{ idle: 0, total: 0 }
|
|
);
|
|
}
|
|
|
|
function calculateCpuUsagePercent(previousSnapshot, currentSnapshot) {
|
|
if (!previousSnapshot || !currentSnapshot) {
|
|
return null;
|
|
}
|
|
|
|
const totalDelta = currentSnapshot.total - previousSnapshot.total;
|
|
const idleDelta = currentSnapshot.idle - previousSnapshot.idle;
|
|
if (totalDelta <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return clampPercentage(((totalDelta - idleDelta) / totalDelta) * 100);
|
|
}
|
|
|
|
async function sampleCpuUsage(previousSnapshot = null, sleepImpl = wait) {
|
|
if (previousSnapshot) {
|
|
const currentSnapshot = snapshotCpuTimes();
|
|
return {
|
|
snapshot: currentSnapshot,
|
|
usagePct: calculateCpuUsagePercent(previousSnapshot, currentSnapshot),
|
|
};
|
|
}
|
|
|
|
const initialSnapshot = snapshotCpuTimes();
|
|
await sleepImpl(DEFAULT_CPU_SAMPLE_DELAY_MS);
|
|
const sampledSnapshot = snapshotCpuTimes();
|
|
return {
|
|
snapshot: sampledSnapshot,
|
|
usagePct: calculateCpuUsagePercent(initialSnapshot, sampledSnapshot),
|
|
};
|
|
}
|
|
|
|
function buildMemoryMetrics() {
|
|
const totalBytes = os.totalmem();
|
|
const freeBytes = os.freemem();
|
|
const usedBytes = Math.max(0, totalBytes - freeBytes);
|
|
|
|
return {
|
|
memory_total_bytes: totalBytes,
|
|
memory_used_bytes: usedBytes,
|
|
memory_usage_pct: totalBytes > 0 ? clampPercentage((usedBytes / totalBytes) * 100) : null,
|
|
};
|
|
}
|
|
|
|
async function readDiskMetrics(execFileImpl = execFile) {
|
|
if (process.platform === "win32") {
|
|
return {
|
|
disk_total_bytes: null,
|
|
disk_used_bytes: null,
|
|
disk_usage_pct: null,
|
|
disk_mount: null,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const { stdout } = await execFileImpl("df", ["-Pk", "/"], {
|
|
encoding: "utf8",
|
|
windowsHide: true,
|
|
});
|
|
const lines = String(stdout)
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
const dataLine = lines.at(-1);
|
|
if (!dataLine) {
|
|
throw new Error("Missing disk usage output");
|
|
}
|
|
|
|
const segments = dataLine.split(/\s+/);
|
|
if (segments.length < 6) {
|
|
throw new Error("Unexpected disk usage output");
|
|
}
|
|
|
|
const totalBytes = Number(segments[1]) * 1024;
|
|
const usedBytes = Number(segments[2]) * 1024;
|
|
const usagePct = clampPercentage(Number.parseFloat(String(segments[4]).replace("%", "")));
|
|
|
|
return {
|
|
disk_total_bytes: Number.isFinite(totalBytes) ? totalBytes : null,
|
|
disk_used_bytes: Number.isFinite(usedBytes) ? usedBytes : null,
|
|
disk_usage_pct: usagePct,
|
|
disk_mount: segments.slice(5).join(" ") || null,
|
|
};
|
|
} catch {
|
|
return {
|
|
disk_total_bytes: null,
|
|
disk_used_bytes: null,
|
|
disk_usage_pct: null,
|
|
disk_mount: null,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function collectSystemMetrics({
|
|
previousCpuSnapshot = null,
|
|
execFileImpl = execFile,
|
|
sleepImpl = wait,
|
|
latencyMs = null,
|
|
} = {}) {
|
|
const [cpuMetric, diskMetrics] = await Promise.all([
|
|
sampleCpuUsage(previousCpuSnapshot, sleepImpl),
|
|
readDiskMetrics(execFileImpl),
|
|
]);
|
|
|
|
return {
|
|
cpuSnapshot: cpuMetric.snapshot,
|
|
metrics: {
|
|
latency_ms: Number.isFinite(latencyMs) ? Math.max(0, Math.round(latencyMs)) : null,
|
|
cpu_usage_pct: cpuMetric.usagePct,
|
|
cpu_core_count: os.cpus().length,
|
|
load_average_1m: process.platform === "win32" ? null : os.loadavg()[0],
|
|
...buildMemoryMetrics(),
|
|
...diskMetrics,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function apiRequest(baseUrl, path, method = "POST", body = {}, fetchImpl = fetch) {
|
|
const response = await fetchImpl(`${String(baseUrl).replace(/\/$/, "")}${path}`, {
|
|
method,
|
|
headers: {
|
|
"content-type": "application/json",
|
|
},
|
|
body: method === "GET" ? undefined : JSON.stringify(body),
|
|
});
|
|
|
|
const json = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(json?.data?.message || json?.message || `HTTP ${response.status}`);
|
|
}
|
|
|
|
return json.data ?? json;
|
|
}
|
|
|
|
export async function claimIfNeeded(config, configPath, fetchImpl = fetch) {
|
|
if (config.gatewayId && config.agentToken) {
|
|
return config;
|
|
}
|
|
|
|
const claimed = await apiRequest(config.apiUrl, "/edge-agent/claim", "POST", {
|
|
token: config.installToken,
|
|
hostname: config.hostname || os.hostname(),
|
|
installed_version: config.installedVersion || DEFAULT_VERSION,
|
|
metadata: {
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
},
|
|
}, fetchImpl);
|
|
|
|
const nextConfig = {
|
|
...config,
|
|
gatewayId: claimed.gateway.id,
|
|
agentToken: claimed.agent_token,
|
|
releaseChannel: claimed.release_channel || config.releaseChannel || "stable",
|
|
};
|
|
await saveConfig(configPath, nextConfig);
|
|
return nextConfig;
|
|
}
|
|
|
|
async function fetchJson(url, fetchImpl = fetch) {
|
|
const response = await fetchImpl(url);
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
function expandCandidateIps(options = {}) {
|
|
if (Array.isArray(options.candidateIps) && options.candidateIps.length > 0) {
|
|
return options.candidateIps;
|
|
}
|
|
|
|
if (typeof options.subnetPrefix === "string") {
|
|
const start = Number.isInteger(options.startHost) ? options.startHost : 1;
|
|
const end = Number.isInteger(options.endHost) ? options.endHost : 20;
|
|
const ips = [];
|
|
for (let host = start; host <= end; host += 1) {
|
|
ips.push(`${options.subnetPrefix}.${host}`);
|
|
}
|
|
return ips;
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
export async function discoverShellyDevices(options = {}, fetchImpl = fetch) {
|
|
const candidateIps = expandCandidateIps(options);
|
|
const discovered = [];
|
|
|
|
await Promise.all(candidateIps.map(async (ip) => {
|
|
try {
|
|
const identity = await fetchJson(`http://${ip}/shelly`, fetchImpl);
|
|
discovered.push({
|
|
id: identity.mac || identity.id || ip,
|
|
device_id: identity.mac || identity.id || ip,
|
|
local_ip: ip,
|
|
model: identity.model || identity.type || "Shelly",
|
|
channel_count: Number(identity.num_outputs || identity.num_switches || 1),
|
|
online: true,
|
|
capabilities: {
|
|
generation: identity.gen || null,
|
|
},
|
|
metadata: identity,
|
|
});
|
|
} catch {
|
|
// Ignore non-responsive candidates during opportunistic discovery.
|
|
}
|
|
}));
|
|
|
|
return discovered;
|
|
}
|
|
|
|
export async function getRelayStatus(payload, fetchImpl = fetch) {
|
|
const ip = payload.localIp || payload.local_ip || payload.ip;
|
|
const channel = Number.isInteger(payload.channel) ? payload.channel : 0;
|
|
if (!ip) {
|
|
throw new Error("Missing relay local IP");
|
|
}
|
|
|
|
try {
|
|
const rpc = await fetchJson(`http://${ip}/rpc/Switch.GetStatus?id=${channel}`, fetchImpl);
|
|
return {
|
|
online: true,
|
|
on: Boolean(rpc.output),
|
|
raw: rpc,
|
|
};
|
|
} catch {
|
|
const legacy = await fetchJson(`http://${ip}/relay/${channel}`, fetchImpl);
|
|
return {
|
|
online: true,
|
|
on: Boolean(legacy.ison ?? legacy.output),
|
|
raw: legacy,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function setRelayState(payload, fetchImpl = fetch) {
|
|
const ip = payload.localIp || payload.local_ip || payload.ip;
|
|
const channel = Number.isInteger(payload.channel) ? payload.channel : 0;
|
|
const on = Boolean(payload.on);
|
|
if (!ip) {
|
|
throw new Error("Missing relay local IP");
|
|
}
|
|
|
|
try {
|
|
const rpc = await fetchJson(`http://${ip}/rpc/Switch.Set?id=${channel}&on=${on ? "true" : "false"}`, fetchImpl);
|
|
return {
|
|
online: true,
|
|
on: Boolean(rpc.output ?? on),
|
|
raw: rpc,
|
|
};
|
|
} catch {
|
|
const legacy = await fetchJson(`http://${ip}/relay/${channel}?turn=${on ? "on" : "off"}`, fetchImpl);
|
|
return {
|
|
online: true,
|
|
on: Boolean(legacy.ison ?? legacy.output ?? on),
|
|
raw: legacy,
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function runUpdate(payload, fetchImpl = fetch) {
|
|
if (!payload.artifactUrl) {
|
|
return {
|
|
updated: false,
|
|
skipped: true,
|
|
reason: "No artifact URL provided",
|
|
};
|
|
}
|
|
|
|
const response = await fetchImpl(payload.artifactUrl);
|
|
if (!response.ok) {
|
|
throw new Error(`Artifact download failed: HTTP ${response.status}`);
|
|
}
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
|
if (payload.sha256 && String(payload.sha256).toLowerCase() !== sha256.toLowerCase()) {
|
|
throw new Error("Artifact checksum mismatch");
|
|
}
|
|
|
|
return {
|
|
updated: true,
|
|
sha256,
|
|
bytes: buffer.length,
|
|
};
|
|
}
|
|
|
|
function defaultShellCommand() {
|
|
if (process.platform === "win32") {
|
|
return { command: process.env.ComSpec || "cmd.exe", args: [] };
|
|
}
|
|
return { command: process.env.SHELL || "/bin/sh", args: [] };
|
|
}
|
|
|
|
function normalizeShellSize(value, fallback) {
|
|
const numeric = Number(value);
|
|
return Number.isFinite(numeric) && numeric > 0 ? Math.round(numeric) : fallback;
|
|
}
|
|
|
|
async function createDefaultPtyProcess(options = {}) {
|
|
const nodePtyModule = await import("node-pty");
|
|
const spawnPty =
|
|
nodePtyModule.spawn ??
|
|
nodePtyModule.default?.spawn ??
|
|
nodePtyModule.default;
|
|
|
|
if (typeof spawnPty !== "function") {
|
|
throw new Error("node-pty spawn is unavailable");
|
|
}
|
|
|
|
return spawnPty(options.command, options.args || [], {
|
|
name: options.env?.TERM || "xterm-256color",
|
|
cols: normalizeShellSize(options.cols, DEFAULT_SHELL_COLS),
|
|
rows: normalizeShellSize(options.rows, DEFAULT_SHELL_ROWS),
|
|
cwd: options.cwd || process.cwd(),
|
|
env: options.env || process.env,
|
|
});
|
|
}
|
|
|
|
export function createShellBridge(sendMessage, { createPtyProcess = createDefaultPtyProcess } = {}) {
|
|
const sessions = new Map();
|
|
|
|
const open = async (payload = {}) => {
|
|
const sessionId = String(payload.sessionId || "");
|
|
if (sessionId === "") {
|
|
return;
|
|
}
|
|
|
|
const existingSession = sessions.get(sessionId);
|
|
if (existingSession) {
|
|
existingSession.pty.kill();
|
|
sessions.delete(sessionId);
|
|
}
|
|
|
|
const shell = payload.shellCommand
|
|
? { command: payload.shellCommand, args: payload.shellArgs || [] }
|
|
: defaultShellCommand();
|
|
|
|
try {
|
|
const pty = await Promise.resolve(createPtyProcess({
|
|
command: shell.command,
|
|
args: shell.args,
|
|
cwd: payload.cwd || process.cwd(),
|
|
cols: normalizeShellSize(payload.cols, DEFAULT_SHELL_COLS),
|
|
rows: normalizeShellSize(payload.rows, DEFAULT_SHELL_ROWS),
|
|
env: {
|
|
...process.env,
|
|
TERM: payload.term || process.env.TERM || "xterm-256color",
|
|
},
|
|
}));
|
|
|
|
const sessionRecord = {
|
|
pty,
|
|
dataSubscription: null,
|
|
exitSubscription: null,
|
|
};
|
|
|
|
sessionRecord.dataSubscription = pty.onData((data) => {
|
|
sendMessage({ type: "SHELL_OUTPUT", sessionId, data: String(data || "") });
|
|
});
|
|
|
|
sessionRecord.exitSubscription = pty.onExit(({ exitCode }) => {
|
|
sessions.delete(sessionId);
|
|
sessionRecord.dataSubscription?.dispose?.();
|
|
sessionRecord.exitSubscription?.dispose?.();
|
|
sendMessage({ type: "SHELL_EXIT", sessionId, code: Number.isFinite(exitCode) ? exitCode : 0 });
|
|
});
|
|
|
|
sessions.set(sessionId, sessionRecord);
|
|
sendMessage({ type: "SHELL_OPENED", sessionId });
|
|
} catch (error) {
|
|
sendMessage({
|
|
type: "SHELL_OUTPUT",
|
|
sessionId,
|
|
data: `Failed to start root shell: ${error instanceof Error ? error.message : String(error)}\r\n`,
|
|
});
|
|
sendMessage({ type: "SHELL_EXIT", sessionId, code: 1 });
|
|
}
|
|
};
|
|
|
|
const input = (payload = {}) => {
|
|
const sessionRecord = sessions.get(String(payload.sessionId || ""));
|
|
if (!sessionRecord) {
|
|
return;
|
|
}
|
|
sessionRecord.pty.write(String(payload.data || ""));
|
|
};
|
|
|
|
const resize = (payload = {}) => {
|
|
const sessionRecord = sessions.get(String(payload.sessionId || ""));
|
|
if (!sessionRecord) {
|
|
return;
|
|
}
|
|
|
|
sessionRecord.pty.resize(
|
|
normalizeShellSize(payload.cols, DEFAULT_SHELL_COLS),
|
|
normalizeShellSize(payload.rows, DEFAULT_SHELL_ROWS)
|
|
);
|
|
};
|
|
|
|
const close = (payload = {}) => {
|
|
const sessionId = String(payload.sessionId || "");
|
|
const sessionRecord = sessions.get(sessionId);
|
|
if (!sessionRecord) {
|
|
return;
|
|
}
|
|
|
|
sessionRecord.pty.kill();
|
|
};
|
|
|
|
const dispose = () => {
|
|
for (const sessionId of sessions.keys()) {
|
|
close({ sessionId });
|
|
}
|
|
};
|
|
|
|
return { open, input, resize, close, dispose };
|
|
}
|
|
|
|
export async function handleAgentCommand(command, deps = {}) {
|
|
const fetchImpl = deps.fetchImpl || fetch;
|
|
switch (command.commandType) {
|
|
case "DISCOVER_SHELLY":
|
|
return { inventory: await discoverShellyDevices(command.payload || {}, fetchImpl) };
|
|
case "GET_RELAY_STATUS":
|
|
return await getRelayStatus(command.payload || {}, fetchImpl);
|
|
case "SET_RELAY_STATE":
|
|
return await setRelayState(command.payload || {}, fetchImpl);
|
|
case "RUN_UPDATE":
|
|
return await runUpdate(command.payload || {}, fetchImpl);
|
|
case "RESTART_AGENT":
|
|
return { restarted: true };
|
|
case "REBOOT_HOST":
|
|
return { rebooted: true };
|
|
default:
|
|
throw new Error(`Unsupported agent command: ${command.commandType}`);
|
|
}
|
|
}
|
|
|
|
export function buildHeartbeatPayload(config, extra = {}) {
|
|
const payload = {
|
|
hostname: os.hostname(),
|
|
installed_version: config.installedVersion || DEFAULT_VERSION,
|
|
target_version: config.targetVersion || config.installedVersion || DEFAULT_VERSION,
|
|
status: extra.status || "ONLINE",
|
|
metadata: {
|
|
release_channel: config.releaseChannel || "stable",
|
|
...extra.metadata,
|
|
},
|
|
};
|
|
|
|
if (Object.prototype.hasOwnProperty.call(extra, "discovery_status")) {
|
|
payload.discovery_status = extra.discovery_status;
|
|
}
|
|
|
|
if (Array.isArray(extra.inventory)) {
|
|
payload.inventory = extra.inventory;
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
export async function sendHeartbeat(config, fetchImpl = fetch, extra = {}) {
|
|
if (!config.gatewayId || !config.agentToken) {
|
|
throw new Error("Gateway claim must complete before heartbeat");
|
|
}
|
|
return apiRequest(
|
|
config.apiUrl,
|
|
`/edge-agent/gateways/${config.gatewayId}/heartbeat`,
|
|
"POST",
|
|
{
|
|
agent_token: config.agentToken,
|
|
...buildHeartbeatPayload(config, extra),
|
|
},
|
|
fetchImpl
|
|
);
|
|
}
|
|
|
|
async function sendTimedHeartbeat(config, fetchImpl = fetch, extra = {}) {
|
|
const startedAt = Date.now();
|
|
const data = await sendHeartbeat(config, fetchImpl, extra);
|
|
|
|
return {
|
|
data,
|
|
latencyMs: Math.max(0, Date.now() - startedAt),
|
|
};
|
|
}
|
|
|
|
export async function pollCommandJob(config, fetchImpl = fetch, waitSeconds = 20) {
|
|
if (!config.gatewayId || !config.agentToken) {
|
|
throw new Error("Gateway claim must complete before polling commands");
|
|
}
|
|
|
|
return apiRequest(
|
|
config.apiUrl,
|
|
`/edge-agent/gateways/${config.gatewayId}/commands/poll`,
|
|
"POST",
|
|
{
|
|
agent_token: config.agentToken,
|
|
wait_seconds: waitSeconds,
|
|
},
|
|
fetchImpl
|
|
);
|
|
}
|
|
|
|
export async function submitCommandJobResult(config, jobId, { ok, payload = {}, error = null } = {}, fetchImpl = fetch) {
|
|
if (!config.gatewayId || !config.agentToken) {
|
|
throw new Error("Gateway claim must complete before submitting command results");
|
|
}
|
|
|
|
return apiRequest(
|
|
config.apiUrl,
|
|
`/edge-agent/gateways/${config.gatewayId}/commands/${jobId}/result`,
|
|
"POST",
|
|
{
|
|
agent_token: config.agentToken,
|
|
ok: Boolean(ok),
|
|
payload,
|
|
error,
|
|
},
|
|
fetchImpl
|
|
);
|
|
}
|
|
|
|
export async function pollShellActionJob(config, fetchImpl = fetch, waitSeconds = 20) {
|
|
if (!config.gatewayId || !config.agentToken) {
|
|
throw new Error("Gateway claim must complete before polling shell actions");
|
|
}
|
|
|
|
return apiRequest(
|
|
config.apiUrl,
|
|
`/edge-agent/gateways/${config.gatewayId}/shell-actions/poll`,
|
|
"POST",
|
|
{
|
|
agent_token: config.agentToken,
|
|
wait_seconds: waitSeconds,
|
|
},
|
|
fetchImpl
|
|
);
|
|
}
|
|
|
|
export async function submitShellActionJobResult(config, jobId, { ok, error = null } = {}, fetchImpl = fetch) {
|
|
if (!config.gatewayId || !config.agentToken) {
|
|
throw new Error("Gateway claim must complete before submitting shell action results");
|
|
}
|
|
|
|
return apiRequest(
|
|
config.apiUrl,
|
|
`/edge-agent/gateways/${config.gatewayId}/shell-actions/${jobId}/result`,
|
|
"POST",
|
|
{
|
|
agent_token: config.agentToken,
|
|
ok: Boolean(ok),
|
|
error,
|
|
},
|
|
fetchImpl
|
|
);
|
|
}
|
|
|
|
export async function submitShellSessionEvents(config, sessionId, events = [], fetchImpl = fetch) {
|
|
if (!config.gatewayId || !config.agentToken) {
|
|
throw new Error("Gateway claim must complete before submitting shell events");
|
|
}
|
|
|
|
return apiRequest(
|
|
config.apiUrl,
|
|
`/edge-agent/gateways/${config.gatewayId}/shell-sessions/${sessionId}/events`,
|
|
"POST",
|
|
{
|
|
agent_token: config.agentToken,
|
|
events,
|
|
},
|
|
fetchImpl
|
|
);
|
|
}
|
|
|
|
export async function processPolledCommand(config, command, fetchImpl = fetch) {
|
|
const jobId = command?.id;
|
|
const commandType = command?.commandType || command?.command_type;
|
|
const payload = command?.payload || {};
|
|
|
|
if (!jobId || !commandType) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const result = await handleAgentCommand({ commandType, payload }, { fetchImpl });
|
|
await submitCommandJobResult(config, jobId, {
|
|
ok: true,
|
|
payload: result,
|
|
}, fetchImpl);
|
|
return { ok: true, payload: result };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
await submitCommandJobResult(config, jobId, {
|
|
ok: false,
|
|
error: message,
|
|
}, fetchImpl);
|
|
return { ok: false, error: message };
|
|
}
|
|
}
|
|
|
|
function normalizeShellEvent(message) {
|
|
const sessionId = message?.sessionId ?? message?.session_id ?? null;
|
|
if (!sessionId) {
|
|
return null;
|
|
}
|
|
|
|
if (message.type === "SHELL_OPENED") {
|
|
return {
|
|
sessionId: String(sessionId),
|
|
event: {
|
|
type: "OPENED",
|
|
payload: {},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (message.type === "SHELL_OUTPUT") {
|
|
return {
|
|
sessionId: String(sessionId),
|
|
event: {
|
|
type: "OUTPUT",
|
|
payload: {
|
|
data: String(message.data || ""),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (message.type === "SHELL_EXIT") {
|
|
return {
|
|
sessionId: String(sessionId),
|
|
event: {
|
|
type: "CLOSED",
|
|
payload: {
|
|
code: Number.isFinite(message.code) ? message.code : 0,
|
|
reason: "agent_exit",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function createShellEventPublisher(config, fetchImpl = fetch) {
|
|
let queue = [];
|
|
let flushTimer = null;
|
|
let flushPromise = Promise.resolve();
|
|
|
|
const flush = () => {
|
|
const pending = queue;
|
|
queue = [];
|
|
|
|
if (pending.length === 0) {
|
|
return flushPromise;
|
|
}
|
|
|
|
const grouped = new Map();
|
|
for (const item of pending) {
|
|
const normalized = normalizeShellEvent(item);
|
|
if (!normalized) {
|
|
continue;
|
|
}
|
|
|
|
const events = grouped.get(normalized.sessionId) || [];
|
|
events.push(normalized.event);
|
|
grouped.set(normalized.sessionId, events);
|
|
}
|
|
|
|
flushPromise = flushPromise
|
|
.catch(() => {})
|
|
.then(async () => {
|
|
for (const [sessionId, events] of grouped.entries()) {
|
|
await submitShellSessionEvents(config, sessionId, events, fetchImpl);
|
|
}
|
|
});
|
|
|
|
return flushPromise;
|
|
};
|
|
|
|
const scheduleFlush = () => {
|
|
if (flushTimer !== null) {
|
|
return;
|
|
}
|
|
|
|
flushTimer = setTimeout(() => {
|
|
flushTimer = null;
|
|
flush().catch(() => {});
|
|
}, DEFAULT_SHELL_EVENT_FLUSH_DELAY_MS);
|
|
flushTimer.unref?.();
|
|
};
|
|
|
|
return {
|
|
publish(message) {
|
|
queue.push(message);
|
|
if (message?.type === "SHELL_OPENED" || message?.type === "SHELL_EXIT") {
|
|
if (flushTimer !== null) {
|
|
clearTimeout(flushTimer);
|
|
flushTimer = null;
|
|
}
|
|
flush().catch(() => {});
|
|
return;
|
|
}
|
|
|
|
scheduleFlush();
|
|
},
|
|
async drain() {
|
|
if (flushTimer !== null) {
|
|
clearTimeout(flushTimer);
|
|
flushTimer = null;
|
|
}
|
|
await flush().catch(() => {});
|
|
await flushPromise.catch(() => {});
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function processPolledShellAction(config, action, shell, fetchImpl = fetch) {
|
|
const jobId = action?.id;
|
|
const actionType = action?.actionType || action?.action_type;
|
|
const payload = action?.payload || {};
|
|
|
|
if (!jobId || !actionType) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
if (actionType === "OPEN") {
|
|
await shell.open(payload);
|
|
} else if (actionType === "INPUT") {
|
|
shell.input(payload);
|
|
} else if (actionType === "RESIZE") {
|
|
shell.resize(payload);
|
|
} else if (actionType === "CLOSE") {
|
|
shell.close(payload);
|
|
} else {
|
|
throw new Error(`Unsupported shell action: ${actionType}`);
|
|
}
|
|
|
|
await submitShellActionJobResult(config, jobId, {
|
|
ok: true,
|
|
}, fetchImpl);
|
|
return { ok: true };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
await submitShellActionJobResult(config, jobId, {
|
|
ok: false,
|
|
error: message,
|
|
}, fetchImpl);
|
|
return { ok: false, error: message };
|
|
}
|
|
}
|
|
|
|
export async function startAgent({
|
|
configPath,
|
|
fetchImpl = fetch,
|
|
collectMetricsImpl = collectSystemMetrics,
|
|
createShellBridgeImpl = createShellBridge,
|
|
} = {}) {
|
|
if (!configPath) {
|
|
throw new Error("Missing --config path");
|
|
}
|
|
|
|
let config = await loadConfig(configPath);
|
|
config = await claimIfNeeded(config, configPath, fetchImpl);
|
|
const intervalMs = Number(config.heartbeatIntervalSeconds || 15) * 1000;
|
|
const commandPollTimeoutSeconds = Number(config.commandPollTimeoutSeconds || 20);
|
|
const shellActionPollTimeoutSeconds = Number(config.shellActionPollTimeoutSeconds || 20);
|
|
const commandPollRetryDelayMs = Number(config.commandPollRetryDelayMs || 1000);
|
|
const shellActionPollRetryDelayMs = Number(config.shellActionPollRetryDelayMs || 1000);
|
|
|
|
let stopped = false;
|
|
let cpuSnapshot = null;
|
|
let lastHeartbeatLatencyMs = null;
|
|
const shellEventPublisher = createShellEventPublisher(config, fetchImpl);
|
|
const shell = createShellBridgeImpl((message) => {
|
|
shellEventPublisher.publish(message);
|
|
});
|
|
|
|
const sendTransportHeartbeat = async (extra = {}) => {
|
|
const transportState = buildTransportHeartbeatState();
|
|
const collectedMetrics = await collectMetricsImpl({
|
|
previousCpuSnapshot: cpuSnapshot,
|
|
latencyMs: lastHeartbeatLatencyMs,
|
|
});
|
|
cpuSnapshot = collectedMetrics.cpuSnapshot ?? cpuSnapshot;
|
|
|
|
const extraMetadata =
|
|
extra.metadata && typeof extra.metadata === "object" && !Array.isArray(extra.metadata)
|
|
? extra.metadata
|
|
: {};
|
|
const { system_metrics: extraSystemMetrics, ...extraMetadataFields } = extraMetadata;
|
|
const metricPayload = {
|
|
...(collectedMetrics.metrics || {}),
|
|
...(extraSystemMetrics && typeof extraSystemMetrics === "object" ? extraSystemMetrics : {}),
|
|
};
|
|
|
|
const heartbeatPayload = {
|
|
...transportState,
|
|
...extra,
|
|
metadata: {
|
|
...(transportState.metadata || {}),
|
|
...extraMetadataFields,
|
|
system_metrics: metricPayload,
|
|
},
|
|
};
|
|
|
|
const response = await sendTimedHeartbeat(config, fetchImpl, heartbeatPayload);
|
|
lastHeartbeatLatencyMs = response.latencyMs;
|
|
return response.data;
|
|
};
|
|
|
|
const runCommandPollLoop = async () => {
|
|
while (!stopped) {
|
|
try {
|
|
const command = await pollCommandJob(config, fetchImpl, commandPollTimeoutSeconds);
|
|
if (stopped) {
|
|
break;
|
|
}
|
|
|
|
if (!command) {
|
|
continue;
|
|
}
|
|
|
|
await processPolledCommand(config, command, fetchImpl);
|
|
} catch {
|
|
if (stopped) {
|
|
break;
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, commandPollRetryDelayMs));
|
|
}
|
|
}
|
|
};
|
|
|
|
const runShellActionPollLoop = async () => {
|
|
while (!stopped) {
|
|
try {
|
|
const action = await pollShellActionJob(config, fetchImpl, shellActionPollTimeoutSeconds);
|
|
if (stopped) {
|
|
break;
|
|
}
|
|
|
|
if (!action) {
|
|
continue;
|
|
}
|
|
|
|
await processPolledShellAction(config, action, shell, fetchImpl);
|
|
} catch {
|
|
if (stopped) {
|
|
break;
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, shellActionPollRetryDelayMs));
|
|
}
|
|
}
|
|
};
|
|
|
|
await sendTransportHeartbeat();
|
|
const commandPollPromise = runCommandPollLoop();
|
|
const shellActionPollPromise = runShellActionPollLoop();
|
|
|
|
const timer = setInterval(() => {
|
|
sendTransportHeartbeat().catch(() => {});
|
|
}, intervalMs);
|
|
timer.unref?.();
|
|
|
|
const stop = async () => {
|
|
stopped = true;
|
|
clearInterval(timer);
|
|
shell.dispose();
|
|
await shellEventPublisher.drain().catch(() => {});
|
|
await Promise.allSettled([commandPollPromise, shellActionPollPromise]);
|
|
};
|
|
|
|
return {
|
|
commandPollPromise,
|
|
shellActionPollPromise,
|
|
timer,
|
|
config,
|
|
stop,
|
|
};
|
|
}
|
|
|
|
export function parseCliArgs(argv = process.argv.slice(2)) {
|
|
let command = "start";
|
|
let configPath = null;
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "status" && command === "start") {
|
|
command = "status";
|
|
continue;
|
|
}
|
|
if (arg === "--config") {
|
|
configPath = argv[index + 1] || null;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return { command, configPath };
|
|
}
|
|
|
|
export async function runCli(argv = process.argv.slice(2)) {
|
|
const { command, configPath } = parseCliArgs(argv);
|
|
if (command === "status") {
|
|
const report = await getAgentStatus(configPath);
|
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
return report;
|
|
}
|
|
|
|
return startAgent({ configPath });
|
|
}
|
|
|
|
const currentModulePath = fileURLToPath(import.meta.url);
|
|
const invokedModulePath = process.argv[1] ? path.resolve(process.argv[1]) : null;
|
|
|
|
if (invokedModulePath && currentModulePath === invokedModulePath) {
|
|
runCli().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
});
|
|
}
|