[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
361
electron/bridges/systemManager/dockerOps.cjs
Normal file
361
electron/bridges/systemManager/dockerOps.cjs
Normal file
@@ -0,0 +1,361 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
function shQuote(str) {
|
||||
return `'${String(str).replace(/'/g, `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
function sanitizeDockerId(id) {
|
||||
return String(id || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 64);
|
||||
}
|
||||
|
||||
function sanitizeContainerName(name) {
|
||||
const trimmed = String(name || "").trim().slice(0, 128);
|
||||
if (!trimmed) return null;
|
||||
return trimmed.replace(/[^a-zA-Z0-9_.-]/g, "") || null;
|
||||
}
|
||||
|
||||
function sanitizeImageRef(ref) {
|
||||
const trimmed = String(ref || "").trim().slice(0, 256);
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function isSuccessfulCommandResult(result) {
|
||||
return result?.success && (result.code === 0 || result.code === null || result.code === undefined);
|
||||
}
|
||||
|
||||
function dockerCommandError(result, fallback) {
|
||||
return (result?.stderr || result?.error || "").trim() || fallback;
|
||||
}
|
||||
|
||||
function isDockerSocketPermissionError(result) {
|
||||
const text = `${result?.stderr || ""}\n${result?.stdout || ""}\n${result?.error || ""}`.toLowerCase();
|
||||
if (!text.includes("permission denied")) return false;
|
||||
return text.includes("docker daemon")
|
||||
|| text.includes("docker.sock")
|
||||
|| text.includes("/var/run/docker.sock")
|
||||
|| text.includes("connect to the docker daemon");
|
||||
}
|
||||
|
||||
function getSessionSudoPassword(session) {
|
||||
return typeof session?.systemManagerSudoPassword === "string" && session.systemManagerSudoPassword.length > 0
|
||||
? session.systemManagerSudoPassword
|
||||
: null;
|
||||
}
|
||||
|
||||
function buildDockerCommand(args) {
|
||||
return `docker ${args}`.trim();
|
||||
}
|
||||
|
||||
function buildPasswordlessSudoDockerCommand(args) {
|
||||
return `sudo ${buildDockerCommand(args)}`;
|
||||
}
|
||||
|
||||
function buildSudoDockerCommand(args) {
|
||||
return `sudo -S -p '' ${buildDockerCommand(args)}`;
|
||||
}
|
||||
|
||||
function parseDockerContainers(stdout) {
|
||||
const containers = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const row = JSON.parse(trimmed);
|
||||
containers.push({
|
||||
id: row.ID || row.Id || "",
|
||||
name: (row.Names || row.Name || "").replace(/^\//, ""),
|
||||
image: row.Image || "",
|
||||
status: row.Status || row.State || "",
|
||||
state: row.State || "",
|
||||
ports: row.Ports || "",
|
||||
createdAt: row.CreatedAt || row.Created || "",
|
||||
});
|
||||
} catch {
|
||||
// skip malformed line
|
||||
}
|
||||
}
|
||||
return containers;
|
||||
}
|
||||
|
||||
function parseDockerStats(stdout) {
|
||||
const stats = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const row = JSON.parse(trimmed);
|
||||
stats.push({
|
||||
id: row.ID || row.Container || "",
|
||||
name: row.Name || "",
|
||||
cpuPercent: parseFloat(String(row.CPUPerc || "0").replace("%", "")) || 0,
|
||||
memUsage: row.MemUsage || "",
|
||||
memPercent: parseFloat(String(row.MemPerc || "0").replace("%", "")) || 0,
|
||||
netIO: row.NetIO || "",
|
||||
blockIO: row.BlockIO || "",
|
||||
pids: Number(row.PIDs || row.Pids || 0) || 0,
|
||||
});
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
function parseDockerImages(stdout) {
|
||||
const images = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const row = JSON.parse(trimmed);
|
||||
const repository = row.Repository || "";
|
||||
const tag = row.Tag || "";
|
||||
images.push({
|
||||
id: row.ID || row.Id || "",
|
||||
repository,
|
||||
tag,
|
||||
size: row.Size || "",
|
||||
createdAt: row.CreatedAt || row.CreatedSince || "",
|
||||
digest: row.Digest || "",
|
||||
name: repository && tag ? `${repository}:${tag}` : repository || tag || row.ID || "",
|
||||
});
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
function summarizeImageInspect(info) {
|
||||
if (!info) return null;
|
||||
return {
|
||||
id: info.Id,
|
||||
repoTags: info.RepoTags,
|
||||
repoDigests: info.RepoDigests,
|
||||
created: info.Created,
|
||||
size: info.Size,
|
||||
architecture: info.Architecture,
|
||||
os: info.Os,
|
||||
config: {
|
||||
env: info.Config?.Env,
|
||||
cmd: info.Config?.Cmd,
|
||||
entrypoint: info.Config?.Entrypoint,
|
||||
workingDir: info.Config?.WorkingDir,
|
||||
exposedPorts: info.Config?.ExposedPorts,
|
||||
labels: info.Config?.Labels,
|
||||
},
|
||||
rootfs: info.RootFS,
|
||||
history: Array.isArray(info.History) ? info.History.slice(0, 5) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeContainerInspect(info) {
|
||||
if (!info) return null;
|
||||
return {
|
||||
id: info.Id,
|
||||
name: info.Name,
|
||||
image: info.Config?.Image,
|
||||
state: info.State,
|
||||
network: info.NetworkSettings,
|
||||
mounts: info.Mounts,
|
||||
env: info.Config?.Env,
|
||||
labels: info.Config?.Labels,
|
||||
created: info.Created,
|
||||
path: info.Path,
|
||||
args: info.Args,
|
||||
restartPolicy: info.HostConfig?.RestartPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
function createDockerOpsApi({ execOnSession, getSession }) {
|
||||
async function runDocker(event, sessionId, args, timeoutMs = 15000) {
|
||||
const cmd = buildDockerCommand(args);
|
||||
const result = await execOnSession(event, sessionId, cmd, timeoutMs);
|
||||
if (isSuccessfulCommandResult(result)) return result;
|
||||
|
||||
if (isDockerSocketPermissionError(result)) {
|
||||
const sudoPassword = getSessionSudoPassword(getSession?.(sessionId));
|
||||
let lastSudoResult = null;
|
||||
|
||||
const nopasswdResult = await execOnSession(
|
||||
event,
|
||||
sessionId,
|
||||
buildPasswordlessSudoDockerCommand(args),
|
||||
timeoutMs,
|
||||
);
|
||||
if (isSuccessfulCommandResult(nopasswdResult)) return nopasswdResult;
|
||||
lastSudoResult = nopasswdResult;
|
||||
|
||||
if (sudoPassword) {
|
||||
const sudoResult = await execOnSession(
|
||||
event,
|
||||
sessionId,
|
||||
buildSudoDockerCommand(args),
|
||||
timeoutMs,
|
||||
{ stdin: `${sudoPassword}\n` },
|
||||
);
|
||||
if (isSuccessfulCommandResult(sudoResult)) return sudoResult;
|
||||
lastSudoResult = sudoResult;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: dockerCommandError(lastSudoResult, `sudo docker exited with code ${lastSudoResult?.code}`),
|
||||
stderr: lastSudoResult?.stderr,
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.success) return result;
|
||||
if (result.code !== 0 && result.code !== null && result.code !== undefined) {
|
||||
return {
|
||||
success: false,
|
||||
error: dockerCommandError(result, `docker exited with code ${result.code}`),
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function listContainers(event, sessionId) {
|
||||
const result = await runDocker(event, sessionId, 'ps -a --format "{{json .}}"', 12000);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
return { success: true, containers: parseDockerContainers(result.stdout) };
|
||||
}
|
||||
|
||||
async function listImages(event, sessionId) {
|
||||
const result = await runDocker(event, sessionId, 'images --format "{{json .}}"', 12000);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
return { success: true, images: parseDockerImages(result.stdout) };
|
||||
}
|
||||
|
||||
async function getStats(event, payload) {
|
||||
const sessionId = payload?.sessionId;
|
||||
if (!sessionId) return { success: false, error: "Missing sessionId" };
|
||||
const ids = Array.isArray(payload?.ids) ? payload.ids.filter(Boolean) : [];
|
||||
const idArg = ids.map((id) => sanitizeDockerId(id)).filter(Boolean).join(" ");
|
||||
const result = await runDocker(
|
||||
event,
|
||||
sessionId,
|
||||
`stats --no-stream --format "{{json .}}" ${idArg}`.trim(),
|
||||
15000,
|
||||
);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
return { success: true, stats: parseDockerStats(result.stdout) };
|
||||
}
|
||||
|
||||
async function inspectContainer(event, payload) {
|
||||
const { sessionId, containerId } = payload || {};
|
||||
if (!sessionId || !containerId) return { success: false, error: "Missing params" };
|
||||
const safeId = sanitizeDockerId(containerId);
|
||||
const result = await runDocker(event, sessionId, `inspect ${safeId}`, 10000);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout || "[]");
|
||||
const info = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
return { success: true, inspect: summarizeContainerInspect(info) };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to parse inspect output" };
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectImage(event, payload) {
|
||||
const { sessionId, imageId } = payload || {};
|
||||
if (!sessionId || !imageId) return { success: false, error: "Missing params" };
|
||||
const safeId = sanitizeDockerId(imageId);
|
||||
const result = await runDocker(event, sessionId, `image inspect ${safeId}`, 10000);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout || "[]");
|
||||
const info = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
return { success: true, inspect: summarizeImageInspect(info) };
|
||||
} catch {
|
||||
return { success: false, error: "Failed to parse image inspect output" };
|
||||
}
|
||||
}
|
||||
|
||||
async function containerAction(event, payload) {
|
||||
const { sessionId, containerId, action, newName } = payload || {};
|
||||
if (!sessionId || !containerId || !action) return { success: false, error: "Missing params" };
|
||||
const safeId = sanitizeDockerId(containerId);
|
||||
|
||||
switch (action) {
|
||||
case "start":
|
||||
return runDocker(event, sessionId, `start ${safeId}`);
|
||||
case "stop":
|
||||
return runDocker(event, sessionId, `stop ${safeId}`);
|
||||
case "restart":
|
||||
return runDocker(event, sessionId, `restart ${safeId}`);
|
||||
case "rm":
|
||||
return runDocker(event, sessionId, `rm -f ${safeId}`);
|
||||
case "pause":
|
||||
return runDocker(event, sessionId, `pause ${safeId}`);
|
||||
case "unpause":
|
||||
return runDocker(event, sessionId, `unpause ${safeId}`);
|
||||
case "kill":
|
||||
return runDocker(event, sessionId, `kill ${safeId}`);
|
||||
case "rename": {
|
||||
const next = sanitizeContainerName(newName);
|
||||
if (!next) return { success: false, error: "Invalid container name" };
|
||||
return runDocker(event, sessionId, `rename ${safeId} ${shQuote(next)}`);
|
||||
}
|
||||
default:
|
||||
return { success: false, error: `Invalid container action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function imageAction(event, payload) {
|
||||
const { sessionId, action, imageRef, imageId, force, all, repository, tag } = payload || {};
|
||||
if (!sessionId || !action) return { success: false, error: "Missing params" };
|
||||
|
||||
switch (action) {
|
||||
case "pull": {
|
||||
const ref = sanitizeImageRef(imageRef);
|
||||
if (!ref) return { success: false, error: "Missing image reference" };
|
||||
return runDocker(event, sessionId, `pull ${shQuote(ref)}`, 600000);
|
||||
}
|
||||
case "rm": {
|
||||
const safeId = sanitizeDockerId(imageId);
|
||||
if (!safeId) return { success: false, error: "Missing image id" };
|
||||
const forceFlag = force ? " -f" : "";
|
||||
return runDocker(event, sessionId, `rmi${forceFlag} ${safeId}`);
|
||||
}
|
||||
case "prune": {
|
||||
const allFlag = all ? " -a" : "";
|
||||
return runDocker(event, sessionId, `image prune${allFlag} -f`, 120000);
|
||||
}
|
||||
case "tag": {
|
||||
const safeId = sanitizeDockerId(imageId);
|
||||
const repo = sanitizeImageRef(repository);
|
||||
const tagName = String(tag || "").trim().slice(0, 128) || "latest";
|
||||
if (!safeId || !repo) return { success: false, error: "Missing params" };
|
||||
return runDocker(
|
||||
event,
|
||||
sessionId,
|
||||
`tag ${safeId} ${shQuote(`${repo}:${tagName}`)}`,
|
||||
);
|
||||
}
|
||||
default:
|
||||
return { success: false, error: `Invalid image action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
listContainers,
|
||||
listImages,
|
||||
getStats,
|
||||
inspectContainer,
|
||||
inspectImage,
|
||||
containerAction,
|
||||
imageAction,
|
||||
parseDockerContainers,
|
||||
parseDockerStats,
|
||||
parseDockerImages,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createDockerOpsApi,
|
||||
parseDockerContainers,
|
||||
parseDockerStats,
|
||||
parseDockerImages,
|
||||
};
|
||||
234
electron/bridges/systemManager/dockerOps.test.cjs
Normal file
234
electron/bridges/systemManager/dockerOps.test.cjs
Normal file
@@ -0,0 +1,234 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { createDockerOpsApi } = require("./dockerOps.cjs");
|
||||
|
||||
test("listContainers uses plain docker first even when a saved session password exists", async () => {
|
||||
const calls = [];
|
||||
const dockerOps = createDockerOpsApi({
|
||||
getSession: () => ({ systemManagerSudoPassword: "host-secret" }),
|
||||
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
|
||||
calls.push({ sessionId, command, timeoutMs, execOptions });
|
||||
return {
|
||||
success: true,
|
||||
stdout: '{"ID":"abc123","Names":"web","Image":"nginx","State":"running"}\n',
|
||||
stderr: "",
|
||||
code: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dockerOps.listContainers(null, "s1");
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.containers.length, 1);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(
|
||||
calls[0].command,
|
||||
"docker ps -a --format '{{json .}}'",
|
||||
);
|
||||
assert.equal(calls[0].execOptions, undefined);
|
||||
});
|
||||
|
||||
test("listContainers falls back to sudo when plain docker hits socket permission denial", async () => {
|
||||
const calls = [];
|
||||
const dockerOps = createDockerOpsApi({
|
||||
getSession: () => ({ systemManagerSudoPassword: "host-secret" }),
|
||||
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
|
||||
calls.push({ sessionId, command, timeoutMs, execOptions });
|
||||
if (calls.length === 1) {
|
||||
return {
|
||||
success: true,
|
||||
stdout: "",
|
||||
stderr: "permission denied while trying to connect to the Docker daemon socket",
|
||||
code: 1,
|
||||
};
|
||||
}
|
||||
if (calls.length === 2) {
|
||||
return {
|
||||
success: true,
|
||||
stdout: "",
|
||||
stderr: "sudo: a password is required",
|
||||
code: 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
stdout: '{"ID":"abc123","Names":"web","Image":"nginx","State":"running"}\n',
|
||||
stderr: "",
|
||||
code: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dockerOps.listContainers(null, "s1");
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.containers.length, 1);
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(calls[0].command, "docker ps -a --format '{{json .}}'");
|
||||
assert.equal(calls[0].execOptions, undefined);
|
||||
assert.equal(
|
||||
calls[1].command,
|
||||
"sudo docker ps -a --format '{{json .}}'",
|
||||
);
|
||||
assert.equal(calls[1].execOptions, undefined);
|
||||
assert.equal(
|
||||
calls[2].command,
|
||||
"sudo -S -p '' docker ps -a --format '{{json .}}'",
|
||||
);
|
||||
assert.deepEqual(calls[2].execOptions, { stdin: "host-secret\n" });
|
||||
});
|
||||
|
||||
test("listContainers falls back to passwordless sudo when no saved password exists", async () => {
|
||||
const calls = [];
|
||||
const dockerOps = createDockerOpsApi({
|
||||
getSession: () => ({}),
|
||||
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
|
||||
calls.push({ sessionId, command, timeoutMs, execOptions });
|
||||
if (calls.length === 1) {
|
||||
return {
|
||||
success: true,
|
||||
stdout: "",
|
||||
stderr: "Got permission denied while trying to connect to the Docker daemon socket",
|
||||
code: 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
stdout: '{"ID":"abc123","Names":"web","Image":"nginx","State":"running"}\n',
|
||||
stderr: "",
|
||||
code: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dockerOps.listContainers(null, "s1");
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.containers.length, 1);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0].command, "docker ps -a --format '{{json .}}'");
|
||||
assert.equal(
|
||||
calls[1].command,
|
||||
"sudo docker ps -a --format '{{json .}}'",
|
||||
);
|
||||
assert.equal(calls[1].execOptions, undefined);
|
||||
});
|
||||
|
||||
test("listContainers does not retry with transport auth passwords that were not saved for sudo autofill", async () => {
|
||||
const calls = [];
|
||||
const dockerOps = createDockerOpsApi({
|
||||
getSession: () => ({
|
||||
moshStatsAuth: { password: "interactive-mosh-password" },
|
||||
etStatsAuth: { password: "interactive-et-password" },
|
||||
}),
|
||||
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
|
||||
calls.push({ sessionId, command, timeoutMs, execOptions });
|
||||
return {
|
||||
success: true,
|
||||
stdout: "",
|
||||
stderr: "permission denied while trying to connect to the Docker daemon socket",
|
||||
code: 1,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dockerOps.listContainers(null, "s1");
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error, /permission denied/i);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(
|
||||
calls[1].command,
|
||||
"sudo docker ps -a --format '{{json .}}'",
|
||||
);
|
||||
assert.equal(calls[1].execOptions, undefined);
|
||||
});
|
||||
|
||||
test("listContainers retries with explicit sudo autofill password on mosh or et sessions", async () => {
|
||||
const calls = [];
|
||||
const dockerOps = createDockerOpsApi({
|
||||
getSession: () => ({
|
||||
systemManagerSudoPassword: "saved-secret",
|
||||
moshStatsAuth: { password: "transport-secret" },
|
||||
}),
|
||||
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
|
||||
calls.push({ sessionId, command, timeoutMs, execOptions });
|
||||
if (calls.length === 1) {
|
||||
return {
|
||||
success: true,
|
||||
stdout: "",
|
||||
stderr: "dial unix /var/run/docker.sock: connect: permission denied",
|
||||
code: 1,
|
||||
};
|
||||
}
|
||||
if (calls.length === 2) {
|
||||
return {
|
||||
success: true,
|
||||
stdout: "",
|
||||
stderr: "sudo: a password is required",
|
||||
code: 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
stdout: '{"ID":"abc123","Names":"web","Image":"nginx","State":"running"}\n',
|
||||
stderr: "",
|
||||
code: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dockerOps.listContainers(null, "s1");
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(
|
||||
calls[2].command,
|
||||
"sudo -S -p '' docker ps -a --format '{{json .}}'",
|
||||
);
|
||||
assert.deepEqual(calls[2].execOptions, { stdin: "saved-secret\n" });
|
||||
});
|
||||
|
||||
test("docker image actions retry with sudo and send saved passwords through stdin", async () => {
|
||||
const calls = [];
|
||||
const dockerOps = createDockerOpsApi({
|
||||
getSession: () => ({ systemManagerSudoPassword: "pa'ss" }),
|
||||
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
|
||||
calls.push({ sessionId, command, timeoutMs, execOptions });
|
||||
if (calls.length === 1) {
|
||||
return {
|
||||
success: true,
|
||||
stdout: "",
|
||||
stderr: "dial unix /var/run/docker.sock: connect: permission denied",
|
||||
code: 1,
|
||||
};
|
||||
}
|
||||
if (calls.length === 2) {
|
||||
return {
|
||||
success: true,
|
||||
stdout: "",
|
||||
stderr: "sudo: a password is required",
|
||||
code: 1,
|
||||
};
|
||||
}
|
||||
return { success: true, stdout: "deleted\n", stderr: "", code: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await dockerOps.imageAction(null, {
|
||||
sessionId: "s1",
|
||||
action: "rm",
|
||||
imageId: "sha256:abc123",
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(
|
||||
calls[2].command,
|
||||
"sudo -S -p '' docker rmi sha256abc123",
|
||||
);
|
||||
assert.deepEqual(calls[2].execOptions, { stdin: "pa'ss\n" });
|
||||
});
|
||||
27
electron/bridges/systemManager/execConnHealth.cjs
Normal file
27
electron/bridges/systemManager/execConnHealth.cjs
Normal file
@@ -0,0 +1,27 @@
|
||||
"use strict";
|
||||
|
||||
/** Best-effort check that an ssh2 Client transport is still usable. */
|
||||
function isSshConnAlive(conn) {
|
||||
if (!conn) return false;
|
||||
const sock = conn._sock;
|
||||
if (sock && sock.destroyed) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True when conn.exec failed because the underlying transport/channel is gone. */
|
||||
function isTransportExecError(message) {
|
||||
const msg = String(message || "").toLowerCase();
|
||||
return (
|
||||
msg.includes("not connected")
|
||||
|| msg.includes("connection lost")
|
||||
|| msg.includes("socket hang up")
|
||||
|| msg.includes("econnreset")
|
||||
|| msg.includes("closed")
|
||||
|| msg.includes("destroyed")
|
||||
|| msg.includes("channel open failure")
|
||||
|| msg.includes("unable to exec")
|
||||
|| msg.includes("no response")
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { isSshConnAlive, isTransportExecError };
|
||||
33
electron/bridges/systemManager/execConnHealth.test.cjs
Normal file
33
electron/bridges/systemManager/execConnHealth.test.cjs
Normal file
@@ -0,0 +1,33 @@
|
||||
"use strict";
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { isSshConnAlive, isTransportExecError } = require("./execConnHealth.cjs");
|
||||
|
||||
describe("isSshConnAlive", () => {
|
||||
it("returns false for missing conn", () => {
|
||||
assert.equal(isSshConnAlive(null), false);
|
||||
});
|
||||
|
||||
it("returns false when socket is destroyed", () => {
|
||||
assert.equal(isSshConnAlive({ _sock: { destroyed: true } }), false);
|
||||
});
|
||||
|
||||
it("returns true when socket is alive", () => {
|
||||
assert.equal(isSshConnAlive({ _sock: { destroyed: false } }), true);
|
||||
assert.equal(isSshConnAlive({}), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTransportExecError", () => {
|
||||
it("detects common ssh2 transport failures", () => {
|
||||
assert.equal(isTransportExecError("Not connected"), true);
|
||||
assert.equal(isTransportExecError("Channel open failure: open failed"), true);
|
||||
assert.equal(isTransportExecError("read ECONNRESET"), true);
|
||||
});
|
||||
|
||||
it("ignores unrelated command errors", () => {
|
||||
assert.equal(isTransportExecError("docker: no such container"), false);
|
||||
assert.equal(isTransportExecError("permission denied"), false);
|
||||
});
|
||||
});
|
||||
234
electron/bridges/systemManager/execOnSession.cjs
Normal file
234
electron/bridges/systemManager/execOnSession.cjs
Normal file
@@ -0,0 +1,234 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
const { isSshConnAlive, isTransportExecError } = require("./execConnHealth.cjs");
|
||||
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
||||
|
||||
function createExecOnSessionApi(ctx) {
|
||||
with (ctx) {
|
||||
const DEFAULT_LOCAL_EXEC_MAX_BUFFER = 10 * 1024 * 1024;
|
||||
|
||||
function normalizeExecMaxBuffer(value, fallback = DEFAULT_LOCAL_EXEC_MAX_BUFFER) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : fallback;
|
||||
}
|
||||
|
||||
function isExecMaxBufferError(err) {
|
||||
const code = String(err?.code || "");
|
||||
const message = String(err?.message || "");
|
||||
return code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || /maxBuffer/i.test(message);
|
||||
}
|
||||
|
||||
/** Serialize remote exec per session to avoid SSH channel storms. */
|
||||
const execQueues = new Map();
|
||||
|
||||
function getSession(sessionId) {
|
||||
return sessions?.get?.(sessionId) ?? null;
|
||||
}
|
||||
|
||||
function enqueueExec(sessionId, task) {
|
||||
let state = execQueues.get(sessionId);
|
||||
if (!state) {
|
||||
state = { running: false, pending: [] };
|
||||
execQueues.set(sessionId, state);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
state.pending.push({ task, resolve });
|
||||
void drainExecQueue(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
async function drainExecQueue(sessionId) {
|
||||
const state = execQueues.get(sessionId);
|
||||
if (!state || state.running) return;
|
||||
state.running = true;
|
||||
while (state.pending.length > 0) {
|
||||
const job = state.pending.shift();
|
||||
if (!job) continue;
|
||||
try {
|
||||
const result = await job.task();
|
||||
job.resolve(result);
|
||||
} catch (err) {
|
||||
job.resolve({ success: false, error: err?.message || String(err) });
|
||||
}
|
||||
}
|
||||
state.running = false;
|
||||
if (state.pending.length === 0) {
|
||||
execQueues.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureMoshCompanion(session, sessionId, event) {
|
||||
if (session?.type !== "mosh" || typeof ensureMoshStatsConnection !== "function") {
|
||||
return;
|
||||
}
|
||||
if (session.moshStatsConn && isSshConnAlive(session.moshStatsConn)) {
|
||||
return;
|
||||
}
|
||||
if (session.moshStatsConn && !isSshConnAlive(session.moshStatsConn)) {
|
||||
session.moshStatsConn = null;
|
||||
}
|
||||
if (!session.moshStatsConn && !session.moshStatsConnFailed) {
|
||||
await ensureMoshStatsConnection(session, sessionId, event?.sender);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveExecConnection(session, sessionId, event) {
|
||||
if (!session) return null;
|
||||
|
||||
await ensureMoshCompanion(session, sessionId, event);
|
||||
|
||||
const conn = session.conn || session.moshStatsConn;
|
||||
if (!conn) return null;
|
||||
|
||||
if (!isSshConnAlive(conn)) {
|
||||
if (session.moshStatsConn === conn) {
|
||||
session.moshStatsConn = null;
|
||||
await ensureMoshCompanion(session, sessionId, event);
|
||||
return session.conn || session.moshStatsConn;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return conn;
|
||||
}
|
||||
|
||||
function execOnConnection(conn, command, timeoutMs, execOptions = {}) {
|
||||
const maxBuffer = normalizeExecMaxBuffer(execOptions.maxBuffer);
|
||||
return executeBoundedSshCommand(conn, command, {
|
||||
openingTimeoutMs: timeoutMs,
|
||||
runTimeoutMs: timeoutMs,
|
||||
maxOutputBytes: maxBuffer,
|
||||
onStream(stream) {
|
||||
if (typeof execOptions.stdin === "string") {
|
||||
stream.write(execOptions.stdin);
|
||||
stream.end();
|
||||
}
|
||||
},
|
||||
}).then(
|
||||
({ stdout, stderr, code }) => ({
|
||||
success: true,
|
||||
stdout,
|
||||
stderr,
|
||||
code: code ?? 0,
|
||||
}),
|
||||
(error) => ({
|
||||
success: false,
|
||||
error: error?.code === "SSH_EXEC_OUTPUT_LIMIT"
|
||||
? "SSH command maxBuffer exceeded"
|
||||
: error?.message || String(error),
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function execOnSshSession(session, sessionId, command, timeoutMs, event, execOptions = {}, allowCompanionRetry = true) {
|
||||
if (session?.type === "et") {
|
||||
if (typeof execOnEtSession !== "function") {
|
||||
return { success: false, error: "ET command executor unavailable" };
|
||||
}
|
||||
return execOnEtSession(session, command, timeoutMs, {
|
||||
requireTrustedHost: true,
|
||||
knownHosts: session.etStatsAuth?.knownHosts,
|
||||
stdin: execOptions.stdin,
|
||||
maxBuffer: execOptions.maxBuffer,
|
||||
});
|
||||
}
|
||||
|
||||
const conn = await resolveExecConnection(session, sessionId, event);
|
||||
if (!conn) {
|
||||
if (session?.type === "mosh" && !session.moshStatsAuth && !session.moshStatsConnFailed) {
|
||||
return { success: false, pending: true, error: "Mosh handshake in progress" };
|
||||
}
|
||||
return { success: false, error: "Session not found or not connected" };
|
||||
}
|
||||
|
||||
const result = await execOnConnection(conn, command, timeoutMs, execOptions);
|
||||
if (
|
||||
allowCompanionRetry
|
||||
&& !result.success
|
||||
&& session.moshStatsConn
|
||||
&& isTransportExecError(result.error)
|
||||
) {
|
||||
session.moshStatsConn = null;
|
||||
return execOnSshSession(session, sessionId, command, timeoutMs, event, execOptions, false);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function execOnLocalMachine(command, timeoutMs, execOptions = {}) {
|
||||
const { execFile } = require("node:child_process");
|
||||
const platform = process.platform;
|
||||
|
||||
if (platform === "win32") {
|
||||
return new Promise((resolve) => {
|
||||
const child = execFile(
|
||||
"powershell.exe",
|
||||
["-NoProfile", "-NonInteractive", "-Command", command],
|
||||
{ timeout: timeoutMs, maxBuffer: normalizeExecMaxBuffer(execOptions.maxBuffer) },
|
||||
(err, stdout, stderr) => {
|
||||
if (err && (isExecMaxBufferError(err) || !stdout)) {
|
||||
resolve({ success: false, error: err.message || String(err), stdout: "", stderr: String(stderr || "") });
|
||||
return;
|
||||
}
|
||||
resolve({ success: true, stdout: String(stdout || ""), stderr: String(stderr || ""), code: err?.code ?? 0 });
|
||||
},
|
||||
);
|
||||
if (typeof execOptions.stdin === "string") {
|
||||
child.stdin?.end(execOptions.stdin);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = execFile(
|
||||
"sh",
|
||||
["-c", command],
|
||||
{ timeout: timeoutMs, maxBuffer: normalizeExecMaxBuffer(execOptions.maxBuffer) },
|
||||
(err, stdout, stderr) => {
|
||||
if (err && (isExecMaxBufferError(err) || !stdout)) {
|
||||
resolve({ success: false, error: err.message || String(err), stdout: "", stderr: String(stderr || "") });
|
||||
return;
|
||||
}
|
||||
resolve({ success: true, stdout: String(stdout || ""), stderr: String(stderr || ""), code: err?.code ?? 0 });
|
||||
},
|
||||
);
|
||||
if (typeof execOptions.stdin === "string") {
|
||||
child.stdin?.end(execOptions.stdin);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function execOnSessionInner(event, sessionId, command, timeoutMs = 8000, execOptions = {}) {
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
execQueues.delete(sessionId);
|
||||
return { success: false, error: "Session not found" };
|
||||
}
|
||||
|
||||
if (session.protocol === "local" || session.type === "local") {
|
||||
return execOnLocalMachine(command, timeoutMs, execOptions);
|
||||
}
|
||||
|
||||
if (session.conn || session.type === "mosh" || session.type === "et") {
|
||||
return execOnSshSession(session, sessionId, command, timeoutMs, event, execOptions);
|
||||
}
|
||||
|
||||
return { success: false, error: "Session not supported for system management" };
|
||||
}
|
||||
|
||||
async function execOnSession(event, sessionId, command, timeoutMs = 8000, execOptions = {}) {
|
||||
return enqueueExec(sessionId, () => execOnSessionInner(event, sessionId, command, timeoutMs, execOptions));
|
||||
}
|
||||
|
||||
function isLocalSession(sessionId) {
|
||||
const session = getSession(sessionId);
|
||||
return !!(session?.protocol === "local" || session?.type === "local");
|
||||
}
|
||||
|
||||
return { execOnSession, execOnLocalMachine, isLocalSession, getSession };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createExecOnSessionApi };
|
||||
122
electron/bridges/systemManager/execOnSession.stdin.test.cjs
Normal file
122
electron/bridges/systemManager/execOnSession.stdin.test.cjs
Normal file
@@ -0,0 +1,122 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { createExecOnSessionApi } = require("./execOnSession.cjs");
|
||||
|
||||
test("execOnSession closes ssh exec stdin after writing provided input", async () => {
|
||||
const writes = [];
|
||||
let ended = false;
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.write = (data) => {
|
||||
writes.push(data);
|
||||
return true;
|
||||
};
|
||||
stream.end = () => {
|
||||
ended = true;
|
||||
};
|
||||
|
||||
const conn = {
|
||||
exec(_command, callback) {
|
||||
callback(null, stream);
|
||||
process.nextTick(() => stream.emit("close", 0));
|
||||
},
|
||||
};
|
||||
const execApi = createExecOnSessionApi({
|
||||
sessions: { get: () => ({ conn, type: "ssh" }) },
|
||||
});
|
||||
|
||||
const result = await execApi.execOnSession(null, "s1", "sudo -S -p '' docker ps", 1000, {
|
||||
stdin: "secret\n",
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.deepEqual(writes, ["secret\n"]);
|
||||
assert.equal(ended, true);
|
||||
});
|
||||
|
||||
test("execOnSession reports local maxBuffer errors instead of returning truncated stdout", async () => {
|
||||
const execApi = createExecOnSessionApi({
|
||||
sessions: { get: () => ({ type: "local", protocol: "local" }) },
|
||||
process,
|
||||
});
|
||||
|
||||
const result = await execApi.execOnSession(null, "local", "yes x | head -c 2048", 1000, {
|
||||
maxBuffer: 128,
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error, /maxBuffer|stdout maxBuffer/i);
|
||||
});
|
||||
|
||||
test("execOnSession enforces maxBuffer for SSH streamed stdout", async () => {
|
||||
let closed = false;
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.close = () => {
|
||||
closed = true;
|
||||
};
|
||||
|
||||
const conn = {
|
||||
exec(_command, callback) {
|
||||
callback(null, stream);
|
||||
process.nextTick(() => {
|
||||
stream.emit("data", Buffer.from("x".repeat(256)));
|
||||
stream.emit("close", 0);
|
||||
});
|
||||
},
|
||||
};
|
||||
const execApi = createExecOnSessionApi({
|
||||
sessions: { get: () => ({ conn, type: "ssh" }) },
|
||||
});
|
||||
|
||||
const result = await execApi.execOnSession(null, "s1", "ps", 1000, {
|
||||
maxBuffer: 128,
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error, /maxBuffer/i);
|
||||
assert.equal(result.stdout, "");
|
||||
assert.equal(closed, true);
|
||||
});
|
||||
|
||||
test("execOnSession closes an SSH stream that arrives after the open timeout", async () => {
|
||||
let callback;
|
||||
const conn = { exec(_command, next) { callback = next; } };
|
||||
const execApi = createExecOnSessionApi({
|
||||
sessions: { get: () => ({ conn, type: "ssh" }) },
|
||||
});
|
||||
|
||||
const result = await execApi.execOnSession(null, "s1", "pending", 5);
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error, /open timed out/);
|
||||
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
let closed = 0;
|
||||
stream.close = () => { closed += 1; };
|
||||
callback(null, stream);
|
||||
assert.ok(closed > 0);
|
||||
});
|
||||
|
||||
test("execOnSession settles and releases listeners on SSH stream errors", async () => {
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.close = () => {};
|
||||
stream.destroy = () => {};
|
||||
const conn = { exec(_command, callback) { callback(null, stream); } };
|
||||
const execApi = createExecOnSessionApi({
|
||||
sessions: { get: () => ({ conn, type: "ssh" }) },
|
||||
});
|
||||
|
||||
const pending = execApi.execOnSession(null, "s1", "fail", 1000);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
stream.stderr.emit("error", new Error("remote stderr failed"));
|
||||
const result = await pending;
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error, /stderr failed/);
|
||||
assert.equal(stream.listenerCount("data"), 0);
|
||||
assert.equal(stream.stderr.listenerCount("data"), 0);
|
||||
});
|
||||
656
electron/bridges/systemManager/gpuOps.cjs
Normal file
656
electron/bridges/systemManager/gpuOps.cjs
Normal file
@@ -0,0 +1,656 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
"use strict";
|
||||
|
||||
const NVIDIA_GPU_QUERY = [
|
||||
"nvidia-smi",
|
||||
"--query-gpu=index,uuid,name,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw,power.limit,fan.speed,driver_version",
|
||||
"--format=csv,noheader,nounits",
|
||||
].join(" ");
|
||||
|
||||
const NVIDIA_PROCESS_QUERY = [
|
||||
"nvidia-smi",
|
||||
"--query-compute-apps=gpu_uuid,pid,process_name,used_gpu_memory",
|
||||
"--format=csv,noheader,nounits",
|
||||
].join(" ");
|
||||
|
||||
/**
|
||||
* Local Windows collector — PowerShell (execOnLocalMachine uses powershell.exe).
|
||||
* Ascend on Windows is uncommon; still attempt npu-smi when present.
|
||||
*/
|
||||
const ACCELERATOR_COLLECT_SCRIPT_WINDOWS = [
|
||||
'Write-Output "__NC_ACCEL_BEGIN__"; ',
|
||||
"if (Get-Command nvidia-smi -ErrorAction SilentlyContinue) { ",
|
||||
'Write-Output "__NC_NVIDIA_DEVICES__"; ',
|
||||
`${NVIDIA_GPU_QUERY} 2>$null; `,
|
||||
'Write-Output "__NC_NVIDIA_PROCESSES__"; ',
|
||||
`${NVIDIA_PROCESS_QUERY} 2>$null; `,
|
||||
"}; ",
|
||||
"if (Get-Command npu-smi -ErrorAction SilentlyContinue) { ",
|
||||
'Write-Output "__NC_NPU_BEGIN__"; ',
|
||||
'Write-Output "__NC_NPU_INFO__"; ',
|
||||
"npu-smi info 2>$null; ",
|
||||
'Write-Output "__NC_NPU_PROCS__"; ',
|
||||
"npu-smi info -t proc-mem 2>$null; ",
|
||||
'Write-Output "__NC_NPU_END__"; ',
|
||||
"}; ",
|
||||
'Write-Output "__NC_ACCEL_END__"',
|
||||
].join("");
|
||||
|
||||
/**
|
||||
* Remote collector body (no outer quoting). Wrapped with JSON.stringify so
|
||||
* nested sed single-quotes cannot break `sh -c`.
|
||||
*/
|
||||
const ACCELERATOR_COLLECT_INNER = [
|
||||
'printf "%s\\n" "__NC_ACCEL_BEGIN__"; ',
|
||||
'if command -v nvidia-smi >/dev/null 2>&1; then ',
|
||||
'printf "%s\\n" "__NC_NVIDIA_DEVICES__"; ',
|
||||
`${NVIDIA_GPU_QUERY} 2>/dev/null || true; `,
|
||||
'printf "%s\\n" "__NC_NVIDIA_PROCESSES__"; ',
|
||||
`${NVIDIA_PROCESS_QUERY} 2>/dev/null || true; `,
|
||||
"fi; ",
|
||||
'if command -v npu-smi >/dev/null 2>&1; then ',
|
||||
'printf "%s\\n" "__NC_NPU_BEGIN__"; ',
|
||||
// Prefer info -l; fall back to info -m (SwanLab / Ascend mapping table).
|
||||
"ids=$(npu-smi info -l 2>/dev/null | sed -n 's/^[[:space:]]*NPU ID[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p'); ",
|
||||
'if [ -z "$ids" ]; then ',
|
||||
"ids=$(npu-smi info -m 2>/dev/null | awk 'NR>1 && $1 ~ /^[0-9]+$/ {print $1}' | sort -nu); ",
|
||||
"fi; ",
|
||||
'if [ -n "$ids" ]; then ',
|
||||
'for id in $ids; do ',
|
||||
'printf "%s\\n" "__NC_NPU_DEVICE__=$id"; ',
|
||||
'npu-smi info -t board -i "$id" 2>/dev/null || true; ',
|
||||
'npu-smi info -t common -i "$id" 2>/dev/null || true; ',
|
||||
'npu-smi info -t usages -i "$id" 2>/dev/null || true; ',
|
||||
'npu-smi info -t memory -i "$id" 2>/dev/null || true; ',
|
||||
"done; ",
|
||||
"fi; ",
|
||||
// Always keep the summary table; typed queries can be empty on ModelArts /
|
||||
// containers, and Windows collectors only emit this dump.
|
||||
'printf "%s\\n" "__NC_NPU_INFO__"; ',
|
||||
"npu-smi info 2>/dev/null || true; ",
|
||||
'printf "%s\\n" "__NC_NPU_PROCS__"; ',
|
||||
"npu-smi info -t proc-mem 2>/dev/null || true; ",
|
||||
'printf "%s\\n" "__NC_NPU_END__"; ',
|
||||
"fi; ",
|
||||
'printf "%s\\n" "__NC_ACCEL_END__"',
|
||||
].join("");
|
||||
|
||||
const ACCELERATOR_COLLECT_SCRIPT = `exec sh -c ${JSON.stringify(ACCELERATOR_COLLECT_INNER)}`;
|
||||
|
||||
function parseCsvNumber(raw) {
|
||||
const text = String(raw ?? "").trim();
|
||||
if (
|
||||
!text
|
||||
|| text === "-"
|
||||
|| /^\[?n\/?a\]?$/i.test(text)
|
||||
|| /^not\s+supported$/i.test(text)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const n = Number.parseFloat(text.replace(/[,%]/g, ""));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function extractAscendDriverVersion(text) {
|
||||
const match = String(text || "").match(
|
||||
/\|\s*npu-smi\s+(\S+)\s+.*?Version:\s*(\S+)/i,
|
||||
);
|
||||
if (!match) return null;
|
||||
const version = (match[2] || match[1] || "").replace(/\|/g, "").trim();
|
||||
return version || null;
|
||||
}
|
||||
|
||||
function applyMemPair(device, used, total, { aggregate = false } = {}) {
|
||||
const usedN = parseCsvNumber(used);
|
||||
const totalN = parseCsvNumber(total);
|
||||
if (!Number.isFinite(usedN) && !Number.isFinite(totalN)) return;
|
||||
if (
|
||||
aggregate
|
||||
&& Number.isFinite(device.memoryUsedMb)
|
||||
&& Number.isFinite(usedN)
|
||||
) {
|
||||
device.memoryUsedMb = Number(device.memoryUsedMb) + usedN;
|
||||
device.memoryTotalMb = Number(device.memoryTotalMb || 0)
|
||||
+ (Number.isFinite(totalN) ? totalN : 0);
|
||||
return;
|
||||
}
|
||||
if (Number.isFinite(usedN)) device.memoryUsedMb = usedN;
|
||||
if (Number.isFinite(totalN)) device.memoryTotalMb = totalN;
|
||||
}
|
||||
|
||||
function maxFinite(current, next) {
|
||||
if (!Number.isFinite(next)) return current;
|
||||
if (!Number.isFinite(current)) return next;
|
||||
return Math.max(current, next);
|
||||
}
|
||||
|
||||
function parseCsvFields(line) {
|
||||
// nvidia-smi csv is simple (no embedded commas in queried fields with nounits)
|
||||
return String(line || "")
|
||||
.split(",")
|
||||
.map((part) => part.trim());
|
||||
}
|
||||
|
||||
function parseNvidiaDevices(sectionText) {
|
||||
const devices = [];
|
||||
for (const line of String(sectionText || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("__NC_")) continue;
|
||||
const fields = parseCsvFields(trimmed);
|
||||
if (fields.length < 3) continue;
|
||||
const index = Number.parseInt(fields[0], 10);
|
||||
if (!Number.isFinite(index)) continue;
|
||||
devices.push({
|
||||
vendor: "nvidia",
|
||||
index,
|
||||
uuid: fields[1] || "",
|
||||
name: fields[2] || `GPU ${index}`,
|
||||
utilizationPercent: parseCsvNumber(fields[3]),
|
||||
memoryUsedMb: parseCsvNumber(fields[4]),
|
||||
memoryTotalMb: parseCsvNumber(fields[5]),
|
||||
temperatureC: parseCsvNumber(fields[6]),
|
||||
powerDrawW: parseCsvNumber(fields[7]),
|
||||
powerLimitW: parseCsvNumber(fields[8]),
|
||||
fanPercent: parseCsvNumber(fields[9]),
|
||||
driverVersion: fields[10] || null,
|
||||
health: null,
|
||||
});
|
||||
}
|
||||
return devices;
|
||||
}
|
||||
|
||||
function parseNvidiaProcesses(sectionText, devices) {
|
||||
const uuidToIndex = new Map();
|
||||
for (const device of devices) {
|
||||
if (device.uuid) uuidToIndex.set(device.uuid, device.index);
|
||||
}
|
||||
const processes = [];
|
||||
for (const line of String(sectionText || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("__NC_")) continue;
|
||||
const fields = parseCsvFields(trimmed);
|
||||
if (fields.length < 3) continue;
|
||||
const uuid = fields[0] || "";
|
||||
const pid = Number.parseInt(fields[1], 10);
|
||||
if (!Number.isFinite(pid) || pid <= 0) continue;
|
||||
processes.push({
|
||||
vendor: "nvidia",
|
||||
gpuIndex: uuidToIndex.has(uuid) ? uuidToIndex.get(uuid) : 0,
|
||||
pid,
|
||||
processName: fields[2] || "",
|
||||
memoryUsedMb: parseCsvNumber(fields[3]),
|
||||
});
|
||||
}
|
||||
return processes;
|
||||
}
|
||||
|
||||
function extractKvNumber(block, labels) {
|
||||
for (const label of labels) {
|
||||
const re = new RegExp(`${label}\\s*[:=]\\s*([0-9]+(?:\\.[0-9]+)?)`, "i");
|
||||
const match = String(block || "").match(re);
|
||||
if (match) {
|
||||
const n = Number.parseFloat(match[1]);
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractKvText(block, labels) {
|
||||
for (const label of labels) {
|
||||
const re = new RegExp(`${label}\\s*[:=]\\s*(.+)`, "i");
|
||||
const match = String(block || "").match(re);
|
||||
if (match) {
|
||||
const value = match[1].trim().replace(/\s{2,}.*/, "").trim();
|
||||
if (value) return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseAscendDeviceBlock(index, block) {
|
||||
const name =
|
||||
extractKvText(block, [
|
||||
"Product Name",
|
||||
"NPU Name",
|
||||
"Chip Name",
|
||||
"Model",
|
||||
"Board Name",
|
||||
]) || `Ascend NPU ${index}`;
|
||||
const utilizationPercent = extractKvNumber(block, [
|
||||
"Aicore Usage Rate\\(%\\)",
|
||||
"AICore Usage Rate\\(%\\)",
|
||||
"Aicore Usage Rate",
|
||||
"AI Core Usage",
|
||||
]);
|
||||
// Absolute MB fields only — never treat "HBM Usage Rate(%)" as megabytes.
|
||||
const hbmUsed = extractKvNumber(block, [
|
||||
"HBM Used Memory\\(MB\\)",
|
||||
"HBM Memory Usage\\(MB\\)",
|
||||
"Used HBM Memory\\(MB\\)",
|
||||
"Used HBM Memory",
|
||||
]);
|
||||
const hbmTotal = extractKvNumber(block, [
|
||||
"HBM Total Memory\\(MB\\)",
|
||||
"HBM Capacity\\(MB\\)",
|
||||
"Total HBM Memory\\(MB\\)",
|
||||
"Total HBM Memory",
|
||||
]);
|
||||
const hbmUsageRate = extractKvNumber(block, [
|
||||
"HBM Usage Rate\\(%\\)",
|
||||
"HBM Usage Rate",
|
||||
]);
|
||||
// memory command sometimes reports "Used / Total"
|
||||
const hbmPair = String(block || "").match(
|
||||
/HBM[^\n]*?(?:Memory|Usage)\([^\n]*?(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/i,
|
||||
) || String(block || "").match(
|
||||
/HBM Used Memory[^\n]*?(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/i,
|
||||
);
|
||||
let memoryUsedMb = hbmPair ? Number.parseFloat(hbmPair[1]) : hbmUsed;
|
||||
let memoryTotalMb = hbmPair ? Number.parseFloat(hbmPair[2]) : hbmTotal;
|
||||
// Derive used MB from rate only when total is known and used is missing.
|
||||
if (
|
||||
!Number.isFinite(memoryUsedMb)
|
||||
&& Number.isFinite(hbmUsageRate)
|
||||
&& Number.isFinite(memoryTotalMb)
|
||||
&& memoryTotalMb > 0
|
||||
) {
|
||||
memoryUsedMb = (hbmUsageRate / 100) * memoryTotalMb;
|
||||
}
|
||||
const temperatureC = extractKvNumber(block, [
|
||||
"Temperature\\(C\\)",
|
||||
"Temp\\(C\\)",
|
||||
"Temperature",
|
||||
]);
|
||||
const powerDrawW = extractKvNumber(block, [
|
||||
"NPU Real-time Power\\(W\\)",
|
||||
"Power Dissipation\\(W\\)",
|
||||
"Power\\(W\\)",
|
||||
"Power",
|
||||
]);
|
||||
const health = extractKvText(block, ["Health", "Health Status"]);
|
||||
|
||||
return {
|
||||
vendor: "ascend",
|
||||
index,
|
||||
uuid: "",
|
||||
name,
|
||||
utilizationPercent: Number.isFinite(utilizationPercent) ? utilizationPercent : null,
|
||||
memoryUsedMb: Number.isFinite(memoryUsedMb) ? memoryUsedMb : null,
|
||||
memoryTotalMb: Number.isFinite(memoryTotalMb) ? memoryTotalMb : null,
|
||||
temperatureC: Number.isFinite(temperatureC) ? temperatureC : null,
|
||||
powerDrawW: Number.isFinite(powerDrawW) ? powerDrawW : null,
|
||||
powerLimitW: null,
|
||||
fanPercent: null,
|
||||
driverVersion: null,
|
||||
health,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `npu-smi info` summary tables.
|
||||
*
|
||||
* Modern CANN (24.x+) rows look like nputop's fixture:
|
||||
* | 6 910B1 | OK | 100.8 33 0 / 0 |
|
||||
* | 0 | 0000:01:00.0 | 0 0 / 0 3384 / 65536 |
|
||||
* Chip-ID on the second row is NOT the NPU ID; attach metrics to the
|
||||
* preceding NPU summary row (same approach as youyve/nputop libascend).
|
||||
*
|
||||
* Multi-chip cards (Atlas A3 / 310P) repeat the NPU summary once per chip.
|
||||
* Sidebar shows one row per NPU ID and aggregates chip memory / util.
|
||||
*/
|
||||
function parseAscendInfoTable(sectionText) {
|
||||
const devices = [];
|
||||
const lines = String(sectionText || "").split("\n");
|
||||
const driverVersion = extractAscendDriverVersion(sectionText);
|
||||
|
||||
// Summary: NPU ID, Name, Health, Power, Temp; tolerate Hugepages column after Temp.
|
||||
// Power may be "NA" / "-"; Name is a single token like 910B1 / Ascend910.
|
||||
const summaryRe =
|
||||
/^\|\s*(\d+)\s+(\S+)\s+\|\s*(\S+)\s+\|\s*(\S+)\s+(\d+(?:\.\d+)?)\b/;
|
||||
// Bus-Id chip row (CANN 24.x): | ChipID [PhyID] | Bus-Id | AICore(%) ... mem pairs ... |
|
||||
const busChipRe =
|
||||
/^\|\s*(\d+)\s*(\d*)\s*\|\s*([0-9A-Fa-f:.]+|NA)\s*\|\s*(\d+(?:\.\d+)?)\b/;
|
||||
// Legacy whitespace chip row: | NPU Chip Logic AICore Mem/Tot HBM/Tot |
|
||||
const legacyChipRe =
|
||||
/^\|\s*(\d+)\s+\d+\s+\d+\s+(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/;
|
||||
|
||||
let lastDevice = null;
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i].trim();
|
||||
if (!line.startsWith("|")) continue;
|
||||
|
||||
const summary = line.match(summaryRe);
|
||||
if (summary) {
|
||||
const index = Number.parseInt(summary[1], 10);
|
||||
if (!Number.isFinite(index)) continue;
|
||||
const name = summary[2].trim();
|
||||
if (!name || /^(?:NPU|Chip|Name)$/i.test(name)) continue;
|
||||
let device = devices.find((d) => d.index === index);
|
||||
const isNew = !device;
|
||||
if (!device) {
|
||||
device = {
|
||||
vendor: "ascend",
|
||||
index,
|
||||
uuid: "",
|
||||
name,
|
||||
utilizationPercent: null,
|
||||
memoryUsedMb: null,
|
||||
memoryTotalMb: null,
|
||||
temperatureC: parseCsvNumber(summary[5]),
|
||||
powerDrawW: parseCsvNumber(summary[4]),
|
||||
powerLimitW: null,
|
||||
fanPercent: null,
|
||||
driverVersion,
|
||||
health: summary[3].trim(),
|
||||
_chipCount: 0,
|
||||
};
|
||||
devices.push(device);
|
||||
} else {
|
||||
if (!device.name) device.name = name;
|
||||
device.temperatureC = maxFinite(device.temperatureC, parseCsvNumber(summary[5]));
|
||||
if (device.powerDrawW == null) device.powerDrawW = parseCsvNumber(summary[4]);
|
||||
if (!device.health || /^ok$/i.test(device.health)) {
|
||||
const health = summary[3].trim();
|
||||
if (health) device.health = health;
|
||||
}
|
||||
if (!device.driverVersion && driverVersion) device.driverVersion = driverVersion;
|
||||
}
|
||||
lastDevice = device;
|
||||
|
||||
const next = (lines[i + 1] || "").trim();
|
||||
const busChip = next.match(busChipRe);
|
||||
if (busChip) {
|
||||
i += 1;
|
||||
const util = parseCsvNumber(busChip[4]);
|
||||
device.utilizationPercent = maxFinite(device.utilizationPercent, util);
|
||||
const pairs = [...next.matchAll(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/g)];
|
||||
if (pairs.length > 0) {
|
||||
const hbm = pairs[pairs.length - 1];
|
||||
applyMemPair(device, hbm[1], hbm[2], { aggregate: !isNew && device._chipCount > 0 });
|
||||
}
|
||||
device._chipCount = (device._chipCount || 0) + 1;
|
||||
continue;
|
||||
}
|
||||
const legacyNext = next.match(legacyChipRe);
|
||||
if (legacyNext) {
|
||||
i += 1;
|
||||
device.utilizationPercent = maxFinite(
|
||||
device.utilizationPercent,
|
||||
parseCsvNumber(legacyNext[2]),
|
||||
);
|
||||
applyMemPair(device, legacyNext[5], legacyNext[6], {
|
||||
aggregate: !isNew && device._chipCount > 0,
|
||||
});
|
||||
device._chipCount = (device._chipCount || 0) + 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const legacy = line.match(legacyChipRe);
|
||||
if (legacy) {
|
||||
const index = Number.parseInt(legacy[1], 10);
|
||||
const device = devices.find((d) => d.index === index) || lastDevice;
|
||||
if (!device) continue;
|
||||
device.utilizationPercent = maxFinite(
|
||||
device.utilizationPercent,
|
||||
parseCsvNumber(legacy[2]),
|
||||
);
|
||||
applyMemPair(device, legacy[5], legacy[6], { aggregate: (device._chipCount || 0) > 0 });
|
||||
device._chipCount = (device._chipCount || 0) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const busChip = line.match(busChipRe);
|
||||
if (busChip && lastDevice) {
|
||||
const util = parseCsvNumber(busChip[4]);
|
||||
lastDevice.utilizationPercent = maxFinite(lastDevice.utilizationPercent, util);
|
||||
const pairs = [...line.matchAll(/(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)/g)];
|
||||
if (pairs.length > 0) {
|
||||
const hbm = pairs[pairs.length - 1];
|
||||
applyMemPair(lastDevice, hbm[1], hbm[2], {
|
||||
aggregate: (lastDevice._chipCount || 0) > 0,
|
||||
});
|
||||
}
|
||||
lastDevice._chipCount = (lastDevice._chipCount || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const device of devices) {
|
||||
delete device._chipCount;
|
||||
}
|
||||
return devices;
|
||||
}
|
||||
|
||||
function parseAscendTypedSections(sectionText) {
|
||||
const devices = [];
|
||||
const chunks = String(sectionText || "").split(/__NC_NPU_DEVICE__=/);
|
||||
for (const chunk of chunks) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed || trimmed.startsWith("__NC_")) continue;
|
||||
const nl = trimmed.indexOf("\n");
|
||||
const idPart = nl === -1 ? trimmed : trimmed.slice(0, nl);
|
||||
const body = nl === -1 ? "" : trimmed.slice(nl + 1);
|
||||
const index = Number.parseInt(idPart, 10);
|
||||
if (!Number.isFinite(index)) continue;
|
||||
devices.push(parseAscendDeviceBlock(index, body));
|
||||
}
|
||||
return devices;
|
||||
}
|
||||
|
||||
function parseAscendProcesses(sectionText) {
|
||||
const processes = [];
|
||||
for (const line of String(sectionText || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || /no running processes/i.test(trimmed)) continue;
|
||||
|
||||
// Common proc-mem lines include NPU/Chip/Pid/Name/Memory
|
||||
const match = trimmed.match(
|
||||
/(?:NPU|Device)?\s*I?D?\s*[:=]?\s*(\d+).*?\b(?:PID|Pid)\s*[:=]?\s*(\d+).*?\b(?:Name|Process)\s*[:=]?\s*(\S+).*?(?:Memory|Mem)\s*[:=]?\s*(\d+(?:\.\d+)?)/i,
|
||||
);
|
||||
if (match) {
|
||||
processes.push({
|
||||
vendor: "ascend",
|
||||
gpuIndex: Number.parseInt(match[1], 10) || 0,
|
||||
pid: Number.parseInt(match[2], 10),
|
||||
processName: match[3],
|
||||
memoryUsedMb: parseCsvNumber(match[4]),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// nputop / npu-smi info process table:
|
||||
// | 0 0 | 124528 | python3.8 | 17400 |
|
||||
const infoProc = trimmed.match(
|
||||
/^\|\s*(\d+)\s+(\d+)\s+\|\s+(\d+)\s+\|\s*([^|]+?)\s*\|\s*(\d+(?:\.\d+)?)/,
|
||||
);
|
||||
if (infoProc) {
|
||||
processes.push({
|
||||
vendor: "ascend",
|
||||
gpuIndex: Number.parseInt(infoProc[1], 10) || 0,
|
||||
pid: Number.parseInt(infoProc[3], 10),
|
||||
processName: infoProc[4].trim(),
|
||||
memoryUsedMb: parseCsvNumber(infoProc[5]),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pipe-separated: | 0 | 0 | 12345 | python | 1024 |
|
||||
const pipeTable = trimmed.match(
|
||||
/^\|\s*(\d+)\s*\|\s*\d+\s*\|\s*(\d+)\s*\|\s*([^|]+?)\s*\|\s*(\d+(?:\.\d+)?)/,
|
||||
);
|
||||
if (pipeTable) {
|
||||
processes.push({
|
||||
vendor: "ascend",
|
||||
gpuIndex: Number.parseInt(pipeTable[1], 10) || 0,
|
||||
pid: Number.parseInt(pipeTable[2], 10),
|
||||
processName: pipeTable[3].trim(),
|
||||
memoryUsedMb: parseCsvNumber(pipeTable[4]),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Whitespace-delimited inside one outer |: | 0 0 12345 python 1024 |
|
||||
const wsTable = trimmed.match(
|
||||
/^\|\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\d+(?:\.\d+)?)\s*\|?\s*$/,
|
||||
);
|
||||
if (!wsTable) continue;
|
||||
processes.push({
|
||||
vendor: "ascend",
|
||||
gpuIndex: Number.parseInt(wsTable[1], 10) || 0,
|
||||
pid: Number.parseInt(wsTable[3], 10),
|
||||
processName: wsTable[4],
|
||||
memoryUsedMb: parseCsvNumber(wsTable[5]),
|
||||
});
|
||||
}
|
||||
return processes.filter((p) => Number.isFinite(p.pid) && p.pid > 0);
|
||||
}
|
||||
|
||||
function dedupeAcceleratorProcesses(processes) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const processInfo of processes) {
|
||||
const key = `${processInfo.vendor}:${processInfo.gpuIndex}:${processInfo.pid}:${processInfo.processName}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(processInfo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sliceMarkedSection(text, beginMarker, endMarkers) {
|
||||
const start = text.indexOf(beginMarker);
|
||||
if (start === -1) return "";
|
||||
const from = start + beginMarker.length;
|
||||
let end = text.length;
|
||||
for (const marker of endMarkers) {
|
||||
const idx = text.indexOf(marker, from);
|
||||
if (idx !== -1 && idx < end) end = idx;
|
||||
}
|
||||
return text.slice(from, end);
|
||||
}
|
||||
|
||||
function parseAcceleratorSnapshot(stdout) {
|
||||
const text = String(stdout || "");
|
||||
const nvidiaDevicesText = sliceMarkedSection(text, "__NC_NVIDIA_DEVICES__", [
|
||||
"__NC_NVIDIA_PROCESSES__",
|
||||
"__NC_NPU_BEGIN__",
|
||||
"__NC_ACCEL_END__",
|
||||
]);
|
||||
const nvidiaProcessesText = sliceMarkedSection(text, "__NC_NVIDIA_PROCESSES__", [
|
||||
"__NC_NPU_BEGIN__",
|
||||
"__NC_ACCEL_END__",
|
||||
]);
|
||||
const npuSection = sliceMarkedSection(text, "__NC_NPU_BEGIN__", ["__NC_NPU_END__", "__NC_ACCEL_END__"]);
|
||||
|
||||
const nvidiaDevices = parseNvidiaDevices(nvidiaDevicesText);
|
||||
const nvidiaProcesses = parseNvidiaProcesses(nvidiaProcessesText, nvidiaDevices);
|
||||
|
||||
let ascendDevices = parseAscendTypedSections(npuSection);
|
||||
const infoDump = sliceMarkedSection(npuSection, "__NC_NPU_INFO__", [
|
||||
"__NC_NPU_PROCS__",
|
||||
"__NC_NPU_END__",
|
||||
]);
|
||||
const tableDevices = parseAscendInfoTable(infoDump || npuSection);
|
||||
if (ascendDevices.length === 0) {
|
||||
ascendDevices = tableDevices;
|
||||
} else if (tableDevices.length > 0) {
|
||||
// Fill gaps when typed -t queries returned stubs but the summary table is rich.
|
||||
const byIndex = new Map(ascendDevices.map((d) => [d.index, d]));
|
||||
for (const tableDevice of tableDevices) {
|
||||
const existing = byIndex.get(tableDevice.index);
|
||||
if (!existing) {
|
||||
ascendDevices.push(tableDevice);
|
||||
byIndex.set(tableDevice.index, tableDevice);
|
||||
continue;
|
||||
}
|
||||
if (!existing.name || /^Ascend NPU\b/i.test(existing.name)) {
|
||||
existing.name = tableDevice.name;
|
||||
}
|
||||
for (const key of [
|
||||
"utilizationPercent",
|
||||
"memoryUsedMb",
|
||||
"memoryTotalMb",
|
||||
"temperatureC",
|
||||
"powerDrawW",
|
||||
"health",
|
||||
]) {
|
||||
if (existing[key] == null && tableDevice[key] != null) {
|
||||
existing[key] = tableDevice[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const ascendProcText = sliceMarkedSection(npuSection, "__NC_NPU_PROCS__", [
|
||||
"__NC_NPU_END__",
|
||||
]);
|
||||
// Processes often live in the `npu-smi info` dump (nputop fixtures), not only -t proc-mem.
|
||||
const ascendProcesses = dedupeAcceleratorProcesses([
|
||||
...parseAscendProcesses(ascendProcText),
|
||||
...parseAscendProcesses(infoDump || npuSection),
|
||||
]);
|
||||
|
||||
const ascendDriverVersion = extractAscendDriverVersion(infoDump || npuSection);
|
||||
if (ascendDriverVersion) {
|
||||
for (const device of ascendDevices) {
|
||||
if (!device.driverVersion) device.driverVersion = ascendDriverVersion;
|
||||
}
|
||||
}
|
||||
|
||||
const devices = [...nvidiaDevices, ...ascendDevices].sort((a, b) => {
|
||||
if (a.vendor !== b.vendor) return a.vendor.localeCompare(b.vendor);
|
||||
return a.index - b.index;
|
||||
});
|
||||
const processes = [...nvidiaProcesses, ...ascendProcesses];
|
||||
const nvidiaDriverVersion = nvidiaDevices.find((d) => d.driverVersion)?.driverVersion || null;
|
||||
|
||||
return {
|
||||
devices,
|
||||
processes,
|
||||
nvidiaDriverVersion,
|
||||
probedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function createGpuOpsApi({
|
||||
execOnSession,
|
||||
execOnLocalMachine,
|
||||
isLocalSession,
|
||||
process: nodeProcess = process,
|
||||
}) {
|
||||
async function listAccelerators(event, sessionId) {
|
||||
if (!sessionId) return { success: false, error: "Missing sessionId" };
|
||||
|
||||
let result;
|
||||
if (
|
||||
typeof isLocalSession === "function"
|
||||
&& isLocalSession(sessionId)
|
||||
&& nodeProcess.platform === "win32"
|
||||
&& typeof execOnLocalMachine === "function"
|
||||
) {
|
||||
result = await execOnLocalMachine(ACCELERATOR_COLLECT_SCRIPT_WINDOWS, 15000);
|
||||
} else {
|
||||
result = await execOnSession(event, sessionId, ACCELERATOR_COLLECT_SCRIPT, 15000);
|
||||
}
|
||||
|
||||
if (result.pending) return { success: false, pending: true };
|
||||
if (!result.success) return { success: false, error: result.error || "Failed to query accelerators" };
|
||||
const snapshot = parseAcceleratorSnapshot(result.stdout);
|
||||
return { success: true, ...snapshot };
|
||||
}
|
||||
|
||||
return { listAccelerators, parseAcceleratorSnapshot };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createGpuOpsApi,
|
||||
parseAcceleratorSnapshot,
|
||||
parseNvidiaDevices,
|
||||
parseNvidiaProcesses,
|
||||
parseAscendDeviceBlock,
|
||||
parseAscendInfoTable,
|
||||
parseAscendProcesses,
|
||||
ACCELERATOR_COLLECT_SCRIPT,
|
||||
ACCELERATOR_COLLECT_SCRIPT_WINDOWS,
|
||||
};
|
||||
356
electron/bridges/systemManager/gpuOps.test.cjs
Normal file
356
electron/bridges/systemManager/gpuOps.test.cjs
Normal file
@@ -0,0 +1,356 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
parseAcceleratorSnapshot,
|
||||
parseAscendDeviceBlock,
|
||||
parseAscendInfoTable,
|
||||
parseNvidiaDevices,
|
||||
parseNvidiaProcesses,
|
||||
} = require("./gpuOps.cjs");
|
||||
|
||||
test("parseNvidiaDevices reads csv nounits rows", () => {
|
||||
const devices = parseNvidiaDevices(
|
||||
"0, GPU-aaa, NVIDIA GeForce RTX 4090, 42, 1024, 24576, 61, 120.5, 450.0, 35, 550.54.15\n" +
|
||||
"1, GPU-bbb, NVIDIA A100-SXM4-80GB, [N/A], 0, 81920, 38, 70.0, 400.0, [N/A], 550.54.15\n",
|
||||
);
|
||||
assert.equal(devices.length, 2);
|
||||
assert.equal(devices[0].vendor, "nvidia");
|
||||
assert.equal(devices[0].name, "NVIDIA GeForce RTX 4090");
|
||||
assert.equal(devices[0].utilizationPercent, 42);
|
||||
assert.equal(devices[0].memoryUsedMb, 1024);
|
||||
assert.equal(devices[0].memoryTotalMb, 24576);
|
||||
assert.equal(devices[1].utilizationPercent, null);
|
||||
assert.equal(devices[1].fanPercent, null);
|
||||
});
|
||||
|
||||
test("parseNvidiaProcesses maps uuid to gpu index", () => {
|
||||
const devices = parseNvidiaDevices("0, GPU-aaa, RTX, 10, 1, 2, 30, 40, 50, 20, 1.0\n");
|
||||
const processes = parseNvidiaProcesses("GPU-aaa, 1234, python, 2048\n", devices);
|
||||
assert.equal(processes.length, 1);
|
||||
assert.equal(processes[0].gpuIndex, 0);
|
||||
assert.equal(processes[0].pid, 1234);
|
||||
assert.equal(processes[0].processName, "python");
|
||||
assert.equal(processes[0].memoryUsedMb, 2048);
|
||||
});
|
||||
|
||||
test("parseAscendDeviceBlock extracts usages and memory pair", () => {
|
||||
const device = parseAscendDeviceBlock(
|
||||
0,
|
||||
`
|
||||
NPU ID : 0
|
||||
Chip ID : 0
|
||||
Product Name : Ascend 910B
|
||||
Aicore Usage Rate(%) : 17
|
||||
HBM Usage Rate(%) : 6
|
||||
HBM Capacity(MB) : 32768
|
||||
HBM Used Memory(MB) : 2048 / 32768
|
||||
Temperature(C) : 41
|
||||
NPU Real-time Power(W) : 71.7
|
||||
Health : OK
|
||||
`,
|
||||
);
|
||||
assert.equal(device.vendor, "ascend");
|
||||
assert.equal(device.name, "Ascend 910B");
|
||||
assert.equal(device.utilizationPercent, 17);
|
||||
assert.equal(device.memoryUsedMb, 2048);
|
||||
assert.equal(device.memoryTotalMb, 32768);
|
||||
assert.equal(device.temperatureC, 41);
|
||||
assert.equal(device.powerDrawW, 71.7);
|
||||
assert.equal(device.health, "OK");
|
||||
});
|
||||
|
||||
test("parseAscendDeviceBlock does not treat HBM usage rate percent as megabytes", () => {
|
||||
const device = parseAscendDeviceBlock(
|
||||
2,
|
||||
`
|
||||
Product Name : Ascend 910B
|
||||
Aicore Usage Rate(%) : 3
|
||||
HBM Usage Rate(%) : 25
|
||||
HBM Capacity(MB) : 32768
|
||||
Temperature(C) : 40
|
||||
`,
|
||||
);
|
||||
assert.equal(device.memoryTotalMb, 32768);
|
||||
assert.equal(device.memoryUsedMb, 8192);
|
||||
});
|
||||
|
||||
test("parseAscendProcesses accepts whitespace-delimited table rows", () => {
|
||||
const { parseAscendProcesses } = require("./gpuOps.cjs");
|
||||
const processes = parseAscendProcesses(`
|
||||
| NPU Chip PID Name Memory |
|
||||
| 0 0 12345 python 1024 |
|
||||
| 1 0 99 train.py 2048 |
|
||||
`);
|
||||
assert.equal(processes.length, 2);
|
||||
assert.equal(processes[0].gpuIndex, 0);
|
||||
assert.equal(processes[0].pid, 12345);
|
||||
assert.equal(processes[0].processName, "python");
|
||||
assert.equal(processes[0].memoryUsedMb, 1024);
|
||||
assert.equal(processes[1].pid, 99);
|
||||
});
|
||||
|
||||
test("POSIX accelerator collector keeps sed quotes inside JSON-wrapped sh -c", () => {
|
||||
const { ACCELERATOR_COLLECT_SCRIPT } = require("./gpuOps.cjs");
|
||||
assert.match(ACCELERATOR_COLLECT_SCRIPT, /^exec sh -c "/);
|
||||
assert.match(ACCELERATOR_COLLECT_SCRIPT, /sed -n '/);
|
||||
// Outer wrapper must not use raw single quotes around the whole script body.
|
||||
assert.doesNotMatch(ACCELERATOR_COLLECT_SCRIPT, /^exec sh -c '/);
|
||||
// Always dump npu-smi info; discover IDs via info -l then info -m.
|
||||
assert.match(ACCELERATOR_COLLECT_SCRIPT, /__NC_NPU_INFO__/);
|
||||
assert.match(ACCELERATOR_COLLECT_SCRIPT, /npu-smi info -m/);
|
||||
assert.match(ACCELERATOR_COLLECT_SCRIPT, /npu-smi info 2>\/dev\/null/);
|
||||
});
|
||||
|
||||
test("POSIX accelerator collector is syntactically valid for sh -c", () => {
|
||||
const { ACCELERATOR_COLLECT_SCRIPT } = require("./gpuOps.cjs");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
// Replace remote tools with no-ops so we only validate shell syntax/runtime of wrappers.
|
||||
const dryRun = ACCELERATOR_COLLECT_SCRIPT
|
||||
.replaceAll("nvidia-smi", "false")
|
||||
.replaceAll("npu-smi", "false");
|
||||
const result = spawnSync("sh", ["-c", dryRun], { encoding: "utf8" });
|
||||
assert.equal(result.status, 0, `stderr=${result.stderr}\nstdout=${result.stdout}`);
|
||||
assert.match(result.stdout, /__NC_ACCEL_BEGIN__/);
|
||||
assert.match(result.stdout, /__NC_ACCEL_END__/);
|
||||
});
|
||||
|
||||
test("listAccelerators uses PowerShell collector for local Windows sessions", async () => {
|
||||
const { createGpuOpsApi, ACCELERATOR_COLLECT_SCRIPT_WINDOWS } = require("./gpuOps.cjs");
|
||||
let seenCommand = "";
|
||||
const gpuOps = createGpuOpsApi({
|
||||
execOnSession: async () => {
|
||||
throw new Error("POSIX collector should not run on local Windows");
|
||||
},
|
||||
execOnLocalMachine: async (command) => {
|
||||
seenCommand = command;
|
||||
return {
|
||||
success: true,
|
||||
stdout: "__NC_ACCEL_BEGIN__\n__NC_NVIDIA_DEVICES__\n0, GPU-w, RTX, 1, 2, 3, 4, 5, 6, 7, 8.0\n__NC_ACCEL_END__\n",
|
||||
};
|
||||
},
|
||||
isLocalSession: () => true,
|
||||
process: { platform: "win32" },
|
||||
});
|
||||
|
||||
const result = await gpuOps.listAccelerators(null, "local-1");
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(seenCommand, ACCELERATOR_COLLECT_SCRIPT_WINDOWS);
|
||||
assert.equal(result.devices.length, 1);
|
||||
assert.equal(result.devices[0].name, "RTX");
|
||||
});
|
||||
|
||||
test("parseAscendInfoTable reads summary and chip rows", () => {
|
||||
const devices = parseAscendInfoTable(`
|
||||
| NPU Name | Health | Power(W) Temp(C) |
|
||||
| 0 910B3 | OK | 71.8 42 |
|
||||
| Chip Phy-ID Chip-Logic-ID AICore(%) Memory-Usage(MB) HBM-Usage(MB) |
|
||||
| 0 0 0 12 100 / 32768 2048 / 32768 |
|
||||
`);
|
||||
assert.equal(devices.length, 1);
|
||||
assert.equal(devices[0].name, "910B3");
|
||||
assert.equal(devices[0].utilizationPercent, 12);
|
||||
assert.equal(devices[0].memoryUsedMb, 2048);
|
||||
assert.equal(devices[0].memoryTotalMb, 32768);
|
||||
assert.equal(devices[0].temperatureC, 42);
|
||||
});
|
||||
|
||||
test("parseAscendInfoTable reads CANN 24.x Bus-Id table with Hugepages column", () => {
|
||||
// Fixture from GitHub issue #2811 (ModelArts / Ascend 910B1, npu-smi 24.1.rc2).
|
||||
// Chip row Chip-ID is 0 while NPU ID is 6; metrics must attach to the NPU row.
|
||||
const devices = parseAscendInfoTable(`
|
||||
+------------------------------------------------------------------------------------------------+
|
||||
| npu-smi 24.1.rc2 Version: 24.1.rc2 |
|
||||
+---------------------------+---------------+----------------------------------------------------+
|
||||
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)|
|
||||
| Chip | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) |
|
||||
+===========================+===============+====================================================+
|
||||
| 6 910B1 | OK | 100.8 33 0 / 0 |
|
||||
| 0 | 0000:01:00.0 | 0 0 / 0 3384 / 65536 |
|
||||
+===========================+===============+====================================================+
|
||||
| No running processes found in NPU 6 |
|
||||
+===========================+===============+====================================================+
|
||||
`);
|
||||
assert.equal(devices.length, 1);
|
||||
assert.equal(devices[0].index, 6);
|
||||
assert.equal(devices[0].name, "910B1");
|
||||
assert.equal(devices[0].health, "OK");
|
||||
assert.equal(devices[0].powerDrawW, 100.8);
|
||||
assert.equal(devices[0].temperatureC, 33);
|
||||
assert.equal(devices[0].utilizationPercent, 0);
|
||||
assert.equal(devices[0].memoryUsedMb, 3384);
|
||||
assert.equal(devices[0].memoryTotalMb, 65536);
|
||||
});
|
||||
|
||||
test("parseAcceleratorSnapshot falls back to modern npu-smi info table", () => {
|
||||
const snapshot = parseAcceleratorSnapshot(`
|
||||
__NC_ACCEL_BEGIN__
|
||||
__NC_NPU_BEGIN__
|
||||
__NC_NPU_INFO__
|
||||
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)|
|
||||
| Chip | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) |
|
||||
| 6 910B1 | OK | 100.8 33 0 / 0 |
|
||||
| 0 | 0000:01:00.0 | 0 0 / 0 3384 / 65536 |
|
||||
__NC_NPU_PROCS__
|
||||
__NC_NPU_END__
|
||||
__NC_ACCEL_END__
|
||||
`);
|
||||
assert.equal(snapshot.devices.length, 1);
|
||||
assert.equal(snapshot.devices[0].vendor, "ascend");
|
||||
assert.equal(snapshot.devices[0].index, 6);
|
||||
assert.equal(snapshot.devices[0].memoryTotalMb, 65536);
|
||||
});
|
||||
|
||||
test("parseAcceleratorSnapshot merges nvidia and ascend marked sections", () => {
|
||||
const snapshot = parseAcceleratorSnapshot(`
|
||||
__NC_ACCEL_BEGIN__
|
||||
__NC_NVIDIA_DEVICES__
|
||||
0, GPU-aaa, RTX 4090, 55, 8192, 24576, 60, 200.0, 450.0, 40, 550.54
|
||||
__NC_NVIDIA_PROCESSES__
|
||||
GPU-aaa, 99, train.py, 4096
|
||||
__NC_NPU_BEGIN__
|
||||
__NC_NPU_DEVICE__=1
|
||||
Product Name : Ascend 910B
|
||||
Aicore Usage Rate(%) : 8
|
||||
HBM Used Memory(MB) : 512 / 32768
|
||||
Temperature(C) : 39
|
||||
NPU Real-time Power(W) : 66.0
|
||||
Health : OK
|
||||
__NC_NPU_PROCS__
|
||||
__NC_NPU_END__
|
||||
__NC_ACCEL_END__
|
||||
`);
|
||||
assert.equal(snapshot.devices.length, 2);
|
||||
assert.equal(snapshot.devices[0].vendor, "ascend");
|
||||
assert.equal(snapshot.devices[1].vendor, "nvidia");
|
||||
assert.equal(snapshot.processes.length, 1);
|
||||
assert.equal(snapshot.nvidiaDriverVersion, "550.54");
|
||||
});
|
||||
|
||||
test("parseAcceleratorSnapshot enriches typed Ascend stubs from info table", () => {
|
||||
const snapshot = parseAcceleratorSnapshot(`
|
||||
__NC_ACCEL_BEGIN__
|
||||
__NC_NPU_BEGIN__
|
||||
__NC_NPU_DEVICE__=6
|
||||
__NC_NPU_INFO__
|
||||
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)|
|
||||
| Chip | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) |
|
||||
| 6 910B1 | OK | 100.8 33 0 / 0 |
|
||||
| 0 | 0000:01:00.0 | 0 0 / 0 3384 / 65536 |
|
||||
__NC_NPU_PROCS__
|
||||
__NC_NPU_END__
|
||||
__NC_ACCEL_END__
|
||||
`);
|
||||
assert.equal(snapshot.devices.length, 1);
|
||||
assert.equal(snapshot.devices[0].index, 6);
|
||||
assert.equal(snapshot.devices[0].name, "910B1");
|
||||
assert.equal(snapshot.devices[0].memoryUsedMb, 3384);
|
||||
assert.equal(snapshot.devices[0].memoryTotalMb, 65536);
|
||||
assert.equal(snapshot.devices[0].temperatureC, 33);
|
||||
assert.equal(snapshot.devices[0].powerDrawW, 100.8);
|
||||
});
|
||||
|
||||
// Fixtures adapted from youyve/nputop tests/test_libascend.py (Apache-2.0).
|
||||
// Valuable because we do not have Ascend hardware in CI.
|
||||
|
||||
test("parseAscendInfoTable reads nputop 910B2C dual-NPU fixture", () => {
|
||||
const devices = parseAscendInfoTable(`
|
||||
+------------------------------------------------------------------------------------------------+
|
||||
| npu-smi 23.0.2.1 Version: 23.0.2.1 |
|
||||
+---------------------------+---------------+----------------------------------------------------+
|
||||
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)|
|
||||
| Chip | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) |
|
||||
+===========================+===============+====================================================+
|
||||
| 0 910B2C | OK | 88.6 51 0 / 0 |
|
||||
| 0 | 0000:5A:00.0 | 0 0 / 0 20701/ 65536 |
|
||||
+===========================+===============+====================================================+
|
||||
| 1 910B2C | OK | 99.6 50 0 / 0 |
|
||||
| 0 | 0000:19:00.0 | 0 0 / 0 20687/ 65536 |
|
||||
+===========================+===============+====================================================+
|
||||
`);
|
||||
assert.equal(devices.length, 2);
|
||||
assert.equal(devices[0].name, "910B2C");
|
||||
assert.equal(devices[0].memoryUsedMb, 20701);
|
||||
assert.equal(devices[0].memoryTotalMb, 65536);
|
||||
assert.equal(devices[0].powerDrawW, 88.6);
|
||||
assert.equal(devices[0].driverVersion, "23.0.2.1");
|
||||
assert.equal(devices[1].memoryUsedMb, 20687);
|
||||
});
|
||||
|
||||
test("parseAscendInfoTable reads nputop 310B4 no-HBM column fixture", () => {
|
||||
const devices = parseAscendInfoTable(`
|
||||
| npu-smi 23.0.0 Version: 23.0.0 |
|
||||
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page) |
|
||||
| Chip Device | Bus-Id | AICore(%) Memory-Usage(MB) |
|
||||
| 0 310B4 | Alarm | 0.0 65 15 / 15 |
|
||||
| 0 0 | NA | 0 3628 / 15609 |
|
||||
`);
|
||||
assert.equal(devices.length, 1);
|
||||
assert.equal(devices[0].name, "310B4");
|
||||
assert.equal(devices[0].health, "Alarm");
|
||||
assert.equal(devices[0].memoryUsedMb, 3628);
|
||||
assert.equal(devices[0].memoryTotalMb, 15609);
|
||||
assert.equal(devices[0].temperatureC, 65);
|
||||
});
|
||||
|
||||
test("parseAscendInfoTable aggregates Atlas A3 multi-chip NPU rows", () => {
|
||||
const devices = parseAscendInfoTable(`
|
||||
| npu-smi 25.2.0 Version: 25.2.0 |
|
||||
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page)|
|
||||
| Chip Phy-ID | Bus-Id | AICore(%) Memory-Usage(MB) HBM-Usage(MB) |
|
||||
| 0 Ascend910 | OK | 162.8 37 0 / 0 |
|
||||
| 0 0 | 0000:9C:00.0 | 0 0 / 0 3133 / 65536 |
|
||||
| 0 Ascend910 | OK | - 37 0 / 0 |
|
||||
| 1 1 | 0000:9E:00.0 | 0 0 / 0 2876 / 65536 |
|
||||
| 1 Ascend910 | OK | 167.1 38 0 / 0 |
|
||||
| 0 2 | 0000:37:00.0 | 0 0 / 0 3116 / 65536 |
|
||||
| 1 Ascend910 | OK | - 38 0 / 0 |
|
||||
| 1 3 | 0000:39:00.0 | 0 0 / 0 10568/ 65536 |
|
||||
`);
|
||||
assert.equal(devices.length, 2);
|
||||
assert.equal(devices[0].name, "Ascend910");
|
||||
assert.equal(devices[0].powerDrawW, 162.8);
|
||||
assert.equal(devices[0].memoryUsedMb, 3133 + 2876);
|
||||
assert.equal(devices[0].memoryTotalMb, 65536 + 65536);
|
||||
assert.equal(devices[1].memoryUsedMb, 3116 + 10568);
|
||||
assert.equal(devices[1].powerDrawW, 167.1);
|
||||
});
|
||||
|
||||
test("parseAcceleratorSnapshot reads nputop 310P3 processes from info dump", () => {
|
||||
const snapshot = parseAcceleratorSnapshot(`
|
||||
__NC_ACCEL_BEGIN__
|
||||
__NC_NPU_BEGIN__
|
||||
__NC_NPU_INFO__
|
||||
| npu-smi 24.1.0.1 Version: 24.1.0.1 |
|
||||
| NPU Name | Health | Power(W) Temp(C) Hugepages-Usage(page) |
|
||||
| Chip Device | Bus-Id | AICore(%) Memory-Usage(MB) |
|
||||
| 1 310P3 | OK | NA 62 7210 / 7210 |
|
||||
| 0 0 | 0000:01:00.0 | 0 16302/ 44280 |
|
||||
| 1 310P3 | OK | NA 62 7210 / 7210 |
|
||||
| 1 1 | 0000:01:00.0 | 0 15543/ 43693 |
|
||||
| 2 310P3 | OK | NA 61 17057 / 17057 |
|
||||
| 0 2 | 0000:02:00.0 | 0 35563/ 44280 |
|
||||
| 2 310P3 | OK | NA 61 16823 / 16823 |
|
||||
| 1 3 | 0000:02:00.0 | 0 35204/ 43693 |
|
||||
| NPU Chip | Process id | Process name | Process memory(MB) |
|
||||
| 1 0 | 3277562 | mindie_llm_back | 14513 |
|
||||
| 1 1 | 3277565 | mindie_llm_back | 14513 |
|
||||
| 2 0 | 3034986 | mindie_llm_back | 34207 |
|
||||
| 2 1 | 3034989 | mindie_llm_back | 33740 |
|
||||
__NC_NPU_PROCS__
|
||||
__NC_NPU_END__
|
||||
__NC_ACCEL_END__
|
||||
`);
|
||||
assert.equal(snapshot.devices.length, 2);
|
||||
assert.equal(snapshot.devices[0].index, 1);
|
||||
assert.equal(snapshot.devices[0].name, "310P3");
|
||||
assert.equal(snapshot.devices[0].memoryUsedMb, 16302 + 15543);
|
||||
assert.equal(snapshot.devices[0].powerDrawW, null);
|
||||
assert.equal(snapshot.devices[0].driverVersion, "24.1.0.1");
|
||||
assert.equal(snapshot.processes.length, 4);
|
||||
assert.equal(snapshot.processes[0].pid, 3277562);
|
||||
assert.equal(snapshot.processes[0].processName, "mindie_llm_back");
|
||||
assert.equal(snapshot.processes[0].memoryUsedMb, 14513);
|
||||
assert.equal(snapshot.processes[0].gpuIndex, 1);
|
||||
});
|
||||
482
electron/bridges/systemManager/portOps.cjs
Normal file
482
electron/bridges/systemManager/portOps.cjs
Normal file
@@ -0,0 +1,482 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
"use strict";
|
||||
|
||||
const { PORT_LIST_PS_COMMAND } = require("./windowsPowerShell.cjs");
|
||||
|
||||
/**
|
||||
* Listening-port collectors.
|
||||
* Parsing approach inspired by Portwatch (ss -tlnp + process field), adapted for
|
||||
* remote SSH exec, UDP, IPv6, macOS netstat/lsof, and BusyBox netstat.
|
||||
*/
|
||||
|
||||
const LISTEN_PORTS_INNER = [
|
||||
'printf "%s\\n" "__NC_PORTS_BEGIN__"; ',
|
||||
// Run every available collector. macOS often has netstat without useful PID
|
||||
// columns; lsof fills that gap. Prefer merging over elif exclusivity.
|
||||
'if command -v ss >/dev/null 2>&1; then ',
|
||||
'printf "%s\\n" "__NC_SS__"; ',
|
||||
"ss -H -tulnp 2>/dev/null || ss -tulnp 2>/dev/null || true; ",
|
||||
"fi; ",
|
||||
'if command -v netstat >/dev/null 2>&1; then ',
|
||||
'printf "%s\\n" "__NC_NETSTAT__"; ',
|
||||
// `-lntp` is TCP-only; prefer `-lntup`, else TCP+UDP separately. macOS uses `-anv -p`.
|
||||
"if netstat -lntup 2>/dev/null; then :; ",
|
||||
"elif netstat -lntp 2>/dev/null; then ",
|
||||
"netstat -lnup 2>/dev/null || true; ",
|
||||
"elif netstat -anv -p tcp >/dev/null 2>&1; then ",
|
||||
// Prefer LISTEN-only TCP to keep stdout under maxBuffer on busy hosts.
|
||||
"netstat -anv -p tcp 2>/dev/null | grep -i LISTEN || true; ",
|
||||
"netstat -anv -p udp 2>/dev/null || true; ",
|
||||
"else ",
|
||||
"netstat -anp 2>/dev/null | grep -Ei 'LISTEN|^udp' || netstat -an 2>/dev/null | grep -Ei 'LISTEN|^udp' || true; ",
|
||||
"fi; ",
|
||||
"fi; ",
|
||||
'if command -v lsof >/dev/null 2>&1; then ',
|
||||
'printf "%s\\n" "__NC_LSOF__"; ',
|
||||
"lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null || true; ",
|
||||
// Idle-only: Linux has no UDP state names, so this no-ops there (ss/netstat cover UDP).
|
||||
// Do not fall back to bare `-iUDP` — that dumps client binds as fake listeners.
|
||||
"lsof -nP -iUDP -sUDP:Idle 2>/dev/null || true; ",
|
||||
"fi; ",
|
||||
'printf "%s\\n" "__NC_PORTS_END__"',
|
||||
].join("");
|
||||
|
||||
const LISTEN_PORTS_SCRIPT = `exec sh -c ${JSON.stringify(LISTEN_PORTS_INNER)}`;
|
||||
|
||||
const LISTEN_PORTS_WINDOWS = [
|
||||
'Write-Output "__NC_PORTS_BEGIN__"; ',
|
||||
'Write-Output "__NC_WIN__"; ',
|
||||
"$rows = @(); ",
|
||||
"$rows += @(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | ",
|
||||
"Select-Object LocalAddress,LocalPort,OwningProcess,@{Name='Protocol';Expression={'tcp'}}); ",
|
||||
// UDP has no listen state; drop high ephemeral ports on non-wildcard/non-loopback
|
||||
// addresses so client binds do not flood the Ports tab.
|
||||
"$rows += @(Get-NetUDPEndpoint -ErrorAction SilentlyContinue | Where-Object { ",
|
||||
"$a = [string]$_.LocalAddress; ",
|
||||
"$wildcard = ($a -eq '0.0.0.0' -or $a -eq '::' -or $a -eq '*'); ",
|
||||
"$loopback = ($a -eq '127.0.0.1' -or $a -eq '::1'); ",
|
||||
"if ($wildcard -or $loopback) { $true } else { $_.LocalPort -lt 49152 } ",
|
||||
"} | Select-Object LocalAddress,LocalPort,OwningProcess,@{Name='Protocol';Expression={'udp'}}); ",
|
||||
"if ($rows.Count -gt 0) { $rows | ConvertTo-Json -Compress } else { Write-Output '[]' }; ",
|
||||
'Write-Output "__NC_PORTS_END__"',
|
||||
].join("");
|
||||
|
||||
const LISTEN_PORTS_MAX_BUFFER = 16 * 1024 * 1024;
|
||||
|
||||
function normalizeProtocol(raw) {
|
||||
const text = String(raw || "").trim().toLowerCase();
|
||||
if (text === "tcp" || text === "tcp4") return "tcp";
|
||||
// macOS dual-stack (tcp46/udp46) is an IPv6 socket with v6only off; map to
|
||||
// tcp6/udp6 so netstat rows merge with lsof IPv6 listeners.
|
||||
if (text === "tcp6" || text === "tcp46") return "tcp6";
|
||||
if (text === "udp" || text === "udp4") return "udp";
|
||||
if (text === "udp6" || text === "udp46") return "udp6";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function parseListenAddress(addr) {
|
||||
const text = String(addr || "").trim();
|
||||
if (!text) return null;
|
||||
|
||||
// macOS / BSD netstat: "*.22", "127.0.0.1.53", "::1.631", "fe80::1%lo0.22"
|
||||
// Prefer dotted port even when the address contains ':' (IPv6).
|
||||
const dotted = text.match(/^(.*)\.(\d+)$/);
|
||||
if (dotted) {
|
||||
const port = Number(dotted[2]);
|
||||
if (Number.isFinite(port) && port >= 0 && port <= 65535) {
|
||||
let address = dotted[1] || "*";
|
||||
if (address.includes("%")) address = address.split("%")[0];
|
||||
if (address === "*" || address === "0.0.0.0" || address === "::") address = "*";
|
||||
return { address, port };
|
||||
}
|
||||
}
|
||||
|
||||
const lastColon = text.lastIndexOf(":");
|
||||
if (lastColon <= 0) return null;
|
||||
const portText = text.slice(lastColon + 1);
|
||||
if (!/^\d+$/.test(portText)) return null;
|
||||
const port = Number(portText);
|
||||
if (!Number.isFinite(port) || port < 0 || port > 65535) return null;
|
||||
let address = text.slice(0, lastColon);
|
||||
if (address.startsWith("[") && address.endsWith("]")) {
|
||||
address = address.slice(1, -1);
|
||||
}
|
||||
if (address.includes("%")) address = address.split("%")[0];
|
||||
if (address === "*" || address === "0.0.0.0" || address === "::") {
|
||||
address = "*";
|
||||
}
|
||||
return { address, port };
|
||||
}
|
||||
|
||||
function parseSsProcesses(info) {
|
||||
const text = String(info || "");
|
||||
// users:(("app",pid=11,fd=3),("app",pid=12,fd=4))
|
||||
const entries = [];
|
||||
const re = /\("([^"]+)",pid=(\d+)/g;
|
||||
let match = re.exec(text);
|
||||
while (match) {
|
||||
entries.push({ processName: match[1] || "", pid: Number(match[2]) });
|
||||
match = re.exec(text);
|
||||
}
|
||||
if (entries.length) return entries;
|
||||
return [{ processName: "", pid: null }];
|
||||
}
|
||||
|
||||
function portKey(protocol, address, port, pid) {
|
||||
// Include pid so SO_REUSEPORT / multi-process listeners stay distinct.
|
||||
return `${normalizeProtocol(protocol)}|${address || "*"}|${port}|${pid == null ? "-" : pid}`;
|
||||
}
|
||||
|
||||
function makePortId(protocol, address, port, pid) {
|
||||
return `${normalizeProtocol(protocol)}|${address}|${port}|${pid == null ? "-" : pid}`;
|
||||
}
|
||||
|
||||
function sameSocket(a, protocol, address, port) {
|
||||
return a.protocol === protocol && a.address === address && a.port === port;
|
||||
}
|
||||
|
||||
function pushPort(entries, byKey, row) {
|
||||
if (!row || !Number.isFinite(row.port)) return;
|
||||
const protocol = normalizeProtocol(row.protocol);
|
||||
const address = row.address || "*";
|
||||
const port = Number(row.port);
|
||||
const pid = Number.isFinite(row.pid) && row.pid > 0 ? Number(row.pid) : null;
|
||||
const processName = String(row.processName || "");
|
||||
|
||||
if (pid != null) {
|
||||
// Drop anonymous placeholder once a PID-bearing collector reports the socket.
|
||||
const anonKey = portKey(protocol, address, port, null);
|
||||
const anon = byKey.get(anonKey);
|
||||
if (anon) {
|
||||
byKey.delete(anonKey);
|
||||
const idx = entries.indexOf(anon);
|
||||
if (idx >= 0) entries.splice(idx, 1);
|
||||
}
|
||||
} else {
|
||||
for (const existing of byKey.values()) {
|
||||
if (sameSocket(existing, protocol, address, port) && existing.pid != null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const key = portKey(protocol, address, port, pid);
|
||||
const existing = byKey.get(key);
|
||||
if (existing) {
|
||||
if (!existing.processName && processName) existing.processName = processName;
|
||||
return;
|
||||
}
|
||||
const entry = {
|
||||
id: makePortId(protocol, address, port, pid),
|
||||
protocol,
|
||||
address,
|
||||
port,
|
||||
pid,
|
||||
processName,
|
||||
};
|
||||
byKey.set(key, entry);
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
function isWildcardPeer(peer) {
|
||||
const text = String(peer || "").trim();
|
||||
return (
|
||||
text === "*.*"
|
||||
|| text === "*:*"
|
||||
|| text === "0.0.0.0:*"
|
||||
|| text === ":::*"
|
||||
|| text === "[::]:*"
|
||||
|| text === "*."
|
||||
);
|
||||
}
|
||||
|
||||
function parseSsOutput(stdout) {
|
||||
const entries = [];
|
||||
const byKey = new Map();
|
||||
for (const line of String(stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (/^Netid\b/i.test(trimmed) || /^State\b/i.test(trimmed)) continue;
|
||||
const parts = trimmed.split(/\s+/);
|
||||
// Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
|
||||
if (parts.length < 5) continue;
|
||||
let protocol;
|
||||
let state = "";
|
||||
let localAddr;
|
||||
let peerAddr = "";
|
||||
let processField = "";
|
||||
if (/^(tcp|udp)/i.test(parts[0])) {
|
||||
protocol = parts[0];
|
||||
// With state column: parts[1]=state, parts[4]=local, parts[5]=peer
|
||||
if (parts.length >= 6 && (parts[4].includes(":") || parts[4].includes("."))) {
|
||||
state = parts[1] || "";
|
||||
localAddr = parts[4];
|
||||
peerAddr = parts[5] || "";
|
||||
processField = parts.slice(6).join(" ");
|
||||
} else {
|
||||
localAddr = parts[3];
|
||||
peerAddr = parts[4] || "";
|
||||
processField = parts.slice(5).join(" ");
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
const isUdp = /^udp/i.test(protocol);
|
||||
if (!isUdp && state && !/^LISTEN$/i.test(state)) continue;
|
||||
if (isUdp && state && !/^(UNCONN|IDLE|LISTEN)$/i.test(state)) continue;
|
||||
if (isUdp && peerAddr && !isWildcardPeer(peerAddr)) continue;
|
||||
const parsed = parseListenAddress(localAddr);
|
||||
if (!parsed) continue;
|
||||
for (const proc of parseSsProcesses(processField)) {
|
||||
pushPort(entries, byKey, {
|
||||
protocol,
|
||||
address: parsed.address,
|
||||
port: parsed.port,
|
||||
pid: proc.pid,
|
||||
processName: proc.processName,
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseNetstatOutput(stdout) {
|
||||
const entries = [];
|
||||
const byKey = new Map();
|
||||
for (const line of String(stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (/^Proto\b/i.test(trimmed) || /^Active\b/i.test(trimmed)) continue;
|
||||
// Linux: tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1234/sshd
|
||||
// BusyBox UDP often omits state: udp 0 0 127.0.0.1:53 0.0.0.0:* 456/dnsmasq
|
||||
// macOS: tcp4 0 0 *.22 *.* LISTEN
|
||||
const m = trimmed.match(
|
||||
/^(tcp46|udp46|tcp[46]?|udp[46]?)\s+\d+\s+\d+\s+(\S+)\s+(\S+)(?:\s+(.*))?$/i,
|
||||
);
|
||||
if (!m) continue;
|
||||
const protocol = m[1];
|
||||
const local = m[2];
|
||||
const peer = m[3];
|
||||
const rest = String(m[4] || "").trim();
|
||||
const isUdp = /^udp/i.test(protocol);
|
||||
let state = "";
|
||||
let pidField = "";
|
||||
if (rest) {
|
||||
const restParts = rest.split(/\s+/);
|
||||
if (/^LISTEN$/i.test(restParts[0])) {
|
||||
state = "LISTEN";
|
||||
pidField = restParts.slice(1).join(" ");
|
||||
} else if (isUdp && /^\d+\//.test(restParts[0])) {
|
||||
// No State column — remainder is pid/program (may contain spaces).
|
||||
pidField = rest;
|
||||
} else if (!isUdp) {
|
||||
// TCP without LISTEN (ESTABLISHED, or a bare pid token) is not a listener.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!isUdp) {
|
||||
if (!/^LISTEN$/i.test(state)) continue;
|
||||
} else if (!isWildcardPeer(peer)) {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseListenAddress(local);
|
||||
if (!parsed) continue;
|
||||
let pid = null;
|
||||
let processName = "";
|
||||
const pidMatch = String(pidField).match(/^(\d+)\/(.+)$/);
|
||||
if (pidMatch) {
|
||||
pid = Number(pidMatch[1]);
|
||||
processName = pidMatch[2];
|
||||
}
|
||||
pushPort(entries, byKey, {
|
||||
protocol,
|
||||
address: parsed.address,
|
||||
port: parsed.port,
|
||||
pid,
|
||||
processName,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseLsofOutput(stdout) {
|
||||
const entries = [];
|
||||
const byKey = new Map();
|
||||
for (const line of String(stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || /^COMMAND\b/i.test(trimmed)) continue;
|
||||
// COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
||||
const parts = trimmed.split(/\s+/);
|
||||
if (parts.length < 9) continue;
|
||||
const processName = parts[0];
|
||||
const pid = Number(parts[1]);
|
||||
const typeField = String(parts[4] || "").toUpperCase();
|
||||
const nodeField = String(parts[7] || "").toUpperCase();
|
||||
const nameField = parts.slice(8).join(" ");
|
||||
if (nameField.includes("->")) continue;
|
||||
|
||||
const isUdpNode = nodeField === "UDP" || /\bUDP\b/.test(nameField);
|
||||
const isTcpNode = nodeField === "TCP" || /\bTCP\b/.test(nameField) || /\(LISTEN\)/i.test(nameField);
|
||||
if (!isUdpNode && !isTcpNode) continue;
|
||||
if (isTcpNode && !/\(LISTEN\)/i.test(nameField)) continue;
|
||||
|
||||
// NAME forms: "TCP *:80 (LISTEN)", "TCP [::1]:80 (LISTEN)", "*:22 (LISTEN)"
|
||||
const cleaned = nameField
|
||||
.replace(/^(?:TCP|UDP)\s+/i, "")
|
||||
.replace(/\s+\((LISTEN|UDP)\)\s*$/i, "")
|
||||
.trim();
|
||||
const parsed = parseListenAddress(cleaned);
|
||||
if (!parsed) continue;
|
||||
let protocol = isUdpNode ? "udp" : "tcp";
|
||||
// typeField is uppercased; match IPV6 / IPv6 before uppercasing would also work.
|
||||
if (typeField.includes("IPV6") || parsed.address.includes(":")) {
|
||||
protocol = isUdpNode ? "udp6" : "tcp6";
|
||||
}
|
||||
pushPort(entries, byKey, {
|
||||
protocol,
|
||||
address: parsed.address,
|
||||
port: parsed.port,
|
||||
pid: Number.isFinite(pid) ? pid : null,
|
||||
processName,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseWindowsPortsJson(stdout) {
|
||||
const entries = [];
|
||||
const byKey = new Map();
|
||||
const text = String(stdout || "").trim();
|
||||
if (!text) return entries;
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch {
|
||||
return entries;
|
||||
}
|
||||
const list = Array.isArray(raw) ? raw : [raw];
|
||||
for (const row of list) {
|
||||
if (!row) continue;
|
||||
const port = Number(row.LocalPort);
|
||||
const pid = Number(row.OwningProcess);
|
||||
let address = String(row.LocalAddress || "*");
|
||||
const isV6 = address.includes(":");
|
||||
if (address === "0.0.0.0" || address === "::" || address === "*") address = "*";
|
||||
const protoRaw = String(row.Protocol || "tcp").toLowerCase();
|
||||
const isUdp = protoRaw.startsWith("udp");
|
||||
pushPort(entries, byKey, {
|
||||
protocol: isUdp ? (isV6 ? "udp6" : "udp") : (isV6 ? "tcp6" : "tcp"),
|
||||
address,
|
||||
port,
|
||||
pid: Number.isFinite(pid) && pid > 0 ? pid : null,
|
||||
processName: "",
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function extractSection(stdout, beginMarker) {
|
||||
const text = String(stdout || "");
|
||||
const begin = text.indexOf(beginMarker);
|
||||
if (begin < 0) return "";
|
||||
const after = text.slice(begin + beginMarker.length);
|
||||
const end = after.search(/\n__NC_(SS|NETSTAT|LSOF|WIN|PORTS_END)__/);
|
||||
return end >= 0 ? after.slice(0, end) : after;
|
||||
}
|
||||
|
||||
function sortPorts(entries) {
|
||||
return entries.slice().sort((a, b) => a.port - b.port || a.protocol.localeCompare(b.protocol));
|
||||
}
|
||||
|
||||
function parseListeningPorts(stdout) {
|
||||
const text = String(stdout || "");
|
||||
const entries = [];
|
||||
const byKey = new Map();
|
||||
|
||||
const merge = (rows) => {
|
||||
for (const row of rows) {
|
||||
pushPort(entries, byKey, row);
|
||||
}
|
||||
};
|
||||
|
||||
if (text.includes("__NC_SS__")) merge(parseSsOutput(extractSection(text, "__NC_SS__")));
|
||||
if (text.includes("__NC_NETSTAT__")) merge(parseNetstatOutput(extractSection(text, "__NC_NETSTAT__")));
|
||||
if (text.includes("__NC_LSOF__")) merge(parseLsofOutput(extractSection(text, "__NC_LSOF__")));
|
||||
if (text.includes("__NC_WIN__")) merge(parseWindowsPortsJson(extractSection(text, "__NC_WIN__")));
|
||||
|
||||
if (entries.length) return sortPorts(entries);
|
||||
|
||||
// Bare output without markers (fallback probes)
|
||||
merge(parseSsOutput(text));
|
||||
if (entries.length) return sortPorts(entries);
|
||||
merge(parseNetstatOutput(text));
|
||||
if (entries.length) return sortPorts(entries);
|
||||
merge(parseLsofOutput(text));
|
||||
return sortPorts(entries);
|
||||
}
|
||||
|
||||
function createPortOpsApi({
|
||||
execOnSession,
|
||||
execOnLocalMachine,
|
||||
isLocalSession,
|
||||
process,
|
||||
}) {
|
||||
async function listListeningPorts(event, sessionId) {
|
||||
if (!sessionId) return { success: false, error: "Missing sessionId" };
|
||||
|
||||
// Local Windows — already PowerShell (Base64-encoded).
|
||||
if (isLocalSession(sessionId) && process.platform === "win32") {
|
||||
const result = await execOnLocalMachine(PORT_LIST_PS_COMMAND, 12000, {
|
||||
maxBuffer: LISTEN_PORTS_MAX_BUFFER,
|
||||
});
|
||||
if (!result.success) return { success: false, error: result.error || "Failed to list ports" };
|
||||
return { success: true, ports: parseListeningPorts(result.stdout) };
|
||||
}
|
||||
|
||||
// POSIX first (Linux / macOS / BSD).
|
||||
const posixResult = await execOnSession(event, sessionId, LISTEN_PORTS_SCRIPT, 12000, {
|
||||
maxBuffer: LISTEN_PORTS_MAX_BUFFER,
|
||||
});
|
||||
if (posixResult.pending) return { success: false, pending: true };
|
||||
const posixOk = posixResult.success;
|
||||
const posixPorts = posixOk ? parseListeningPorts(posixResult.stdout) : [];
|
||||
|
||||
// POSIX gave zero ports — try Windows PowerShell (Windows OpenSSH host).
|
||||
if (!posixOk || posixPorts.length === 0) {
|
||||
console.log(`[Ports] POSIX failed=${!posixOk}, got ${posixPorts.length} ports → trying PowerShell fallback.`);
|
||||
const psResult = await execOnSession(event, sessionId, PORT_LIST_PS_COMMAND, 12000, {
|
||||
maxBuffer: LISTEN_PORTS_MAX_BUFFER,
|
||||
});
|
||||
if (psResult.pending) return { success: false, pending: true };
|
||||
console.log(`[Ports] PowerShell result: success=${psResult.success}, code=${psResult.code}, error=${JSON.stringify(psResult.error||'').slice(0,120)}, stdoutSnippet=${JSON.stringify((psResult.stdout||'').slice(0,300))}`);
|
||||
if (psResult.success) {
|
||||
const psPorts = parseListeningPorts(psResult.stdout);
|
||||
console.log(`[Ports] PowerShell parsed ${psPorts.length} ports.`);
|
||||
if (psPorts.length > 0) {
|
||||
return { success: true, ports: psPorts };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!posixOk) return { success: false, error: posixResult.error || "Failed to list ports" };
|
||||
return { success: true, ports: posixPorts };
|
||||
}
|
||||
|
||||
return {
|
||||
listListeningPorts,
|
||||
parseListeningPorts,
|
||||
parseSsOutput,
|
||||
parseNetstatOutput,
|
||||
parseLsofOutput,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPortOpsApi,
|
||||
parseListeningPorts,
|
||||
parseSsOutput,
|
||||
parseNetstatOutput,
|
||||
parseLsofOutput,
|
||||
LISTEN_PORTS_SCRIPT,
|
||||
};
|
||||
248
electron/bridges/systemManager/portOps.test.cjs
Normal file
248
electron/bridges/systemManager/portOps.test.cjs
Normal file
@@ -0,0 +1,248 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const {
|
||||
parseSsOutput,
|
||||
parseNetstatOutput,
|
||||
parseLsofOutput,
|
||||
parseListeningPorts,
|
||||
} = require("./portOps.cjs");
|
||||
|
||||
test("parseSsOutput reads tcp/udp listeners and process fields", () => {
|
||||
const sample = `
|
||||
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
|
||||
tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1234,fd=3))
|
||||
tcp LISTEN 0 511 *:80 *:* users:(("nginx",pid=99,fd=6))
|
||||
udp UNCONN 0 0 127.0.0.1:53 0.0.0.0:* users:(("systemd-resolve",pid=500,fd=12))
|
||||
tcp6 LISTEN 0 128 [::]:443 [::]:* users:(("nginx",pid=99,fd=7))
|
||||
`;
|
||||
const ports = parseSsOutput(sample);
|
||||
assert.equal(ports.length, 4);
|
||||
assert.deepEqual(
|
||||
ports.find((p) => p.port === 22),
|
||||
{
|
||||
id: "tcp|*|22|1234",
|
||||
protocol: "tcp",
|
||||
address: "*",
|
||||
port: 22,
|
||||
pid: 1234,
|
||||
processName: "sshd",
|
||||
},
|
||||
);
|
||||
assert.equal(ports.find((p) => p.port === 80)?.processName, "nginx");
|
||||
assert.equal(ports.find((p) => p.port === 53)?.protocol, "udp");
|
||||
assert.equal(ports.find((p) => p.port === 443)?.protocol, "tcp6");
|
||||
});
|
||||
|
||||
test("parseNetstatOutput maps pid/program and keeps BusyBox UDP without PID", () => {
|
||||
const sample = `
|
||||
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
|
||||
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1234/sshd
|
||||
tcp6 0 0 :::80 :::* LISTEN 99/nginx
|
||||
udp 0 0 127.0.0.1:53 0.0.0.0:*
|
||||
udp 0 0 0.0.0.0:5353 0.0.0.0:* 456/dnsmasq
|
||||
`;
|
||||
const ports = parseNetstatOutput(sample);
|
||||
assert.equal(ports.length, 4);
|
||||
assert.equal(ports.find((p) => p.port === 22)?.processName, "sshd");
|
||||
assert.equal(ports.find((p) => p.port === 80)?.address, "*");
|
||||
assert.equal(ports.find((p) => p.port === 53)?.protocol, "udp");
|
||||
assert.equal(ports.find((p) => p.port === 53)?.pid, null);
|
||||
assert.equal(ports.find((p) => p.port === 5353)?.pid, 456);
|
||||
assert.equal(ports.find((p) => p.port === 5353)?.processName, "dnsmasq");
|
||||
});
|
||||
|
||||
test("parseNetstatOutput keeps spaced UDP program names without State", () => {
|
||||
const sample = `
|
||||
udp 0 0 0.0.0.0:5353 0.0.0.0:* 882/avahi-daemon: r
|
||||
udp 0 0 0.0.0.0:53 0.0.0.0:* 100/named -u bind
|
||||
`;
|
||||
const ports = parseNetstatOutput(sample);
|
||||
assert.equal(ports.find((p) => p.port === 5353)?.pid, 882);
|
||||
assert.equal(ports.find((p) => p.port === 5353)?.processName, "avahi-daemon: r");
|
||||
assert.equal(ports.find((p) => p.port === 53)?.pid, 100);
|
||||
assert.equal(ports.find((p) => p.port === 53)?.processName, "named -u bind");
|
||||
});
|
||||
|
||||
test("parseNetstatOutput understands macOS dotted addresses", () => {
|
||||
const sample = `
|
||||
Active Internet connections
|
||||
Proto Recv-Q Send-Q Local Address Foreign Address (state)
|
||||
tcp4 0 0 *.22 *.* LISTEN
|
||||
tcp4 0 0 127.0.0.1.631 *.* LISTEN
|
||||
`;
|
||||
const ports = parseNetstatOutput(sample);
|
||||
assert.equal(ports.length, 2);
|
||||
assert.equal(ports.find((p) => p.port === 22)?.address, "*");
|
||||
assert.equal(ports.find((p) => p.port === 631)?.address, "127.0.0.1");
|
||||
});
|
||||
|
||||
test("parseListeningPorts merges ss/netstat/lsof and prefers process-aware rows", () => {
|
||||
const stdout = `
|
||||
__NC_PORTS_BEGIN__
|
||||
__NC_NETSTAT__
|
||||
tcp4 0 0 *.22 *.* LISTEN
|
||||
__NC_LSOF__
|
||||
sshd 1234 root 3u IPv4 0x1 0t0 TCP *:22 (LISTEN)
|
||||
__NC_PORTS_END__
|
||||
`;
|
||||
const ports = parseListeningPorts(stdout);
|
||||
assert.equal(ports.length, 1);
|
||||
assert.equal(ports[0].port, 22);
|
||||
assert.equal(ports[0].pid, 1234);
|
||||
assert.equal(ports[0].processName, "sshd");
|
||||
});
|
||||
|
||||
test("parseLsofOutput reads TCP LISTEN rows", () => {
|
||||
const sample = `
|
||||
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
||||
nginx 99 root 6u IPv4 0xabc 0t0 TCP *:80 (LISTEN)
|
||||
`;
|
||||
const ports = parseLsofOutput(sample);
|
||||
assert.equal(ports.length, 1);
|
||||
assert.equal(ports[0].port, 80);
|
||||
assert.equal(ports[0].processName, "nginx");
|
||||
});
|
||||
|
||||
test("parseNetstatOutput rejects established TCP and connected UDP", () => {
|
||||
const sample = `
|
||||
tcp 0 0 10.0.0.1:22 10.0.0.2:40000 ESTABLISHED 1234/sshd
|
||||
tcp 0 0 10.0.0.1:22 10.0.0.2:40000 1234/sshd
|
||||
udp 0 0 10.0.0.5:68 10.0.0.1:67
|
||||
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 9/sshd
|
||||
`;
|
||||
const ports = parseNetstatOutput(sample);
|
||||
assert.equal(ports.length, 1);
|
||||
assert.equal(ports[0].port, 22);
|
||||
assert.equal(ports[0].processName, "sshd");
|
||||
});
|
||||
|
||||
test("parseLsofOutput ignores connected UDP and non-listen TCP", () => {
|
||||
const sample = `
|
||||
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
||||
chrome 1000 user 10u IPv4 0x1 0t0 UDP 10.0.0.1:53122->8.8.8.8:53
|
||||
nginx 99 root 6u IPv4 0xabc 0t0 TCP 10.0.0.1:80->10.0.0.2:12345 (ESTABLISHED)
|
||||
sshd 1234 root 3u IPv4 0x2 0t0 TCP *:22 (LISTEN)
|
||||
`;
|
||||
const ports = parseLsofOutput(sample);
|
||||
assert.equal(ports.length, 1);
|
||||
assert.equal(ports[0].port, 22);
|
||||
});
|
||||
|
||||
test("parseLsofOutput keeps bracketed IPv6 listeners as tcp6", () => {
|
||||
const sample = `
|
||||
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
||||
nginx 99 root 7u IPv6 0xabc 0t0 TCP [::1]:80 (LISTEN)
|
||||
`;
|
||||
const ports = parseLsofOutput(sample);
|
||||
assert.equal(ports.length, 1);
|
||||
assert.equal(ports[0].protocol, "tcp6");
|
||||
assert.equal(ports[0].address, "::1");
|
||||
assert.equal(ports[0].port, 80);
|
||||
});
|
||||
|
||||
test("parseNetstatOutput keeps macOS IPv6 dotted ports", () => {
|
||||
const sample = `
|
||||
tcp6 0 0 ::1.631 *.* LISTEN
|
||||
tcp6 0 0 fe80::1%lo0.22 *.* LISTEN
|
||||
`;
|
||||
const ports = parseNetstatOutput(sample);
|
||||
assert.equal(ports.length, 2);
|
||||
assert.equal(ports.find((p) => p.port === 631)?.address, "::1");
|
||||
assert.equal(ports.find((p) => p.port === 22)?.address, "fe80::1");
|
||||
});
|
||||
|
||||
test("parseSsOutput rejects UDP with non-wildcard peers", () => {
|
||||
const sample = `
|
||||
udp UNCONN 0 0 10.0.0.5:68 10.0.0.1:67 users:(("dhclient",pid=7,fd=1))
|
||||
udp UNCONN 0 0 0.0.0.0:5353 0.0.0.0:* users:(("avahi",pid=8,fd=1))
|
||||
`;
|
||||
const ports = parseSsOutput(sample);
|
||||
assert.equal(ports.length, 1);
|
||||
assert.equal(ports[0].port, 5353);
|
||||
});
|
||||
|
||||
test("parseListeningPorts merges ss tcp6 with lsof IPv6 without duplicating", () => {
|
||||
const stdout = `
|
||||
__NC_PORTS_BEGIN__
|
||||
__NC_SS__
|
||||
tcp6 LISTEN 0 128 [::]:80 [::]:* users:(("nginx",pid=99,fd=7))
|
||||
__NC_LSOF__
|
||||
nginx 99 root 7u IPv6 0xabc 0t0 TCP *:80 (LISTEN)
|
||||
__NC_PORTS_END__
|
||||
`;
|
||||
const ports = parseListeningPorts(stdout);
|
||||
assert.equal(ports.length, 1);
|
||||
assert.equal(ports[0].protocol, "tcp6");
|
||||
assert.equal(ports[0].pid, 99);
|
||||
});
|
||||
|
||||
test("parseListeningPorts keeps SO_REUSEPORT listeners as separate pid rows", () => {
|
||||
const sample = `
|
||||
tcp LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* users:(("app",pid=11,fd=3))
|
||||
tcp LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* users:(("app",pid=12,fd=3))
|
||||
`;
|
||||
const ports = parseSsOutput(sample);
|
||||
assert.equal(ports.length, 2);
|
||||
assert.deepEqual(ports.map((p) => p.pid).sort((a, b) => a - b), [11, 12]);
|
||||
});
|
||||
|
||||
test("parseListeningPorts reads Windows UDP endpoints from Protocol field", () => {
|
||||
const stdout = `
|
||||
__NC_PORTS_BEGIN__
|
||||
__NC_WIN__
|
||||
[{"LocalAddress":"0.0.0.0","LocalPort":53,"OwningProcess":500,"Protocol":"udp"},{"LocalAddress":"::","LocalPort":22,"OwningProcess":1234,"Protocol":"tcp"}]
|
||||
__NC_PORTS_END__
|
||||
`;
|
||||
const ports = parseListeningPorts(stdout);
|
||||
assert.equal(ports.length, 2);
|
||||
assert.equal(ports.find((p) => p.port === 53)?.protocol, "udp");
|
||||
assert.equal(ports.find((p) => p.port === 22)?.protocol, "tcp6");
|
||||
});
|
||||
|
||||
test("parseLsofOutput keeps Idle UDP listeners", () => {
|
||||
const sample = `
|
||||
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
||||
mdnsd 200 root 12u IPv4 0x1 0t0 UDP *:5353
|
||||
`;
|
||||
const ports = parseLsofOutput(sample);
|
||||
assert.equal(ports.length, 1);
|
||||
assert.equal(ports[0].protocol, "udp");
|
||||
assert.equal(ports[0].port, 5353);
|
||||
});
|
||||
|
||||
test("parseNetstatOutput accepts macOS tcp46/udp46 dual-stack rows", () => {
|
||||
const sample = `
|
||||
tcp46 0 0 *.80 *.* LISTEN
|
||||
udp46 0 0 *.5353 *.*
|
||||
`;
|
||||
const ports = parseNetstatOutput(sample);
|
||||
assert.equal(ports.length, 2);
|
||||
assert.equal(ports.find((p) => p.port === 80)?.protocol, "tcp6");
|
||||
assert.equal(ports.find((p) => p.port === 5353)?.protocol, "udp6");
|
||||
});
|
||||
|
||||
test("parseListeningPorts merges macOS tcp46 netstat with lsof IPv6", () => {
|
||||
const stdout = `
|
||||
__NC_PORTS_BEGIN__
|
||||
__NC_NETSTAT__
|
||||
tcp46 0 0 *.80 *.* LISTEN
|
||||
__NC_LSOF__
|
||||
nginx 99 root 7u IPv6 0xabc 0t0 TCP *:80 (LISTEN)
|
||||
__NC_PORTS_END__
|
||||
`;
|
||||
const ports = parseListeningPorts(stdout);
|
||||
assert.equal(ports.length, 1);
|
||||
assert.equal(ports[0].protocol, "tcp6");
|
||||
assert.equal(ports[0].pid, 99);
|
||||
});
|
||||
|
||||
test("parseSsOutput expands multi-pid users tuples", () => {
|
||||
const sample = `
|
||||
tcp LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* users:(("app",pid=11,fd=3),("app",pid=12,fd=4))
|
||||
`;
|
||||
const ports = parseSsOutput(sample);
|
||||
assert.equal(ports.length, 2);
|
||||
assert.deepEqual(ports.map((p) => p.pid).sort((a, b) => a - b), [11, 12]);
|
||||
});
|
||||
334
electron/bridges/systemManager/serviceOps.cjs
Normal file
334
electron/bridges/systemManager/serviceOps.cjs
Normal file
@@ -0,0 +1,334 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
"use strict";
|
||||
|
||||
const {
|
||||
SERVICE_LIST_PS_COMMAND,
|
||||
buildServiceActionPsCommand,
|
||||
} = require("./windowsPowerShell.cjs");
|
||||
|
||||
/**
|
||||
* systemd service management over SSH exec.
|
||||
* List/status via systemctl; mutate with optional sudo (same session password
|
||||
* pattern as dockerOps).
|
||||
*/
|
||||
|
||||
function shQuote(str) {
|
||||
return `'${String(str).replace(/'/g, `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
function sanitizeUnitName(name) {
|
||||
const trimmed = String(name || "").trim().slice(0, 256);
|
||||
// systemd unit names: letters, digits, : . _ @ - and must end with a type suffix ideally
|
||||
if (!trimmed || !/^[A-Za-z0-9:._@\\-]+$/.test(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const ALLOWED_ACTIONS = new Set(["start", "stop", "restart", "enable", "disable", "reload"]);
|
||||
|
||||
function normalizeActiveState(raw) {
|
||||
const text = String(raw || "").trim().toLowerCase();
|
||||
if (
|
||||
text === "active"
|
||||
|| text === "inactive"
|
||||
|| text === "failed"
|
||||
|| text === "activating"
|
||||
|| text === "deactivating"
|
||||
|| text === "reloading"
|
||||
) {
|
||||
return text;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function normalizeLoadState(raw) {
|
||||
const text = String(raw || "").trim().toLowerCase();
|
||||
if (
|
||||
text === "loaded"
|
||||
|| text === "not-found"
|
||||
|| text === "bad-setting"
|
||||
|| text === "error"
|
||||
|| text === "masked"
|
||||
) {
|
||||
return text;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function parseSystemctlListUnits(stdout, scope) {
|
||||
const units = [];
|
||||
for (const line of String(stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const cleaned = trimmed.replace(/^●\s*/, "");
|
||||
if (!cleaned || /^UNIT\b/i.test(cleaned) || /^Legend:/i.test(cleaned)) continue;
|
||||
if (/^\d+ loaded units listed/i.test(cleaned)) continue;
|
||||
if (/^To show all/i.test(cleaned)) continue;
|
||||
// UNIT LOAD ACTIVE SUB [DESCRIPTION] — description may be empty
|
||||
const m = cleaned.match(/^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)(?:\s+(.*))?$/);
|
||||
if (!m) continue;
|
||||
const name = m[1];
|
||||
if (!name.includes(".")) continue;
|
||||
units.push({
|
||||
name,
|
||||
loadState: normalizeLoadState(m[2]),
|
||||
activeState: normalizeActiveState(m[3]),
|
||||
subState: m[4],
|
||||
description: (m[5] || "").trim(),
|
||||
scope,
|
||||
});
|
||||
}
|
||||
return units;
|
||||
}
|
||||
|
||||
function getSessionSudoPassword(session) {
|
||||
return typeof session?.systemManagerSudoPassword === "string" && session.systemManagerSudoPassword.length > 0
|
||||
? session.systemManagerSudoPassword
|
||||
: null;
|
||||
}
|
||||
|
||||
function isSuccessfulCommandResult(result) {
|
||||
return result?.success && (result.code === 0 || result.code === null || result.code === undefined);
|
||||
}
|
||||
|
||||
function commandError(result, fallback) {
|
||||
return (result?.stderr || result?.error || "").trim() || fallback;
|
||||
}
|
||||
|
||||
function isPermissionDenied(result) {
|
||||
const text = `${result?.stderr || ""}\n${result?.stdout || ""}\n${result?.error || ""}`.toLowerCase();
|
||||
return text.includes("permission denied")
|
||||
|| text.includes("access denied")
|
||||
|| text.includes("authentication is required")
|
||||
|| text.includes("interactive authentication required")
|
||||
|| text.includes("not authorized");
|
||||
}
|
||||
|
||||
const LIST_UNITS_INNER = [
|
||||
'printf "%s\\n" "__NC_SERVICES_BEGIN__"; ',
|
||||
'if command -v systemctl >/dev/null 2>&1; then ',
|
||||
'printf "%s\\n" "__NC_SYSTEM__"; ',
|
||||
// --plain needs systemd >= ~230; fall back for RHEL/CentOS 7-era hosts.
|
||||
"systemctl list-units --type=service --all --no-pager --no-legend --plain 2>/dev/null ",
|
||||
"|| systemctl list-units --type=service --all --no-pager --no-legend 2>/dev/null ",
|
||||
"|| true; ",
|
||||
'printf "%s\\n" "__NC_USER__"; ',
|
||||
"systemctl --user list-units --type=service --all --no-pager --no-legend --plain 2>/dev/null ",
|
||||
"|| systemctl --user list-units --type=service --all --no-pager --no-legend 2>/dev/null ",
|
||||
"|| true; ",
|
||||
"fi; ",
|
||||
'printf "%s\\n" "__NC_SERVICES_END__"',
|
||||
].join("");
|
||||
|
||||
const LIST_UNITS_SCRIPT = `exec sh -c ${JSON.stringify(LIST_UNITS_INNER)}`;
|
||||
|
||||
function extractBetween(stdout, startMarker, endMarkers) {
|
||||
const text = String(stdout || "");
|
||||
const begin = text.indexOf(startMarker);
|
||||
if (begin < 0) return "";
|
||||
const after = text.slice(begin + startMarker.length);
|
||||
let end = -1;
|
||||
for (const marker of endMarkers) {
|
||||
const idx = after.indexOf(marker);
|
||||
if (idx >= 0 && (end < 0 || idx < end)) end = idx;
|
||||
}
|
||||
return end >= 0 ? after.slice(0, end) : after;
|
||||
}
|
||||
|
||||
function parseServiceList(stdout) {
|
||||
const text = String(stdout || "");
|
||||
const systemPart = extractBetween(text, "__NC_SYSTEM__", ["__NC_USER__", "__NC_SERVICES_END__"]);
|
||||
const userPart = extractBetween(text, "__NC_USER__", ["__NC_SERVICES_END__"]);
|
||||
const systemUnits = parseSystemctlListUnits(systemPart, "system");
|
||||
const userUnits = parseSystemctlListUnits(userPart, "user");
|
||||
// Prefer system unit when names collide
|
||||
const seen = new Set(systemUnits.map((u) => u.name));
|
||||
const merged = systemUnits.slice();
|
||||
for (const unit of userUnits) {
|
||||
if (seen.has(unit.name)) continue;
|
||||
seen.add(unit.name);
|
||||
merged.push(unit);
|
||||
}
|
||||
merged.sort((a, b) => {
|
||||
if (a.activeState === "failed" && b.activeState !== "failed") return -1;
|
||||
if (b.activeState === "failed" && a.activeState !== "failed") return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Parse Windows Get-Service JSON output (from SERVICE_LIST_PS_COMMAND). */
|
||||
function parseWindowsServices(stdout) {
|
||||
const text = String(stdout || "").trim();
|
||||
if (!text) return [];
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
||||
|
||||
// Windows ServiceControllerStatus enum:
|
||||
// 0=Stopped, 1=StartPending, 2=StopPending, 3=Running,
|
||||
// 4=ContinuePending, 5=PausePending, 6=Paused
|
||||
const STATUS_NUMBER_TO_NAME = {
|
||||
0: "stopped",
|
||||
1: "startpending",
|
||||
2: "stoppending",
|
||||
3: "running",
|
||||
4: "continuepending",
|
||||
5: "pausepending",
|
||||
6: "paused",
|
||||
};
|
||||
|
||||
const units = list.map((s) => {
|
||||
const name = String(s.Name || "");
|
||||
if (!name) return null;
|
||||
let status = String(s.Status || "").toLowerCase();
|
||||
// Handle numeric enum values from ConvertTo-Json (older PS / direct enum cast)
|
||||
if (!status || /^\d+$/.test(status)) {
|
||||
const num = Number(status);
|
||||
status = STATUS_NUMBER_TO_NAME[num] || "unknown";
|
||||
}
|
||||
const startType = String(s.StartType || "").toLowerCase();
|
||||
let activeState = "unknown";
|
||||
if (status === "running" || status === "continuepending") activeState = "active";
|
||||
else if (status === "stopped" || status === "stoppending" || status === "paused" || status === "pausepending" || status === "startpending") activeState = "inactive";
|
||||
let loadState = "loaded";
|
||||
if (startType === "disabled") loadState = "loaded";
|
||||
const subState = status || "unknown";
|
||||
return {
|
||||
name,
|
||||
loadState,
|
||||
activeState,
|
||||
subState,
|
||||
description: String(s.DisplayName || ""),
|
||||
scope: "system",
|
||||
};
|
||||
}).filter(Boolean);
|
||||
units.sort((a, b) => {
|
||||
if (a.activeState === "failed" && b.activeState !== "failed") return -1;
|
||||
if (b.activeState === "failed" && a.activeState !== "failed") return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return units;
|
||||
}
|
||||
|
||||
function createServiceOpsApi({
|
||||
execOnSession,
|
||||
getSession,
|
||||
}) {
|
||||
async function listServices(event, sessionId) {
|
||||
if (!sessionId) return { success: false, error: "Missing sessionId" };
|
||||
|
||||
// POSIX first (systemd).
|
||||
const posixResult = await execOnSession(event, sessionId, LIST_UNITS_SCRIPT, 20000, {
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
if (posixResult.pending) return { success: false, pending: true };
|
||||
const posixOk = posixResult.success;
|
||||
const posixUnits = posixOk ? parseServiceList(posixResult.stdout) : [];
|
||||
|
||||
// POSIX gave zero services — try Windows PowerShell Get-Service.
|
||||
if (!posixOk || posixUnits.length === 0) {
|
||||
console.log(`[Services] POSIX failed=${!posixOk}, got ${posixUnits.length} units → trying PowerShell fallback.`);
|
||||
const psResult = await execOnSession(event, sessionId, SERVICE_LIST_PS_COMMAND, 15000, {
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
if (psResult.pending) return { success: false, pending: true };
|
||||
console.log(`[Services] PowerShell result: success=${psResult.success}, code=${psResult.code}, error=${JSON.stringify(psResult.error||'').slice(0,120)}, stdoutSnippet=${JSON.stringify((psResult.stdout||'').slice(0,300))}`);
|
||||
if (psResult.success) {
|
||||
const winUnits = parseWindowsServices(psResult.stdout);
|
||||
console.log(`[Services] PowerShell parsed ${winUnits.length} services.`);
|
||||
if (winUnits.length > 0) {
|
||||
return { success: true, units: winUnits };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!posixOk) return { success: false, error: posixResult.error || "Failed to list services" };
|
||||
return { success: true, units: posixUnits };
|
||||
}
|
||||
|
||||
async function serviceAction(event, payload) {
|
||||
const sessionId = payload?.sessionId;
|
||||
const unitName = sanitizeUnitName(payload?.unitName);
|
||||
const action = String(payload?.action || "").toLowerCase();
|
||||
const scope = payload?.scope === "user" ? "user" : "system";
|
||||
if (!sessionId || !unitName) return { success: false, error: "Missing sessionId or unitName" };
|
||||
if (!ALLOWED_ACTIONS.has(action)) return { success: false, error: "Invalid action" };
|
||||
|
||||
const userFlag = scope === "user" ? "--user " : "";
|
||||
const baseCmd = `systemctl ${userFlag}${action} ${shQuote(unitName)}`;
|
||||
const wrapped = `exec sh -c ${JSON.stringify(baseCmd)}`;
|
||||
|
||||
let result = await execOnSession(event, sessionId, wrapped, 30000);
|
||||
if (result.pending) return { success: false, pending: true };
|
||||
if (isSuccessfulCommandResult(result)) return { success: true };
|
||||
|
||||
// User-scope units should not escalate via sudo.
|
||||
if (scope !== "user" && isPermissionDenied(result)) {
|
||||
const sudoPassword = getSessionSudoPassword(getSession?.(sessionId));
|
||||
const passwordless = `exec sh -c ${JSON.stringify(`sudo systemctl ${action} ${shQuote(unitName)}`)}`;
|
||||
const passwordlessResult = await execOnSession(event, sessionId, passwordless, 30000);
|
||||
if (passwordlessResult.pending) return { success: false, pending: true };
|
||||
if (isSuccessfulCommandResult(passwordlessResult)) return { success: true };
|
||||
|
||||
if (sudoPassword) {
|
||||
const withPassword = `exec sh -c ${JSON.stringify(`sudo -S -p '' systemctl ${action} ${shQuote(unitName)}`)}`;
|
||||
const sudoResult = await execOnSession(event, sessionId, withPassword, 30000, {
|
||||
stdin: `${sudoPassword}\n`,
|
||||
});
|
||||
if (sudoResult.pending) return { success: false, pending: true };
|
||||
if (isSuccessfulCommandResult(sudoResult)) return { success: true };
|
||||
return { success: false, error: commandError(sudoResult, `sudo systemctl ${action} failed`) };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: commandError(
|
||||
passwordlessResult.success === false ? passwordlessResult : result,
|
||||
`systemctl ${action} failed (sudo required)`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// systemctl failed for a non-POSIX reason (Windows host, systemctl not found).
|
||||
// Try Windows PowerShell service action.
|
||||
if (!isPermissionDenied(result)) {
|
||||
const psCmd = buildServiceActionPsCommand(action, unitName);
|
||||
if (psCmd) {
|
||||
const psResult = await execOnSession(event, sessionId, psCmd, 30000);
|
||||
if (psResult.pending) return { success: false, pending: true };
|
||||
if (isSuccessfulCommandResult(psResult)) return { success: true };
|
||||
if (!psResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: commandError(psResult, `PowerShell ${action} service failed`),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: commandError(result, `systemctl ${action} failed`) };
|
||||
}
|
||||
|
||||
return {
|
||||
listServices,
|
||||
serviceAction,
|
||||
parseServiceList,
|
||||
parseSystemctlListUnits,
|
||||
parseWindowsServices,
|
||||
sanitizeUnitName,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createServiceOpsApi,
|
||||
parseServiceList,
|
||||
parseSystemctlListUnits,
|
||||
parseWindowsServices,
|
||||
sanitizeUnitName,
|
||||
LIST_UNITS_SCRIPT,
|
||||
};
|
||||
59
electron/bridges/systemManager/serviceOps.test.cjs
Normal file
59
electron/bridges/systemManager/serviceOps.test.cjs
Normal file
@@ -0,0 +1,59 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const {
|
||||
parseSystemctlListUnits,
|
||||
parseServiceList,
|
||||
sanitizeUnitName,
|
||||
} = require("./serviceOps.cjs");
|
||||
|
||||
test("sanitizeUnitName rejects shell metacharacters", () => {
|
||||
assert.equal(sanitizeUnitName("nginx.service"), "nginx.service");
|
||||
assert.equal(sanitizeUnitName("user@1000.service"), "user@1000.service");
|
||||
assert.equal(sanitizeUnitName("evil;rm -rf /"), null);
|
||||
assert.equal(sanitizeUnitName(""), null);
|
||||
});
|
||||
|
||||
test("parseSystemctlListUnits reads plain list-units rows", () => {
|
||||
const sample = `
|
||||
nginx.service loaded active running A high performance web server
|
||||
● broken.service loaded failed failed Broken unit
|
||||
cron.service loaded inactive dead Regular background program processing daemon
|
||||
`;
|
||||
const units = parseSystemctlListUnits(sample, "system");
|
||||
assert.equal(units.length, 3);
|
||||
assert.equal(units[0].name, "nginx.service");
|
||||
assert.equal(units[0].activeState, "active");
|
||||
assert.equal(units[1].name, "broken.service");
|
||||
assert.equal(units[1].activeState, "failed");
|
||||
assert.equal(units[2].activeState, "inactive");
|
||||
});
|
||||
|
||||
test("parseSystemctlListUnits keeps units with an empty description", () => {
|
||||
const sample = `
|
||||
● broken.service loaded failed failed
|
||||
quiet.service loaded active running
|
||||
`;
|
||||
const units = parseSystemctlListUnits(sample, "system");
|
||||
assert.equal(units.length, 2);
|
||||
assert.equal(units.find((u) => u.name === "broken.service")?.description, "");
|
||||
assert.equal(units.find((u) => u.name === "quiet.service")?.activeState, "active");
|
||||
});
|
||||
|
||||
test("parseServiceList merges system and user scopes and sorts failed first", () => {
|
||||
const stdout = `
|
||||
__NC_SERVICES_BEGIN__
|
||||
__NC_SYSTEM__
|
||||
broken.service loaded failed failed Broken
|
||||
nginx.service loaded active running Nginx
|
||||
__NC_USER__
|
||||
podman.service loaded active running Podman
|
||||
nginx.service loaded active running User nginx
|
||||
__NC_SERVICES_END__
|
||||
`;
|
||||
const units = parseServiceList(stdout);
|
||||
assert.equal(units[0].name, "broken.service");
|
||||
assert.equal(units.find((u) => u.name === "nginx.service")?.scope, "system");
|
||||
assert.equal(units.find((u) => u.name === "podman.service")?.scope, "user");
|
||||
});
|
||||
194
electron/bridges/systemManager/tmuxEnv.cjs
Normal file
194
electron/bridges/systemManager/tmuxEnv.cjs
Normal file
@@ -0,0 +1,194 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
function shQuote(str) {
|
||||
return `'${String(str).replace(/'/g, `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
function wrapLoginShell(command) {
|
||||
const oneLine = String(command || "")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
return `bash -lc ${JSON.stringify(oneLine)}`;
|
||||
}
|
||||
|
||||
function wrapShExec(command) {
|
||||
const oneLine = String(command || "")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
return `exec sh -c ${JSON.stringify(oneLine)}`;
|
||||
}
|
||||
|
||||
function stripAnsi(text) {
|
||||
return String(text || "").replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
|
||||
}
|
||||
|
||||
function parseTmuxVersionString(text) {
|
||||
const match = stripAnsi(text).match(/tmux\s+(\d+)\.(\d+)([a-z0-9]*)/i);
|
||||
if (!match) {
|
||||
return { raw: stripAnsi(text).trim(), major: 0, minor: 0, patch: "" };
|
||||
}
|
||||
return {
|
||||
raw: match[0],
|
||||
major: Number(match[1]) || 0,
|
||||
minor: Number(match[2]) || 0,
|
||||
patch: match[3] || "",
|
||||
};
|
||||
}
|
||||
|
||||
function getListSessionsFormat(version) {
|
||||
const major = version?.major ?? 0;
|
||||
const minor = version?.minor ?? 0;
|
||||
|
||||
if (major < 2) return null;
|
||||
|
||||
const fields = ["#{session_name}", "#{session_windows}", "#{session_attached}"];
|
||||
|
||||
if (major >= 3 || (major === 2 && minor >= 1)) {
|
||||
fields.push("#{session_created}", "#{session_activity}");
|
||||
}
|
||||
|
||||
if (major > 3 || (major === 3 && minor >= 2)) {
|
||||
fields.push("#{session_group}");
|
||||
}
|
||||
|
||||
return fields.join("\\t");
|
||||
}
|
||||
|
||||
// Single-line script — multiline strings break when passed through bash -lc JSON quoting.
|
||||
const TMUX_DETECT_SCRIPT = [
|
||||
"uid=$(id -u 2>/dev/null || echo 0)",
|
||||
"echo \"__TMUX_VERSION__=$(tmux -V 2>/dev/null || true)\"",
|
||||
"echo \"__TMUX_BIN__=$(command -v tmux 2>/dev/null || which tmux 2>/dev/null || true)\"",
|
||||
"for d in \"${TMUX_TMPDIR:-/tmp}/tmux-$uid\" \"/tmp/tmux-$uid\"; do",
|
||||
"[ -d \"$d\" ] || continue",
|
||||
"for s in \"$d\"/*; do [ -S \"$s\" ] && echo \"__SOCKET__=$s\"; done",
|
||||
"done",
|
||||
].join("; ");
|
||||
|
||||
function parseDetectScriptOutput(stdout) {
|
||||
const info = {
|
||||
version: { raw: "", major: 0, minor: 0, patch: "" },
|
||||
binary: "",
|
||||
sockets: [],
|
||||
};
|
||||
|
||||
for (const line of stripAnsi(stdout).split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (trimmed.startsWith("__TMUX_VERSION__=")) {
|
||||
info.version = parseTmuxVersionString(trimmed.slice("__TMUX_VERSION__=".length));
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith("__TMUX_BIN__=")) {
|
||||
info.binary = trimmed.slice("__TMUX_BIN__=".length).trim();
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith("__SOCKET__=")) {
|
||||
info.sockets.push(trimmed.slice("__SOCKET__=".length).trim());
|
||||
}
|
||||
}
|
||||
|
||||
info.sockets = [...new Set(info.sockets.filter(Boolean))];
|
||||
return info;
|
||||
}
|
||||
|
||||
function normalizeExecResult(result) {
|
||||
if (!result) return { success: false, error: "No exec result" };
|
||||
const stdout = stripAnsi(result.stdout || "");
|
||||
const stderr = stripAnsi(result.stderr || "");
|
||||
const combined = [stderr, stdout].filter(Boolean).join("\n").trim();
|
||||
if (!result.success && combined) {
|
||||
return {
|
||||
...result,
|
||||
success: true,
|
||||
stdout: combined,
|
||||
stderr,
|
||||
code: result.code ?? 1,
|
||||
};
|
||||
}
|
||||
return { ...result, stdout: combined || stdout, stderr };
|
||||
}
|
||||
|
||||
function buildTmuxInvocation(binary, socketPath, args) {
|
||||
const bin = binary || "tmux";
|
||||
const socketFlag = socketPath ? `-S ${shQuote(socketPath)} ` : "";
|
||||
return `${bin} ${socketFlag}${args}`.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
// tmux diagnostics that must never be mistaken for session names — a stale
|
||||
// socket makes `tmux ls 2>&1` print "error connecting to /tmp/tmux-0/default
|
||||
// (No such file or directory)", which the bare-name fallback below would
|
||||
// otherwise turn into a phantom session row.
|
||||
const TMUX_DIAGNOSTIC_LINE = /^(error connecting to|no server running|no current client|can't find|lost server|server exited|failed to connect|protocol version mismatch|open terminal failed|invalid option|usage:|unknown command)/i;
|
||||
|
||||
function isTmuxDiagnosticLine(line) {
|
||||
return TMUX_DIAGNOSTIC_LINE.test(String(line || "").trim());
|
||||
}
|
||||
|
||||
function parseListOutput(stdout) {
|
||||
const text = stripAnsi(stdout);
|
||||
const plain = [];
|
||||
const lines = text.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (isTmuxDiagnosticLine(trimmed)) continue;
|
||||
const match = trimmed.match(/^([^:]+):\s*(\d+)\s+windows?\b/i);
|
||||
if (match) {
|
||||
plain.push({
|
||||
name: match[1].trim(),
|
||||
windows: Number(match[2]) || 0,
|
||||
attached: /\battached\b/i.test(trimmed),
|
||||
created: 0,
|
||||
activity: "",
|
||||
group: "",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const parts = trimmed.split("\t");
|
||||
if (parts.length >= 4) {
|
||||
plain.push({
|
||||
name: parts[0].trim(),
|
||||
windows: Number(parts[1]) || 0,
|
||||
attached: parts[2] === "1",
|
||||
created: Number(parts[3]) || 0,
|
||||
activity: parts[4] || "",
|
||||
group: parts[5] || "",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (parts.length === 1 && !trimmed.includes(":")) {
|
||||
plain.push({
|
||||
name: trimmed,
|
||||
windows: 0,
|
||||
attached: false,
|
||||
created: 0,
|
||||
activity: "",
|
||||
group: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
return plain;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
shQuote,
|
||||
wrapLoginShell,
|
||||
wrapShExec,
|
||||
stripAnsi,
|
||||
parseTmuxVersionString,
|
||||
getListSessionsFormat,
|
||||
TMUX_DETECT_SCRIPT,
|
||||
parseDetectScriptOutput,
|
||||
normalizeExecResult,
|
||||
buildTmuxInvocation,
|
||||
parseListOutput,
|
||||
isTmuxDiagnosticLine,
|
||||
TMUX_DIAGNOSTIC_LINE,
|
||||
};
|
||||
272
electron/bridges/systemManager/tmuxEnv.test.cjs
Normal file
272
electron/bridges/systemManager/tmuxEnv.test.cjs
Normal file
@@ -0,0 +1,272 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
parseTmuxVersionString,
|
||||
getListSessionsFormat,
|
||||
parseDetectScriptOutput,
|
||||
normalizeExecResult,
|
||||
parseListOutput,
|
||||
wrapLoginShell,
|
||||
} = require("./tmuxEnv.cjs");
|
||||
|
||||
test("parseTmuxVersionString handles tmux 3.0a", () => {
|
||||
const v = parseTmuxVersionString("tmux 3.0a");
|
||||
assert.equal(v.major, 3);
|
||||
assert.equal(v.minor, 0);
|
||||
assert.equal(v.patch, "a");
|
||||
});
|
||||
|
||||
test("wrapLoginShell flattens multiline scripts", () => {
|
||||
const wrapped = wrapLoginShell("echo one\necho two");
|
||||
assert.ok(!wrapped.includes("\\n"));
|
||||
assert.ok(wrapped.includes("; echo two"));
|
||||
});
|
||||
|
||||
test("parseListOutput parses default tmux ls line", () => {
|
||||
const sample = "test-session: 1 windows (created Thu Jun 11 00:38:14 2026)\n";
|
||||
const sessions = parseListOutput(sample);
|
||||
assert.equal(sessions.length, 1);
|
||||
assert.equal(sessions[0].name, "test-session");
|
||||
assert.equal(sessions[0].windows, 1);
|
||||
});
|
||||
|
||||
test("getListSessionsFormat omits session_group before tmux 3.2", () => {
|
||||
const fmt = getListSessionsFormat({ major: 3, minor: 0 });
|
||||
assert.ok(fmt.includes("session_name"));
|
||||
assert.ok(!fmt.includes("session_group"));
|
||||
});
|
||||
|
||||
test("parseDetectScriptOutput reads version and sockets", () => {
|
||||
const stdout = [
|
||||
"__TMUX_VERSION__=tmux 3.0a",
|
||||
"__TMUX_BIN__=/usr/bin/tmux",
|
||||
"__SOCKET__=/tmp/tmux-0/default",
|
||||
].join("\n");
|
||||
const parsed = parseDetectScriptOutput(stdout);
|
||||
assert.equal(parsed.version.major, 3);
|
||||
assert.equal(parsed.binary, "/usr/bin/tmux");
|
||||
assert.deepEqual(parsed.sockets, ["/tmp/tmux-0/default"]);
|
||||
});
|
||||
|
||||
test("parseListOutput ignores tmux diagnostic lines", () => {
|
||||
const sample = [
|
||||
"error connecting to /tmp/tmux-0/default (No such file or directory)",
|
||||
"no server running on /private/tmp/tmux-501/default",
|
||||
"can't find session: missing",
|
||||
"test-session: 1 windows (created Thu Jun 11 00:38:14 2026)",
|
||||
].join("\n");
|
||||
const sessions = parseListOutput(sample);
|
||||
assert.equal(sessions.length, 1);
|
||||
assert.equal(sessions[0].name, "test-session");
|
||||
});
|
||||
|
||||
test("parseListOutput returns nothing for a lone stale-socket error", () => {
|
||||
const sessions = parseListOutput(
|
||||
"error connecting to /tmp/tmux-0/default (No such file or directory)\n",
|
||||
);
|
||||
assert.deepEqual(sessions, []);
|
||||
});
|
||||
|
||||
test("isNoTmuxServerMessage matches stale-socket connect errors", () => {
|
||||
const { isNoTmuxServerMessage } = require("./tmuxOps.cjs");
|
||||
assert.equal(
|
||||
isNoTmuxServerMessage("error connecting to /tmp/tmux-0/default (No such file or directory)", 1),
|
||||
true,
|
||||
);
|
||||
assert.equal(isNoTmuxServerMessage("no server running on /tmp/tmux-501/default", 1), true);
|
||||
assert.equal(isNoTmuxServerMessage("test-session: 1 windows", 1), false);
|
||||
assert.equal(isNoTmuxServerMessage("error connecting to /tmp/tmux-0/default (No such file or directory)", 0), false);
|
||||
});
|
||||
|
||||
test("mutating tmux commands execute exactly once on silent success", async () => {
|
||||
const { createTmuxOpsApi } = require("./tmuxOps.cjs");
|
||||
const executed = [];
|
||||
const api = createTmuxOpsApi({
|
||||
execOnSession: async (_event, _sessionId, command) => {
|
||||
executed.push(command);
|
||||
// Silent success, the normal result for kill-session/send-keys/split-window.
|
||||
return { success: true, stdout: "", stderr: "", code: 0 };
|
||||
},
|
||||
});
|
||||
const result = await api.tmuxAction(null, {
|
||||
sessionId: "s1",
|
||||
action: "killSession",
|
||||
sessionName: "demo",
|
||||
});
|
||||
assert.equal(result.success, true);
|
||||
const killRuns = executed.filter((cmd) => cmd.includes("kill-session"));
|
||||
assert.equal(killRuns.length, 1, `kill-session ran ${killRuns.length} times: ${executed.join(" | ")}`);
|
||||
});
|
||||
|
||||
test("parseTmuxWindowsPlain parses default tmux list-windows output", () => {
|
||||
const { parseTmuxWindowsPlain } = require("./tmuxOps.cjs");
|
||||
const sample = [
|
||||
"0: bash* (2 panes) [160x40] [b33d,1]",
|
||||
"1: zsh (1 pane) [160x40] [b33d,2]",
|
||||
].join("\n");
|
||||
const windows = parseTmuxWindowsPlain(sample);
|
||||
assert.equal(windows.length, 2);
|
||||
assert.equal(windows[0].name, "bash");
|
||||
assert.equal(windows[0].panes, 2);
|
||||
assert.equal(windows[0].active, true);
|
||||
assert.equal(windows[1].name, "zsh");
|
||||
});
|
||||
|
||||
test("parseTmuxWindows falls back to plain output when -F tabs are missing", () => {
|
||||
const { parseTmuxWindows } = require("./tmuxOps.cjs");
|
||||
const windows = parseTmuxWindows("0: main* (2 panes) [80x24]");
|
||||
assert.equal(windows.length, 1);
|
||||
assert.equal(windows[0].panes, 2);
|
||||
});
|
||||
|
||||
test("parseTmuxWindows reads list-windows output from stderr", () => {
|
||||
const { parseTmuxWindows } = require("./tmuxOps.cjs");
|
||||
const windows = parseTmuxWindows("0: main* (2 panes) [80x24]");
|
||||
assert.equal(windows.length, 1);
|
||||
assert.equal(windows[0].name, "main");
|
||||
});
|
||||
|
||||
test("list-windows tries alternate socket when default returns empty", async () => {
|
||||
const { createTmuxOpsApi } = require("./tmuxOps.cjs");
|
||||
const api = createTmuxOpsApi({
|
||||
execOnSession: async (_event, _sessionId, command) => {
|
||||
if (command.includes("TMUX_DETECT") || command.includes("__SOCKET__") || command.includes("__TMUX_")) {
|
||||
return {
|
||||
success: true,
|
||||
stdout: "__TMUX_VERSION__=tmux 3.0a\n__SOCKET__=/tmp/tmux-0/custom\n",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
};
|
||||
}
|
||||
if (command.includes("-S '/tmp/tmux-0/custom'") && command.includes("list-windows")) {
|
||||
return { success: true, stdout: "0: main* (2 panes) [80x24]", stderr: "", code: 0 };
|
||||
}
|
||||
if (command.includes("list-windows")) {
|
||||
return { success: true, stdout: "", stderr: "", code: 0 };
|
||||
}
|
||||
return { success: true, stdout: "tmux 3.0a", stderr: "", code: 0 };
|
||||
},
|
||||
});
|
||||
const result = await api.listWindows(null, { sessionId: "s1", sessionName: "test-session" });
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.windows.length, 1);
|
||||
assert.equal(result.windows[0].panes, 2);
|
||||
});
|
||||
|
||||
test("parseTmuxWindowsAllPlain parses list-windows -a default output", () => {
|
||||
const { parseTmuxWindowsAllPlain } = require("./tmuxOps.cjs");
|
||||
const sample = [
|
||||
"test-session: 0: bash* (2 panes) [80x24]",
|
||||
"test-session: 1: zsh (1 pane) [80x24]",
|
||||
"other: 0: vim (1 pane) [80x24]",
|
||||
].join("\n");
|
||||
const windows = parseTmuxWindowsAllPlain(sample, "test-session");
|
||||
assert.equal(windows.length, 2);
|
||||
assert.equal(windows[0].name, "bash");
|
||||
assert.equal(windows[1].index, 1);
|
||||
});
|
||||
|
||||
test("list-windows falls back to list-windows -a when -t target misses", async () => {
|
||||
const { createTmuxOpsApi } = require("./tmuxOps.cjs");
|
||||
const api = createTmuxOpsApi({
|
||||
execOnSession: async (_event, _sessionId, command) => {
|
||||
if (command.includes("for d in") || command.includes("__TMUX_")) {
|
||||
return { success: true, stdout: "__TMUX_VERSION__=tmux 3.0a\n", stderr: "", code: 0 };
|
||||
}
|
||||
if (command.includes("list-windows -a")) {
|
||||
return {
|
||||
success: true,
|
||||
stdout: "test-session: 0: main* (2 panes) [80x24]\ntest-session: 1: aux (1 pane) [80x24]",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
};
|
||||
}
|
||||
if (command.includes("list-windows")) {
|
||||
return { success: true, stdout: "can't find session: test-session", stderr: "", code: 1 };
|
||||
}
|
||||
return { success: true, stdout: "tmux 3.0a", stderr: "", code: 0 };
|
||||
},
|
||||
});
|
||||
const result = await api.listWindows(null, { sessionId: "s1", sessionName: "test-session" });
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.windows.length, 2);
|
||||
});
|
||||
|
||||
test("list-windows parses output delivered on stderr", async () => {
|
||||
const { createTmuxOpsApi } = require("./tmuxOps.cjs");
|
||||
const api = createTmuxOpsApi({
|
||||
execOnSession: async (_event, _sessionId, command) => {
|
||||
if (command.includes("TMUX_DETECT") || command.includes("__SOCKET__") || command.includes("__TMUX_") || command.includes("for d in")) {
|
||||
return { success: true, stdout: "__TMUX_VERSION__=tmux 3.0a\n", stderr: "", code: 0 };
|
||||
}
|
||||
if (command.includes("list-windows")) {
|
||||
return {
|
||||
success: true,
|
||||
stdout: "",
|
||||
stderr: "0: remote* (2 panes) [80x24]\n",
|
||||
code: 0,
|
||||
};
|
||||
}
|
||||
return { success: true, stdout: "tmux 3.0a", stderr: "", code: 0 };
|
||||
},
|
||||
});
|
||||
const result = await api.listWindows(null, { sessionId: "s1", sessionName: "test-session" });
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.windows.length, 1);
|
||||
assert.equal(result.windows[0].name, "remote");
|
||||
});
|
||||
|
||||
test("parseTmuxPanes splits literal \\t when remote printf fails", () => {
|
||||
const { parseTmuxPanes } = require("./tmuxOps.cjs");
|
||||
const sample = "0\\tRainYun-0tWTeTRw\\tbash\\t\\t\\t2232702\\t80\\t24";
|
||||
const panes = parseTmuxPanes(sample);
|
||||
assert.equal(panes.length, 1);
|
||||
assert.equal(panes[0].title, "RainYun-0tWTeTRw");
|
||||
assert.equal(panes[0].command, "bash");
|
||||
assert.equal(panes[0].pid, 2232702);
|
||||
assert.equal(panes[0].width, 80);
|
||||
assert.equal(panes[0].height, 24);
|
||||
});
|
||||
|
||||
test("parseTmuxPanesPlain parses default list-panes output", () => {
|
||||
const { parseTmuxPanesPlain } = require("./tmuxOps.cjs");
|
||||
const panes = parseTmuxPanesPlain("0: [80x24]\n1: [80x24] (active)");
|
||||
assert.equal(panes.length, 2);
|
||||
assert.equal(panes[1].active, true);
|
||||
});
|
||||
|
||||
test("list-windows routes tab separators through printf (tmux does not expand \\t in -F)", async () => {
|
||||
const { createTmuxOpsApi } = require("./tmuxOps.cjs");
|
||||
const commands = [];
|
||||
const api = createTmuxOpsApi({
|
||||
execOnSession: async (_event, _sessionId, command) => {
|
||||
commands.push(command);
|
||||
if (command.includes("list-windows")) {
|
||||
// Real tab characters, as printf would produce on the remote host.
|
||||
return { success: true, stdout: "0\tmain\t2\t1\tlayout", stderr: "", code: 0 };
|
||||
}
|
||||
return { success: true, stdout: "tmux 3.0a", stderr: "", code: 0 };
|
||||
},
|
||||
});
|
||||
const result = await api.listWindows(null, { sessionId: "s1", sessionName: "demo" });
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.windows.length, 1);
|
||||
assert.equal(result.windows[0].name, "main");
|
||||
assert.equal(result.windows[0].panes, 2);
|
||||
const listCmd = commands.find((cmd) => cmd.includes("list-windows"));
|
||||
assert.ok(listCmd.includes("$(printf '"), `expected printf-wrapped format, got: ${listCmd}`);
|
||||
});
|
||||
|
||||
test("normalizeExecResult keeps stdout from failed ET-style exec", () => {
|
||||
const normalized = normalizeExecResult({
|
||||
success: false,
|
||||
error: "Command failed",
|
||||
stdout: "",
|
||||
stderr: "test-session: 1 windows (created Thu Jun 11 00:38:14 2026)",
|
||||
code: 1,
|
||||
});
|
||||
assert.equal(normalized.success, true);
|
||||
assert.equal(parseListOutput(normalized.stdout).length, 1);
|
||||
});
|
||||
803
electron/bridges/systemManager/tmuxOps.cjs
Normal file
803
electron/bridges/systemManager/tmuxOps.cjs
Normal file
@@ -0,0 +1,803 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
const {
|
||||
shQuote,
|
||||
wrapLoginShell,
|
||||
wrapShExec,
|
||||
parseTmuxVersionString,
|
||||
getListSessionsFormat,
|
||||
TMUX_DETECT_SCRIPT,
|
||||
parseDetectScriptOutput,
|
||||
normalizeExecResult,
|
||||
buildTmuxInvocation,
|
||||
parseListOutput,
|
||||
isTmuxDiagnosticLine,
|
||||
} = require("./tmuxEnv.cjs");
|
||||
|
||||
function shQuoteLocal(str) {
|
||||
return shQuote(str);
|
||||
}
|
||||
|
||||
function tmuxTarget(sessionName, windowIndex, paneIndex) {
|
||||
const sessionRef = shQuoteLocal(sessionName);
|
||||
if (windowIndex === undefined || windowIndex === null) return sessionRef;
|
||||
const win = Number(windowIndex);
|
||||
if (paneIndex === undefined || paneIndex === null) return `${sessionRef}:${win}`;
|
||||
return `${sessionRef}:${win}.${Number(paneIndex)}`;
|
||||
}
|
||||
|
||||
function sanitizeNewSessionName(name) {
|
||||
const trimmed = String(name || "").trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed.slice(0, 64);
|
||||
}
|
||||
|
||||
function parseTmuxSessions(stdout) {
|
||||
const sessions = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const parts = trimmed.split("\t");
|
||||
if (parts.length < 4) continue;
|
||||
sessions.push({
|
||||
name: parts[0],
|
||||
windows: Number(parts[1]) || 0,
|
||||
attached: parts[2] === "1",
|
||||
created: Number(parts[3]) || 0,
|
||||
activity: parts[4] || "",
|
||||
group: parts[5] || "",
|
||||
});
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/** Fallback parser for default `tmux list-sessions` / `tmux ls` output. */
|
||||
function parseTmuxSessionsPlain(stdout) {
|
||||
const sessions = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const match = trimmed.match(/^([^:]+):\s*(\d+)\s+windows?\b/i);
|
||||
if (!match) continue;
|
||||
sessions.push({
|
||||
name: match[1].trim(),
|
||||
windows: Number(match[2]) || 0,
|
||||
attached: /\battached\b/i.test(trimmed),
|
||||
created: 0,
|
||||
activity: "",
|
||||
group: "",
|
||||
});
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
function parseTmuxSessionNames(stdout) {
|
||||
return (stdout || "")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((name) => ({
|
||||
name,
|
||||
windows: 0,
|
||||
attached: false,
|
||||
created: 0,
|
||||
activity: "",
|
||||
group: "",
|
||||
}));
|
||||
}
|
||||
|
||||
function isNoTmuxServerMessage(text, code) {
|
||||
if (code !== 1) return false;
|
||||
const msg = String(text || "").toLowerCase();
|
||||
if (msg.includes("no server running")) return true;
|
||||
// Stale socket file: "error connecting to /tmp/tmux-0/default (No such file or directory)"
|
||||
return msg.includes("error connecting to") && msg.includes("no such file or directory");
|
||||
}
|
||||
|
||||
/** Split tmux -F rows on real tabs or literal `\t` when remote printf fails. */
|
||||
function splitTmuxFields(line) {
|
||||
const text = String(line || "");
|
||||
if (text.includes("\t")) return text.split("\t");
|
||||
if (text.includes("\\t")) return text.split("\\t");
|
||||
return [text];
|
||||
}
|
||||
|
||||
/** Default `tmux list-windows` lines, e.g. `0: bash* (2 panes) [80x24]`. */
|
||||
function parseTmuxWindowsPlain(stdout) {
|
||||
const windows = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || isTmuxDiagnosticLine(trimmed)) continue;
|
||||
let match = trimmed.match(/^(\d+):\s*(.+?)(\*)?\s+\((\d+)\s+panes?\)/i);
|
||||
if (match) {
|
||||
windows.push({
|
||||
index: Number(match[1]),
|
||||
name: match[2].trim(),
|
||||
panes: Number(match[4]) || 0,
|
||||
active: match[3] === "*",
|
||||
layout: "",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
match = trimmed.match(/^(\d+):\s*(.*?)(\*)?(?:\s+\[[^\]]+\]|\s*$)/);
|
||||
if (!match) continue;
|
||||
windows.push({
|
||||
index: Number(match[1]),
|
||||
name: match[2].trim(),
|
||||
panes: 0,
|
||||
active: match[3] === "*",
|
||||
layout: "",
|
||||
});
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
function parseTmuxWindows(stdout) {
|
||||
const windows = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || isTmuxDiagnosticLine(trimmed)) continue;
|
||||
const parts = splitTmuxFields(trimmed);
|
||||
if (parts.length < 4) continue;
|
||||
windows.push({
|
||||
index: Number(parts[0]),
|
||||
name: parts[1],
|
||||
panes: Number(parts[2]) || 0,
|
||||
active: parts[3] === "1",
|
||||
layout: parts[4] || "",
|
||||
});
|
||||
}
|
||||
if (windows.length > 0) return windows;
|
||||
return parseTmuxWindowsPlain(stdout);
|
||||
}
|
||||
|
||||
function parseTmuxWindowsAll(stdout) {
|
||||
const windows = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || isTmuxDiagnosticLine(trimmed)) continue;
|
||||
const parts = splitTmuxFields(trimmed);
|
||||
if (parts.length < 5) continue;
|
||||
windows.push({
|
||||
session: parts[0].trim(),
|
||||
index: Number(parts[1]),
|
||||
name: parts[2],
|
||||
panes: Number(parts[3]) || 0,
|
||||
active: parts[4] === "1",
|
||||
layout: parts[5] || "",
|
||||
});
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
/** Plain `tmux list-windows -a` lines, e.g. `test-session: 0: bash* (2 panes)`. */
|
||||
function parseTmuxWindowsAllPlain(stdout, sessionName) {
|
||||
const name = String(sessionName || "").trim();
|
||||
const windows = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || isTmuxDiagnosticLine(trimmed)) continue;
|
||||
const match = trimmed.match(/^([^:]+):\s*(\d+):\s*(.+)$/);
|
||||
if (!match || match[1].trim() !== name) continue;
|
||||
const parsed = parseTmuxWindowsPlain(`${match[2]}: ${match[3]}`);
|
||||
if (parsed.length > 0) windows.push(parsed[0]);
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
function filterWindowsForSession(rows, sessionName) {
|
||||
const name = String(sessionName || "").trim();
|
||||
return rows
|
||||
.filter((row) => row.session === name)
|
||||
.map(({ session, ...window }) => window);
|
||||
}
|
||||
|
||||
function parseTmuxPaneRow(parts) {
|
||||
if (parts.length < 5) return null;
|
||||
return {
|
||||
index: Number(parts[0]),
|
||||
title: parts[1] || "",
|
||||
command: parts[2] || "",
|
||||
active: parts[3] === "1" || (parts.length >= 7 && parts[parts.length - 4] === "1"),
|
||||
pid: Number(parts[4]) || (parts.length >= 7 ? Number(parts[parts.length - 3]) : 0) || 0,
|
||||
width: Number(parts[parts.length >= 7 ? parts.length - 2 : 5]) || 0,
|
||||
height: Number(parts[parts.length >= 7 ? parts.length - 1 : 6]) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Default `tmux list-panes` lines, e.g. `0: [80x24]` or `1: [80x24] (active)`. */
|
||||
function parseTmuxPanesPlain(stdout) {
|
||||
const panes = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || isTmuxDiagnosticLine(trimmed)) continue;
|
||||
const match = trimmed.match(/^(\d+):\s*\[(\d+)x(\d+)\]/);
|
||||
if (!match) continue;
|
||||
panes.push({
|
||||
index: Number(match[1]),
|
||||
title: "",
|
||||
command: "",
|
||||
active: /\bactive\b/i.test(trimmed) || trimmed.includes("*"),
|
||||
pid: 0,
|
||||
width: Number(match[2]) || 0,
|
||||
height: Number(match[3]) || 0,
|
||||
});
|
||||
}
|
||||
return panes;
|
||||
}
|
||||
|
||||
function parseTmuxPanes(stdout) {
|
||||
const panes = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || isTmuxDiagnosticLine(trimmed)) continue;
|
||||
const row = parseTmuxPaneRow(splitTmuxFields(trimmed));
|
||||
if (row) panes.push(row);
|
||||
}
|
||||
if (panes.length > 0) return panes;
|
||||
return parseTmuxPanesPlain(stdout);
|
||||
}
|
||||
|
||||
function parseTmuxClients(stdout, sessionName) {
|
||||
const clients = [];
|
||||
for (const line of (stdout || "").split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const parts = trimmed.split("\t");
|
||||
if (parts.length < 4) continue;
|
||||
if (sessionName && parts[3] !== sessionName) continue;
|
||||
clients.push({
|
||||
name: parts[0],
|
||||
tty: parts[1],
|
||||
activity: parts[2],
|
||||
session: parts[3],
|
||||
});
|
||||
}
|
||||
return clients;
|
||||
}
|
||||
|
||||
// Legacy export kept for tests — prefer getListSessionsFormat(version).
|
||||
const TMUX_LIST_SESSIONS_FMT = getListSessionsFormat({ major: 3, minor: 0 });
|
||||
|
||||
const TMUX_LIST_WINDOWS_FMT = "#{window_index}\\t#{window_name}\\t#{window_panes}\\t#{window_active}\\t#{window_layout}";
|
||||
const TMUX_LIST_ALL_WINDOWS_FMT = "#{session_name}\\t#{window_index}\\t#{window_name}\\t#{window_panes}\\t#{window_active}\\t#{window_layout}";
|
||||
const TMUX_LIST_PANES_FMT = "#{pane_index}\\t#{pane_title}\\t#{pane_current_command}\\t#{pane_active}\\t#{pane_pid}\\t#{pane_width}\\t#{pane_height}";
|
||||
const TMUX_LIST_CLIENTS_FMT = "#{client_name}\\t#{client_tty}\\t#{client_activity}\\t#{client_session}";
|
||||
|
||||
/**
|
||||
* tmux does NOT expand \t inside -F format strings — quoting the format
|
||||
* directly emits a literal backslash-t and the tab-split parsers see one
|
||||
* giant field. Route the format through printf on the remote side so the
|
||||
* separators become real tab characters.
|
||||
*/
|
||||
function tmuxFormatArg(format) {
|
||||
return `"$(printf '${format}')"`;
|
||||
}
|
||||
|
||||
function createTmuxOpsApi({ execOnSession }) {
|
||||
/** @type {Map<string, { version: object, binary: string, sockets: string[], detectedAt: number }>} */
|
||||
const envCache = new Map();
|
||||
const ENV_TTL_MS = 60_000;
|
||||
|
||||
async function execShell(event, sessionId, script, timeoutMs = 8000, options = {}) {
|
||||
// retryOnEmptyOutput exists for READ commands where empty stdout means the
|
||||
// wrapper swallowed the output. Mutating commands (kill-session, send-keys,
|
||||
// split-window…) succeed silently — retrying them re-executes the mutation,
|
||||
// so they must pass retryOnEmptyOutput: false.
|
||||
const { retryOnEmptyOutput = true } = options;
|
||||
const attempts = [
|
||||
wrapShExec(script),
|
||||
wrapLoginShell(script),
|
||||
script,
|
||||
];
|
||||
let last = { success: false, error: "No exec result", stdout: "", stderr: "" };
|
||||
for (const cmd of attempts) {
|
||||
last = normalizeExecResult(await execOnSession(event, sessionId, cmd, timeoutMs));
|
||||
if (last.success && (!retryOnEmptyOutput || String(last.stdout || "").trim())) return last;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
async function detectTmuxEnv(event, sessionId, force = false) {
|
||||
const cached = envCache.get(sessionId);
|
||||
if (!force && cached && Date.now() - cached.detectedAt < ENV_TTL_MS) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const env = {
|
||||
version: { raw: "", major: 0, minor: 0, patch: "" },
|
||||
binary: "tmux",
|
||||
sockets: [],
|
||||
preferredSocket: cached?.preferredSocket ?? null,
|
||||
detectedAt: Date.now(),
|
||||
};
|
||||
|
||||
const versionResult = await execShell(event, sessionId, "tmux -V 2>&1", 5000);
|
||||
if (versionResult.success && versionResult.stdout) {
|
||||
env.version = parseTmuxVersionString(versionResult.stdout);
|
||||
}
|
||||
|
||||
const binResult = await execShell(event, sessionId, "command -v tmux 2>/dev/null || which tmux 2>/dev/null", 5000);
|
||||
if (binResult.success && binResult.stdout) {
|
||||
const bin = binResult.stdout.split("\n").map((l) => l.trim()).find(Boolean);
|
||||
if (bin) env.binary = bin;
|
||||
}
|
||||
|
||||
const socketResult = await execShell(
|
||||
event,
|
||||
sessionId,
|
||||
TMUX_DETECT_SCRIPT,
|
||||
8000,
|
||||
);
|
||||
if (socketResult.success) {
|
||||
const parsed = parseDetectScriptOutput(socketResult.stdout);
|
||||
if (parsed.version.raw) env.version = parsed.version;
|
||||
if (parsed.binary) env.binary = parsed.binary;
|
||||
env.sockets = parsed.sockets;
|
||||
}
|
||||
|
||||
envCache.set(sessionId, env);
|
||||
return env;
|
||||
}
|
||||
|
||||
function buildSocketOrder(env) {
|
||||
const order = [];
|
||||
if (env.preferredSocket) order.push(env.preferredSocket);
|
||||
order.push(null);
|
||||
for (const socket of env.sockets || []) {
|
||||
if (socket && !order.includes(socket)) order.push(socket);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
function rememberPreferredSocket(sessionId, env, socketPath) {
|
||||
const next = {
|
||||
...env,
|
||||
preferredSocket: socketPath ?? env.preferredSocket ?? null,
|
||||
detectedAt: Date.now(),
|
||||
};
|
||||
envCache.set(sessionId, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
async function queryTmuxRows(event, sessionId, buildArgVariants, parseRows) {
|
||||
const env = await detectTmuxEnv(event, sessionId);
|
||||
let lastError = "Cannot read tmux data";
|
||||
let lastOutput = "";
|
||||
const tried = [];
|
||||
|
||||
for (const socketPath of buildSocketOrder(env)) {
|
||||
for (const args of buildArgVariants()) {
|
||||
const cmd = buildTmuxInvocation(env.binary, socketPath, args);
|
||||
tried.push(cmd);
|
||||
const result = await execShell(event, sessionId, cmd, 8000);
|
||||
const output = String(result.stdout || result.stderr || "").trim();
|
||||
if (output) lastOutput = output.slice(0, 500);
|
||||
if (isNoTmuxServerMessage(output, result.code)) continue;
|
||||
if (!result.success && !output) {
|
||||
lastError = (result.error || result.stderr || lastError).slice(0, 240);
|
||||
continue;
|
||||
}
|
||||
const rows = parseRows(output);
|
||||
if (rows.length > 0) {
|
||||
rememberPreferredSocket(sessionId, env, socketPath);
|
||||
return { success: true, rows };
|
||||
}
|
||||
if (output) lastError = output.slice(0, 240);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: lastError,
|
||||
debug: {
|
||||
lastOutput,
|
||||
tried: tried.slice(-8),
|
||||
sockets: buildSocketOrder(env),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function execTmux(event, sessionId, args, timeoutMs = 8000, options = {}) {
|
||||
const env = options.env || await detectTmuxEnv(event, sessionId);
|
||||
const shellOptions = { retryOnEmptyOutput: options.retryOnEmptyOutput ?? true };
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(options, "socketPath")) {
|
||||
const cmd = buildTmuxInvocation(env.binary, options.socketPath, args);
|
||||
const result = await execShell(event, sessionId, cmd, timeoutMs, shellOptions);
|
||||
return { ...result, socketPath: options.socketPath ?? null, env };
|
||||
}
|
||||
|
||||
const attempts = buildSocketOrder(env);
|
||||
let lastResult = null;
|
||||
for (const socketPathResolved of attempts) {
|
||||
const cmd = buildTmuxInvocation(env.binary, socketPathResolved, args);
|
||||
const result = await execShell(event, sessionId, cmd, timeoutMs, shellOptions);
|
||||
lastResult = result;
|
||||
if (!result.success) continue;
|
||||
|
||||
const combined = `${result.stderr || ""}\n${result.stdout || ""}`;
|
||||
if (isNoTmuxServerMessage(combined, result.code)) continue;
|
||||
const hasOutput = Boolean((result.stdout || "").trim());
|
||||
if (hasOutput || result.code === 0) {
|
||||
if (hasOutput) rememberPreferredSocket(sessionId, env, socketPathResolved);
|
||||
return { ...result, socketPath: socketPathResolved, env };
|
||||
}
|
||||
}
|
||||
|
||||
return lastResult || { success: false, error: "tmux command failed" };
|
||||
}
|
||||
|
||||
async function runTmux(event, sessionId, args, timeoutMs = 8000) {
|
||||
// No empty-output retry here: runTmux carries every mutating tmux command,
|
||||
// and list-* commands routed through it may legitimately print nothing.
|
||||
const result = await execTmux(event, sessionId, args, timeoutMs, { retryOnEmptyOutput: false });
|
||||
if (!result.success) return result;
|
||||
if (result.code !== 0 && result.code !== null && result.code !== undefined) {
|
||||
return {
|
||||
success: false,
|
||||
error: (result.stderr || result.stdout || "").trim() || `tmux exited with code ${result.code}`,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function listSessions(event, sessionId) {
|
||||
const env = await detectTmuxEnv(event, sessionId, true);
|
||||
const socketPaths = buildSocketOrder(env);
|
||||
let lastOutput = "";
|
||||
|
||||
const buildListCommands = (binary, socketPath) => {
|
||||
const inv = (args) => buildTmuxInvocation(binary, socketPath, args);
|
||||
const cmds = [inv("list-sessions 2>&1"), inv("list-sessions")];
|
||||
const format = getListSessionsFormat(env.version);
|
||||
if (format) cmds.push(inv(`list-sessions -F ${tmuxFormatArg(format)}`));
|
||||
cmds.push(inv("list-sessions -F '#{session_name}'"));
|
||||
return cmds;
|
||||
};
|
||||
|
||||
for (const socketPath of socketPaths) {
|
||||
for (const cmd of buildListCommands(env.binary, socketPath)) {
|
||||
const result = await execShell(event, sessionId, cmd, 8000);
|
||||
const output = String(result.stdout || result.stderr || "").trim();
|
||||
if (output) lastOutput = output;
|
||||
if (!result.success) continue;
|
||||
if (isNoTmuxServerMessage(output, result.code)) continue;
|
||||
|
||||
const sessions = parseListOutput(output);
|
||||
if (sessions.length > 0) {
|
||||
rememberPreferredSocket(sessionId, env, socketPath);
|
||||
return {
|
||||
success: true,
|
||||
sessions,
|
||||
tmuxVersion: env.version.raw || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isNoTmuxServerMessage(lastOutput, 1)) {
|
||||
return { success: true, sessions: [], tmuxVersion: env.version.raw || undefined };
|
||||
}
|
||||
|
||||
const diag = [
|
||||
env.version.raw || "tmux version unknown",
|
||||
env.binary ? `bin=${env.binary}` : null,
|
||||
env.sockets.length ? `sockets=${env.sockets.join(",")}` : "sockets=none",
|
||||
lastOutput ? `last=${lastOutput.slice(0, 240)}` : "last=empty",
|
||||
].filter(Boolean).join("; ");
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Cannot list tmux sessions (${diag})`,
|
||||
tmuxVersion: env.version.raw || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function createSession(event, payload) {
|
||||
const { sessionId, name, command } = payload || {};
|
||||
if (!sessionId || !name) return { success: false, error: "Missing sessionId or name" };
|
||||
const safeName = sanitizeNewSessionName(name);
|
||||
if (!safeName) return { success: false, error: "Invalid session name" };
|
||||
|
||||
envCache.delete(sessionId);
|
||||
const result = await runTmux(event, sessionId, `new-session -d -s ${shQuoteLocal(safeName)}`, 8000);
|
||||
if (!result.success) return { success: false, error: result.error || result.stderr };
|
||||
const cmd = String(command || "").trim();
|
||||
if (cmd) {
|
||||
const sendResult = await runTmux(
|
||||
event,
|
||||
sessionId,
|
||||
`send-keys -t ${shQuoteLocal(safeName)} ${shQuoteLocal(cmd)} C-m`,
|
||||
8000,
|
||||
);
|
||||
if (!sendResult.success) {
|
||||
return { success: false, error: sendResult.error || sendResult.stderr };
|
||||
}
|
||||
}
|
||||
envCache.delete(sessionId);
|
||||
return { success: true, name: safeName };
|
||||
}
|
||||
|
||||
async function listWindows(event, payload) {
|
||||
const { sessionId, sessionName } = payload || {};
|
||||
if (!sessionId || !sessionName) return { success: false, error: "Missing params" };
|
||||
const name = String(sessionName).trim();
|
||||
const target = tmuxTarget(name);
|
||||
const targetExact = tmuxTarget(`=${name}`);
|
||||
|
||||
// Mirror listSessions: force-refresh env and walk the same socket order.
|
||||
const env = await detectTmuxEnv(event, sessionId, true);
|
||||
const socketPaths = buildSocketOrder(env);
|
||||
let lastOutput = "";
|
||||
let lastError = "Cannot list tmux windows";
|
||||
const tried = [];
|
||||
|
||||
const buildCommands = (binary, socketPath) => {
|
||||
const inv = (args) => buildTmuxInvocation(binary, socketPath, args);
|
||||
return [
|
||||
inv(`list-windows -t ${target} -F ${tmuxFormatArg(TMUX_LIST_WINDOWS_FMT)} 2>&1`),
|
||||
inv(`list-windows -t ${target} 2>&1`),
|
||||
inv(`list-windows -t ${targetExact} -F ${tmuxFormatArg(TMUX_LIST_WINDOWS_FMT)} 2>&1`),
|
||||
inv(`list-windows -t ${targetExact} 2>&1`),
|
||||
inv(`list-windows -a -F ${tmuxFormatArg(TMUX_LIST_ALL_WINDOWS_FMT)} 2>&1`),
|
||||
inv(`list-windows -a 2>&1`),
|
||||
inv(`list-windows -t ${target} -F ${tmuxFormatArg(TMUX_LIST_WINDOWS_FMT)}`),
|
||||
inv(`list-windows -t ${target}`),
|
||||
];
|
||||
};
|
||||
|
||||
for (const socketPath of socketPaths) {
|
||||
for (const cmd of buildCommands(env.binary, socketPath)) {
|
||||
tried.push(cmd);
|
||||
const result = await execShell(event, sessionId, cmd, 8000);
|
||||
const output = String(result.stdout || result.stderr || "").trim();
|
||||
if (output) lastOutput = output.slice(0, 500);
|
||||
if (!result.success && !output) {
|
||||
lastError = (result.error || result.stderr || lastError).slice(0, 240);
|
||||
continue;
|
||||
}
|
||||
if (isNoTmuxServerMessage(output, result.code)) continue;
|
||||
|
||||
let windows = [];
|
||||
if (cmd.includes("list-windows -a")) {
|
||||
const formatted = parseTmuxWindowsAll(output);
|
||||
windows = formatted.length > 0
|
||||
? filterWindowsForSession(formatted, name)
|
||||
: parseTmuxWindowsAllPlain(output, name);
|
||||
} else {
|
||||
windows = parseTmuxWindows(output);
|
||||
}
|
||||
|
||||
if (windows.length > 0) {
|
||||
rememberPreferredSocket(sessionId, env, socketPath);
|
||||
return { success: true, windows };
|
||||
}
|
||||
if (output) lastError = output.slice(0, 240);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: lastError,
|
||||
debug: { lastOutput, tried: tried.slice(-8), sockets: socketPaths },
|
||||
};
|
||||
}
|
||||
|
||||
async function listPanes(event, payload) {
|
||||
const { sessionId, sessionName, windowIndex } = payload || {};
|
||||
if (!sessionId || !sessionName || windowIndex === undefined) {
|
||||
return { success: false, error: "Missing params" };
|
||||
}
|
||||
const name = String(sessionName).trim();
|
||||
const target = tmuxTarget(name, windowIndex);
|
||||
|
||||
const env = await detectTmuxEnv(event, sessionId, true);
|
||||
const socketPaths = buildSocketOrder(env);
|
||||
let lastOutput = "";
|
||||
let lastError = "Cannot list tmux panes";
|
||||
const tried = [];
|
||||
|
||||
const buildCommands = (binary, socketPath) => {
|
||||
const inv = (args) => buildTmuxInvocation(binary, socketPath, args);
|
||||
return [
|
||||
inv(`list-panes -t ${target} -F ${tmuxFormatArg(TMUX_LIST_PANES_FMT)} 2>&1`),
|
||||
inv(`list-panes -t ${target} 2>&1`),
|
||||
inv(`list-panes -t ${target} -F ${tmuxFormatArg(TMUX_LIST_PANES_FMT)}`),
|
||||
inv(`list-panes -t ${target}`),
|
||||
];
|
||||
};
|
||||
|
||||
for (const socketPath of socketPaths) {
|
||||
for (const cmd of buildCommands(env.binary, socketPath)) {
|
||||
tried.push(cmd);
|
||||
const result = await execShell(event, sessionId, cmd, 8000);
|
||||
const output = String(result.stdout || result.stderr || "").trim();
|
||||
if (output) lastOutput = output.slice(0, 500);
|
||||
if (!result.success && !output) {
|
||||
lastError = (result.error || result.stderr || lastError).slice(0, 240);
|
||||
continue;
|
||||
}
|
||||
if (isNoTmuxServerMessage(output, result.code)) continue;
|
||||
|
||||
const panes = parseTmuxPanes(output);
|
||||
if (panes.length > 0) {
|
||||
rememberPreferredSocket(sessionId, env, socketPath);
|
||||
return { success: true, panes };
|
||||
}
|
||||
if (output) lastError = output.slice(0, 240);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: lastError,
|
||||
debug: { lastOutput, tried: tried.slice(-8), sockets: socketPaths },
|
||||
};
|
||||
}
|
||||
|
||||
async function listClients(event, payload) {
|
||||
const { sessionId, sessionName } = payload || {};
|
||||
if (!sessionId) return { success: false, error: "Missing sessionId" };
|
||||
const result = await runTmux(
|
||||
event,
|
||||
sessionId,
|
||||
`list-clients -F ${tmuxFormatArg(TMUX_LIST_CLIENTS_FMT)}`,
|
||||
8000,
|
||||
);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
return {
|
||||
success: true,
|
||||
clients: parseTmuxClients(result.stdout, sessionName || undefined),
|
||||
};
|
||||
}
|
||||
|
||||
async function tmuxAction(event, payload) {
|
||||
const { sessionId, action } = payload || {};
|
||||
if (!sessionId || !action) return { success: false, error: "Missing sessionId or action" };
|
||||
|
||||
switch (action) {
|
||||
case "killSession": {
|
||||
const { sessionName } = payload;
|
||||
if (!sessionName) return { success: false, error: "Missing sessionName" };
|
||||
return runTmux(event, sessionId, `kill-session -t ${tmuxTarget(sessionName)}`, 8000);
|
||||
}
|
||||
case "renameSession": {
|
||||
const { sessionName, newName } = payload;
|
||||
const next = sanitizeNewSessionName(newName);
|
||||
if (!sessionName || !next) return { success: false, error: "Missing params" };
|
||||
return runTmux(
|
||||
event,
|
||||
sessionId,
|
||||
`rename-session -t ${tmuxTarget(sessionName)} ${shQuote(next)}`,
|
||||
8000,
|
||||
);
|
||||
}
|
||||
case "detachSession": {
|
||||
const { sessionName } = payload;
|
||||
if (!sessionName) return { success: false, error: "Missing sessionName" };
|
||||
return runTmux(event, sessionId, `detach-client -s ${tmuxTarget(sessionName)}`, 8000);
|
||||
}
|
||||
case "createWindow": {
|
||||
const { sessionName, windowName } = payload;
|
||||
if (!sessionName) return { success: false, error: "Missing sessionName" };
|
||||
const nameArg = windowName && String(windowName).trim()
|
||||
? ` -n ${shQuote(String(windowName).trim().slice(0, 64))}`
|
||||
: "";
|
||||
return runTmux(
|
||||
event,
|
||||
sessionId,
|
||||
`new-window -t ${tmuxTarget(sessionName)}${nameArg}`,
|
||||
8000,
|
||||
);
|
||||
}
|
||||
case "killWindow": {
|
||||
const { sessionName, windowIndex } = payload;
|
||||
if (!sessionName || windowIndex === undefined) return { success: false, error: "Missing params" };
|
||||
return runTmux(
|
||||
event,
|
||||
sessionId,
|
||||
`kill-window -t ${tmuxTarget(sessionName, windowIndex)}`,
|
||||
8000,
|
||||
);
|
||||
}
|
||||
case "renameWindow": {
|
||||
const { sessionName, windowIndex, newName } = payload;
|
||||
const next = String(newName || "").trim().slice(0, 64);
|
||||
if (!sessionName || windowIndex === undefined || !next) {
|
||||
return { success: false, error: "Missing params" };
|
||||
}
|
||||
return runTmux(
|
||||
event,
|
||||
sessionId,
|
||||
`rename-window -t ${tmuxTarget(sessionName, windowIndex)} ${shQuote(next)}`,
|
||||
8000,
|
||||
);
|
||||
}
|
||||
case "killPane": {
|
||||
const { sessionName, windowIndex, paneIndex } = payload;
|
||||
if (!sessionName || windowIndex === undefined || paneIndex === undefined) {
|
||||
return { success: false, error: "Missing params" };
|
||||
}
|
||||
return runTmux(
|
||||
event,
|
||||
sessionId,
|
||||
`kill-pane -t ${tmuxTarget(sessionName, windowIndex, paneIndex)}`,
|
||||
8000,
|
||||
);
|
||||
}
|
||||
case "splitPane": {
|
||||
const { sessionName, windowIndex, paneIndex, direction } = payload;
|
||||
if (!sessionName || windowIndex === undefined) {
|
||||
return { success: false, error: "Missing params" };
|
||||
}
|
||||
const flag = direction === "vertical" ? "-v" : "-h";
|
||||
const target = paneIndex !== undefined && paneIndex !== null
|
||||
? tmuxTarget(sessionName, windowIndex, paneIndex)
|
||||
: tmuxTarget(sessionName, windowIndex);
|
||||
return runTmux(event, sessionId, `split-window -t ${target} ${flag}`, 8000);
|
||||
}
|
||||
case "sendKeys": {
|
||||
const { sessionName, windowIndex, paneIndex, keys, enter } = payload;
|
||||
if (!sessionName || windowIndex === undefined || paneIndex === undefined) {
|
||||
return { success: false, error: "Missing params" };
|
||||
}
|
||||
const keyText = String(keys ?? "");
|
||||
const enterSuffix = enter !== false ? " C-m" : "";
|
||||
return runTmux(
|
||||
event,
|
||||
sessionId,
|
||||
`send-keys -t ${tmuxTarget(sessionName, windowIndex, paneIndex)} ${shQuote(keyText)}${enterSuffix}`,
|
||||
8000,
|
||||
);
|
||||
}
|
||||
case "selectWindow": {
|
||||
const { sessionName, windowIndex } = payload;
|
||||
if (!sessionName || windowIndex === undefined) return { success: false, error: "Missing params" };
|
||||
return runTmux(
|
||||
event,
|
||||
sessionId,
|
||||
`select-window -t ${tmuxTarget(sessionName, windowIndex)}`,
|
||||
8000,
|
||||
);
|
||||
}
|
||||
case "killServer": {
|
||||
return runTmux(event, sessionId, "kill-server", 8000);
|
||||
}
|
||||
default:
|
||||
return { success: false, error: `Unknown tmux action: ${action}` };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
listSessions,
|
||||
createSession,
|
||||
listWindows,
|
||||
listPanes,
|
||||
listClients,
|
||||
tmuxAction,
|
||||
shQuote,
|
||||
tmuxTarget,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createTmuxOpsApi,
|
||||
shQuote,
|
||||
tmuxTarget,
|
||||
parseTmuxSessions,
|
||||
parseTmuxSessionsPlain,
|
||||
parseTmuxSessionNames,
|
||||
parseTmuxWindows,
|
||||
parseTmuxWindowsPlain,
|
||||
parseTmuxWindowsAll,
|
||||
parseTmuxWindowsAllPlain,
|
||||
filterWindowsForSession,
|
||||
splitTmuxFields,
|
||||
parseTmuxPaneRow,
|
||||
parseTmuxPanesPlain,
|
||||
parseTmuxPanes,
|
||||
parseTmuxClients,
|
||||
isNoTmuxServerMessage,
|
||||
};
|
||||
198
electron/bridges/systemManager/windowsPowerShell.cjs
Normal file
198
electron/bridges/systemManager/windowsPowerShell.cjs
Normal file
@@ -0,0 +1,198 @@
|
||||
/* eslint-disable no-undef */
|
||||
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Windows PowerShell command helpers for remote SSH hosts.
|
||||
*
|
||||
* Every script is UTF-16LE Base64-encoded and invoked via
|
||||
* powershell -NoProfile -NonInteractive -EncodedCommand <blob>
|
||||
* so cmd.exe / OpenSSH-Server quoting cannot break it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Encode a PowerShell script string as UTF-16LE bytes, then Base64.
|
||||
* Matches what `powershell -EncodedCommand` expects.
|
||||
*/
|
||||
function encodePowerShellScript(script) {
|
||||
if (typeof Buffer !== "undefined" && Buffer.alloc) {
|
||||
// Node Buffer path: convert UTF-8 → UTF-16LE → Base64.
|
||||
const utf8 = Buffer.from(String(script), "utf8");
|
||||
const utf16 = Buffer.alloc(utf8.length * 2);
|
||||
for (let i = 0; i < utf8.length; i++) {
|
||||
utf16[i * 2] = utf8[i]; // low byte (ASCII chars: high byte = 0)
|
||||
utf16[i * 2 + 1] = 0; // high byte
|
||||
}
|
||||
return utf16.toString("base64");
|
||||
}
|
||||
// Browser / pure JS fallback.
|
||||
const chars = String(script);
|
||||
let out = "";
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
const code = chars.charCodeAt(i);
|
||||
out += String.fromCharCode(code & 0xff, (code >> 8) & 0xff);
|
||||
}
|
||||
return btoa(out);
|
||||
}
|
||||
|
||||
/** Build an SSH-exec-friendly PowerShell invocation. */
|
||||
function wrapPowerShell(encodedBlob) {
|
||||
return `powershell -NoProfile -NonInteractive -EncodedCommand ${encodedBlob}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Process list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROCESS_LIST_PS = [
|
||||
// Minimal Windows process list — speed > precision.
|
||||
// Single Get-Process pass; CPU% is a rough estimate (cumulative/uptime/cores).
|
||||
// PPID and command line come from Win32_Process (one WMI query, indexed).
|
||||
'$ErrorActionPreference = "SilentlyContinue";',
|
||||
'$procs = Get-Process;',
|
||||
// Build WMI lookup for PPID + command line
|
||||
'$wmi = @{};',
|
||||
'Get-CimInstance Win32_Process | ForEach-Object { $wmi[[int]$_.ProcessId] = $_ };',
|
||||
// Get logical core count from first processor (faster than ComputerSystem WMI)
|
||||
'$cores = (Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum;',
|
||||
'if (-not $cores -or $cores -le 0) { $cores = 1 };',
|
||||
'$totalMemMB = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1MB, 0);',
|
||||
'$now = Get-Date;',
|
||||
'$rows = foreach ($p in $procs) {',
|
||||
' $cpu = 0;',
|
||||
' if ($p.CPU -ne $null -and $p.StartTime -ne $null) {',
|
||||
' $up = ($now - $p.StartTime).TotalSeconds;',
|
||||
' if ($up -gt 0) { $cpu = [math]::Round([double]$p.CPU / $up / $cores * 100, 2) };',
|
||||
' if ($cpu -lt 0) { $cpu = 0 };',
|
||||
' if ($cpu -gt 100) { $cpu = 100 };',
|
||||
' }',
|
||||
' $w = $wmi[$p.Id];',
|
||||
' $ppid = if ($w) { [int]$w.ParentProcessId } else { 0 };',
|
||||
' $cmd = if ($w -and $w.CommandLine) { $w.CommandLine } else { $p.ProcessName };',
|
||||
' $el = "";',
|
||||
' if ($p.StartTime) {',
|
||||
' $e = $now - $p.StartTime;',
|
||||
' $el = "{0}:{1}:{2}" -f [int]$e.TotalHours, $e.Minutes, $e.Seconds;',
|
||||
' };',
|
||||
' $wsKb = [math]::Round($p.WorkingSet64 / 1024, 0);',
|
||||
' $mem = if ($totalMemMB -gt 0) { [math]::Round($wsKb / 1024 / $totalMemMB * 100, 2) } else { 0 };',
|
||||
' [PSCustomObject]@{',
|
||||
' ProcessId = $p.Id;',
|
||||
' ParentProcessId = $ppid;',
|
||||
' Name = $p.ProcessName;',
|
||||
' CpuPercent = $cpu;',
|
||||
' MemPercent = $mem;',
|
||||
' WorkingSetKb = $wsKb;',
|
||||
' Elapsed = $el;',
|
||||
' CommandLine = $cmd;',
|
||||
' }',
|
||||
'}',
|
||||
'$rows | Sort-Object WorkingSetKb -Descending | Select-Object -First 200 | ConvertTo-Json -Compress',
|
||||
].join(" ");
|
||||
|
||||
const PROCESS_LIST_PS_COMMAND = wrapPowerShell(encodePowerShellScript(PROCESS_LIST_PS));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// portOps.cjs already defines LISTEN_PORTS_WINDOWS inline; export it here too
|
||||
// so other ops can re-use the encoding convention.
|
||||
const PORT_LIST_PS_INNER = [
|
||||
'$rows = @();',
|
||||
'$rows += @(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | ',
|
||||
"Select-Object LocalAddress,LocalPort,OwningProcess,@{Name='Protocol';Expression={'tcp'}});",
|
||||
'$rows += @(Get-NetUDPEndpoint -ErrorAction SilentlyContinue | Where-Object { ',
|
||||
'$a = [string]$_.LocalAddress; ',
|
||||
'$wildcard = ($a -eq "0.0.0.0" -or $a -eq "::" -or $a -eq "*"); ',
|
||||
'$loopback = ($a -eq "127.0.0.1" -or $a -eq "::1"); ',
|
||||
'if ($wildcard -or $loopback) { $true } else { $_.LocalPort -lt 49152 } ',
|
||||
"} | Select-Object LocalAddress,LocalPort,OwningProcess,@{Name='Protocol';Expression={'udp'}}); ",
|
||||
"if ($rows.Count -gt 0) { $rows | ConvertTo-Json -Compress } else { '[]' }",
|
||||
].join("");
|
||||
|
||||
const PORT_LIST_PS_COMMAND = wrapPowerShell(encodePowerShellScript(PORT_LIST_PS_INNER));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SERVICE_LIST_PS_INNER = [
|
||||
// Force UTF-8 output so Chinese service names don't get mangled over SSH.
|
||||
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;',
|
||||
'$OutputEncoding = [System.Text.Encoding]::UTF8;',
|
||||
'$services = Get-Service -ErrorAction SilentlyContinue | ForEach-Object {',
|
||||
' [PSCustomObject]@{',
|
||||
' Name = $_.Name;',
|
||||
' DisplayName = $_.DisplayName;',
|
||||
' Status = $_.Status.ToString();',
|
||||
' StartType = $_.StartType.ToString();',
|
||||
' }',
|
||||
'};',
|
||||
'$services | ConvertTo-Json -Compress',
|
||||
].join(" ");
|
||||
|
||||
const SERVICE_LIST_PS_COMMAND = wrapPowerShell(encodePowerShellScript(SERVICE_LIST_PS_INNER));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capability probe (mirrors CAPABILITY_SCRIPT_POSIX marker format)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CAPABILITY_PROBE_PS_INNER = [
|
||||
'$ErrorActionPreference = "SilentlyContinue";',
|
||||
'Write-Output "__NC_OS__=Windows";',
|
||||
'if (Get-Command tmux -ErrorAction SilentlyContinue) { Write-Output "__NC_TMUX__=1" };',
|
||||
'if (Get-Command docker -ErrorAction SilentlyContinue) { Write-Output "__NC_DOCKER__=1" };',
|
||||
'if (Get-Command nvidia-smi -ErrorAction SilentlyContinue) { Write-Output "__NC_NVIDIA_SMI__=1" };',
|
||||
'if (Get-Command npu-smi -ErrorAction SilentlyContinue) { Write-Output "__NC_NPU_SMI__=1" };',
|
||||
'if (Get-Command ss -ErrorAction SilentlyContinue) { Write-Output "__NC_SS__=1" };',
|
||||
// Windows always has netstat.exe
|
||||
'Write-Output "__NC_NETSTAT__=1";',
|
||||
'if (Get-Command lsof -ErrorAction SilentlyContinue) { Write-Output "__NC_LSOF__=1" };',
|
||||
// Windows has system services via Get-Service — treat as "systemctl-equivalent"
|
||||
'Write-Output "__NC_SYSTEMCTL__=1";',
|
||||
].join(" ");
|
||||
|
||||
const CAPABILITY_PROBE_PS_COMMAND = wrapPowerShell(encodePowerShellScript(CAPABILITY_PROBE_PS_INNER));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service action helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildServiceActionPsCommand(action, serviceName) {
|
||||
const safe = String(serviceName).replace(/[^a-zA-Z0-9_\\-]/g, "").slice(0, 256);
|
||||
if (!safe) return null;
|
||||
let verb;
|
||||
switch (action) {
|
||||
case "start": verb = "Start-Service"; break;
|
||||
case "stop": verb = "Stop-Service"; break;
|
||||
case "restart": verb = "Restart-Service"; break;
|
||||
default: return null;
|
||||
}
|
||||
const script = `${verb} -Name "${safe}" -ErrorAction Stop; Write-Output "__NC_OK__"`;
|
||||
return wrapPowerShell(encodePowerShellScript(script));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Process kill
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildStopProcessPsCommand(pid, force) {
|
||||
const p = Math.trunc(Number(pid));
|
||||
if (!Number.isFinite(p) || p <= 0) return null;
|
||||
const cmd = force
|
||||
? `Stop-Process -Id ${p} -Force -ErrorAction Stop; Write-Output "__NC_OK__"`
|
||||
: `Stop-Process -Id ${p} -ErrorAction Stop; Write-Output "__NC_OK__"`;
|
||||
return wrapPowerShell(encodePowerShellScript(cmd));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encodePowerShellScript,
|
||||
wrapPowerShell,
|
||||
PROCESS_LIST_PS_COMMAND,
|
||||
PORT_LIST_PS_COMMAND,
|
||||
SERVICE_LIST_PS_COMMAND,
|
||||
CAPABILITY_PROBE_PS_COMMAND,
|
||||
buildServiceActionPsCommand,
|
||||
buildStopProcessPsCommand,
|
||||
};
|
||||
Reference in New Issue
Block a user