diff --git a/electron/bridges/systemManager/execOnSession.cjs b/electron/bridges/systemManager/execOnSession.cjs index 09a532bb8..dc8a3d198 100644 --- a/electron/bridges/systemManager/execOnSession.cjs +++ b/electron/bridges/systemManager/execOnSession.cjs @@ -4,6 +4,19 @@ const { isSshConnAlive, isTransportExecError } = require("./execConnHealth.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(); @@ -82,6 +95,7 @@ function createExecOnSessionApi(ctx) { return new Promise((resolve) => { let settled = false; let activeStream = null; + const maxBuffer = normalizeExecMaxBuffer(execOptions.maxBuffer); const settle = (result) => { if (settled) return; settled = true; @@ -102,9 +116,24 @@ function createExecOnSessionApi(ctx) { activeStream = stream; let stdout = ""; let stderr = ""; - stream.on("data", (chunk) => { stdout += chunk.toString(); }); + const appendOutput = (streamName, current, chunk) => { + const next = current + chunk.toString(); + if (next.length > maxBuffer) { + settle({ + success: false, + error: `${streamName} maxBuffer exceeded`, + stdout: "", + stderr: "", + code: 1, + }); + try { stream.close(); } catch { /* ignore */ } + return current; + } + return next; + }; + stream.on("data", (chunk) => { stdout = appendOutput("stdout", stdout, chunk); }); if (stream.stderr) { - stream.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); + stream.stderr.on("data", (chunk) => { stderr = appendOutput("stderr", stderr, chunk); }); } if (typeof execOptions.stdin === "string") { stream.write(execOptions.stdin); @@ -129,6 +158,7 @@ function createExecOnSessionApi(ctx) { requireTrustedHost: true, knownHosts: session.etStatsAuth?.knownHosts, stdin: execOptions.stdin, + maxBuffer: execOptions.maxBuffer, }); } @@ -162,9 +192,9 @@ function createExecOnSessionApi(ctx) { const child = execFile( "powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], - { timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, + { timeout: timeoutMs, maxBuffer: normalizeExecMaxBuffer(execOptions.maxBuffer) }, (err, stdout, stderr) => { - if (err && !stdout) { + if (err && (isExecMaxBufferError(err) || !stdout)) { resolve({ success: false, error: err.message || String(err), stdout: "", stderr: String(stderr || "") }); return; } @@ -181,9 +211,9 @@ function createExecOnSessionApi(ctx) { const child = execFile( "sh", ["-c", command], - { timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, + { timeout: timeoutMs, maxBuffer: normalizeExecMaxBuffer(execOptions.maxBuffer) }, (err, stdout, stderr) => { - if (err && !stdout) { + if (err && (isExecMaxBufferError(err) || !stdout)) { resolve({ success: false, error: err.message || String(err), stdout: "", stderr: String(stderr || "") }); return; } diff --git a/electron/bridges/systemManager/execOnSession.stdin.test.cjs b/electron/bridges/systemManager/execOnSession.stdin.test.cjs index 7eaaa743f..92d137ded 100644 --- a/electron/bridges/systemManager/execOnSession.stdin.test.cjs +++ b/electron/bridges/systemManager/execOnSession.stdin.test.cjs @@ -36,3 +36,48 @@ test("execOnSession closes ssh exec stdin after writing provided input", async ( 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); +}); diff --git a/electron/bridges/systemManagerBridge.cjs b/electron/bridges/systemManagerBridge.cjs index d13d42a3c..00331dd3a 100644 --- a/electron/bridges/systemManagerBridge.cjs +++ b/electron/bridges/systemManagerBridge.cjs @@ -16,11 +16,10 @@ const CAPABILITY_SCRIPT_POSIX = [ const PROCESS_LIST_SCRIPT_POSIX = [ "exec sh -c ", "'", - // Safety cap: head -n 2000 prevents maxBuffer/timeout on process-dense hosts. - // This is NOT a functional limit — monitored processes still show accurate metrics. - "ps -eo pid= -o ppid= -o user= -o stat= -o pcpu= -o pmem= -o rss= -o vsz= -o etime= -o args= 2>/dev/null | head -n 2000", + "ps -eo pid= -o ppid= -o user= -o stat= -o pcpu= -o pmem= -o rss= -o vsz= -o etime= -o args= 2>/dev/null", "'", ].join(""); +const PROCESS_LIST_MAX_BUFFER = 64 * 1024 * 1024; function parseCapabilities(stdout, isLocal, localPlatform) { const text = stdout || ""; @@ -166,11 +165,10 @@ function createSystemManagerBridge(deps) { if (!sessionId) return { success: false, error: "Missing sessionId" }; if (isLocalSession(sessionId) && process.platform === "win32") { - // Safety cap: -First 2000 prevents maxBuffer/timeout on process-dense hosts. - // This is NOT a functional limit — monitored processes still show accurate metrics. const result = await execOnLocalMachine( - "Get-CimInstance Win32_Process | Sort-Object KernelModeTime -Descending | Select-Object -First 2000 ProcessId,ParentProcessId,Name,WorkingSetSize | ConvertTo-Json -Compress", + "Get-CimInstance Win32_Process | Sort-Object KernelModeTime -Descending | Select-Object ProcessId,ParentProcessId,Name,WorkingSetSize | ConvertTo-Json -Compress", 10000, + { maxBuffer: PROCESS_LIST_MAX_BUFFER }, ); if (!result.success) return { success: false, error: result.error }; try { @@ -194,7 +192,9 @@ function createSystemManagerBridge(deps) { } } - const result = await execOnSession(event, sessionId, PROCESS_LIST_SCRIPT_POSIX, 12000); + const result = await execOnSession(event, sessionId, PROCESS_LIST_SCRIPT_POSIX, 12000, { + maxBuffer: PROCESS_LIST_MAX_BUFFER, + }); if (result.pending) return { success: false, pending: true }; if (!result.success) return { success: false, error: result.error || "Failed to list processes" }; return { success: true, processes: parseProcessLines(result.stdout) }; diff --git a/electron/bridges/systemManagerBridge.processes.test.cjs b/electron/bridges/systemManagerBridge.processes.test.cjs index 71730b49d..4622cc8f6 100644 --- a/electron/bridges/systemManagerBridge.processes.test.cjs +++ b/electron/bridges/systemManagerBridge.processes.test.cjs @@ -3,6 +3,8 @@ const test = require("node:test"); const assert = require("node:assert/strict"); const { EventEmitter } = require("node:events"); +const fs = require("node:fs"); +const path = require("node:path"); const { createSystemManagerBridge } = require("./systemManagerBridge.cjs"); function createFakeExecStream(stdout, options = {}) { @@ -26,8 +28,10 @@ test("listProcesses uses a ps format that works on CentOS 7 procps", async () => " 1 0 root Ss 0.0 0.0 4060 191024 2-19:23:42 /usr/lib/systemd/systemd --switched-root --system --deserialize 21", ].join("\n"); + let seenCommand = ""; const conn = { exec(command, callback) { + seenCommand = command; const stdout = command.includes(compatiblePsFormat) ? compatibleOutput : badCentos7Output; @@ -46,6 +50,44 @@ test("listProcesses uses a ps format that works on CentOS 7 procps", async () => assert.equal(result.processes.length, 1); assert.equal(result.processes[0].pid, 1); assert.equal(result.processes[0].command, "/usr/lib/systemd/systemd --switched-root --system --deserialize 21"); + assert.doesNotMatch(seenCommand, /head\s+-n\s+2000/); +}); + +test("process listing commands do not hard-cap the visible list at 2000 entries", () => { + const source = fs.readFileSync(path.join(__dirname, "systemManagerBridge.cjs"), "utf8"); + + assert.doesNotMatch(source, /head\s+-n\s+2000/); + assert.doesNotMatch(source, /Select-Object\s+-First\s+2000/); +}); + +test("listProcesses gives ET process listing enough output buffer for dense hosts", async () => { + let seenOptions = null; + const sessions = new Map([["s1", { + type: "et", + etStatsAuth: { knownHosts: [] }, + }]]); + const bridge = createSystemManagerBridge({ + getSessions: () => sessions, + process, + execOnEtSession: async (_session, _command, _timeoutMs, options) => { + seenOptions = options; + return { success: true, stdout: "" }; + }, + }); + + const result = await bridge.listProcesses(null, { sessionId: "s1" }); + + assert.equal(result.success, true); + assert.ok(seenOptions.maxBuffer > 10 * 1024 * 1024); +}); + +test("listProcesses gives Windows process listing enough output buffer for dense local hosts", () => { + const source = fs.readFileSync(path.join(__dirname, "systemManagerBridge.cjs"), "utf8"); + + assert.match( + source, + /execOnLocalMachine\(\s*"Get-CimInstance Win32_Process[\s\S]+10000,\s*\{\s*maxBuffer:\s*PROCESS_LIST_MAX_BUFFER\s*\}/, + ); }); test("probeCapabilities reports Docker when docker is installed even if plain docker access is denied", async () => { diff --git a/electron/bridges/terminalBridge/etSession.cjs b/electron/bridges/terminalBridge/etSession.cjs index 23e047154..d008cfc86 100644 --- a/electron/bridges/terminalBridge/etSession.cjs +++ b/electron/bridges/terminalBridge/etSession.cjs @@ -660,12 +660,17 @@ main(); args.push(session.sshUserHost, command); return new Promise((resolve) => { - const child = execFile(sshCmd, args, { + const execFileOptions = { env: { ...process.env, ...session.sshEnv }, timeout: timeoutMs, encoding: "utf8", windowsHide: true, - }, (err, stdout, stderr) => { + }; + const maxBuffer = Number(execOpts.maxBuffer); + if (Number.isFinite(maxBuffer) && maxBuffer > 0) { + execFileOptions.maxBuffer = Math.floor(maxBuffer); + } + const child = execFile(sshCmd, args, execFileOptions, (err, stdout, stderr) => { if (err) { resolve({ success: false, diff --git a/electron/bridges/terminalBridge/etSession.test.cjs b/electron/bridges/terminalBridge/etSession.test.cjs index 2cba8ad4a..d11f3e5e4 100644 --- a/electron/bridges/terminalBridge/etSession.test.cjs +++ b/electron/bridges/terminalBridge/etSession.test.cjs @@ -360,6 +360,50 @@ test("execOnEtSession requireTrustedHost uses strict host-key checking", async ( assert.match(strictContent, /host\.example ssh-ed25519 vaultblob/); }); +test("execOnEtSession forwards maxBuffer to the ssh execFile call", async (t) => { + let capturedOptions = null; + const { api } = makeApi(t, { + execFile: (_cmd, _args, opts, cb) => { + capturedOptions = opts; + process.nextTick(() => cb(null, "", "")); + }, + }); + const env = api.prepareEtSshEnvironment("sess1", { hostname: "host.example", username: "alice" }); + const session = { + sshUserHost: env.userHost, + sshOptions: env.sshOptions, + sshEnv: env.env, + externalAuthArtifacts: env.artifacts, + externalAuthArtifactsCleaned: false, + }; + + await api.execOnEtSession(session, "echo ok", 1000, { maxBuffer: 64 * 1024 * 1024 }); + + assert.equal(capturedOptions.maxBuffer, 64 * 1024 * 1024); +}); + +test("execOnEtSession keeps the default execFile maxBuffer when no override is provided", async (t) => { + let capturedOptions = null; + const { api } = makeApi(t, { + execFile: (_cmd, _args, opts, cb) => { + capturedOptions = opts; + process.nextTick(() => cb(null, "", "")); + }, + }); + const env = api.prepareEtSshEnvironment("sess1", { hostname: "host.example", username: "alice" }); + const session = { + sshUserHost: env.userHost, + sshOptions: env.sshOptions, + sshEnv: env.env, + externalAuthArtifacts: env.artifacts, + externalAuthArtifactsCleaned: false, + }; + + await api.execOnEtSession(session, "echo ok", 1000); + + assert.equal(Object.hasOwn(capturedOptions, "maxBuffer"), false); +}); + test("cleanupStaleEtTempDirs only removes Netcatty ET temp directories by prefix", (t) => { const { api, base } = makeApi(t); const staleEtDir = path.join(base, "et-ssh-home-old-session");