[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View 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,
};

View 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" });
});

View 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 };

View 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);
});
});

View 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 };

View 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);
});

View 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,
};

View 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);
});

View 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,
};

View 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]);
});

View 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,
};

View 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");
});

View 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,
};

View 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);
});

View 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,
};

View 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,
};