From ecadc1fc2d5b4c6be9f5572f59e0cda3b0d7edee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=A4=A7=E7=8C=AB?= <16399091+binaricat@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:47:21 +0800 Subject: [PATCH] [codex] Enable sudo fallback for Docker panel (#1466) * Enable sudo fallback for Docker panel * Prefer sudo for Docker panel commands * Use pending saved sudo password immediately * Try plain Docker before sudo fallback * Detect Docker before sudo fallback * Add sudo fallback for Docker popup commands * Harden Docker popup sudo fallback --- .../createTerminalSessionStarters.test.ts | 48 ++++- .../runtime/createTerminalSessionStarters.ts | 9 +- domain/remoteHistory.ts | 2 +- domain/systemManager/dockerShell.test.ts | 30 +++ domain/systemManager/dockerShell.ts | 37 +++- electron/bridges/sshBridge/startSession.cjs | 3 + electron/bridges/systemManager/dockerOps.cjs | 79 ++++++-- .../bridges/systemManager/dockerOps.test.cjs | 186 ++++++++++++++++++ .../bridges/systemManager/execOnSession.cjs | 35 ++-- .../execOnSession.stdin.test.cjs | 38 ++++ electron/bridges/systemManagerBridge.cjs | 8 +- .../systemManagerBridge.processes.test.cjs | 21 ++ electron/bridges/terminalBridge/etSession.cjs | 8 +- .../bridges/terminalBridge/moshSession.cjs | 3 + global.d.ts | 2 + types/global/netcatty-bridge-session.d.ts | 2 + 16 files changed, 466 insertions(+), 45 deletions(-) create mode 100644 domain/systemManager/dockerShell.test.ts create mode 100644 electron/bridges/systemManager/dockerOps.test.cjs create mode 100644 electron/bridges/systemManager/execOnSession.stdin.test.cjs diff --git a/components/terminal/runtime/createTerminalSessionStarters.test.ts b/components/terminal/runtime/createTerminalSessionStarters.test.ts index f3c1de30..b0a78e32 100644 --- a/components/terminal/runtime/createTerminalSessionStarters.test.ts +++ b/components/terminal/runtime/createTerminalSessionStarters.test.ts @@ -161,6 +161,47 @@ test("startSSH forwards custom ProxyCommand to the SSH bridge", async () => { }); }); +test("startSSH forwards the saved sudo autofill password to the SSH bridge", async () => { + let capturedOptions: Record | null = null; + const terminalBackend = { + backendAvailable: () => true, + telnetAvailable: () => true, + moshAvailable: () => true, + localAvailable: () => true, + serialAvailable: () => true, + execAvailable: () => true, + startSSHSession: async (options: Record) => { + capturedOptions = options; + return "ssh-session"; + }, + startTelnetSession: async () => "telnet-session", + startMoshSession: async () => "mosh-session", + startLocalSession: async () => "local-session", + startSerialSession: async () => "serial-session", + execCommand: async () => ({}), + onSessionData: () => noop, + onSessionExit: () => noop, + onChainProgress: () => noop, + writeToSession: noop, + resizeSession: noop, + }; + const ctx = createStarterContext({ + host: { + id: "host-1", + label: "Target", + hostname: "target.example.test", + username: "alice", + password: "login-secret", + }, + terminalBackend, + sudoAutofillPassword: "sudo-secret", + }); + + await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never); + + assert.equal(capturedOptions?.sudoAutofillPassword, "sudo-secret"); +}); + test("startSSH enables sudo autofill only with the host saved password", async () => { let onData: ((data: string) => void) | null = null; const sent: string[] = []; @@ -255,7 +296,7 @@ test("startSSH does not use unsaved retry passwords for sudo autofill", async () assert.deepEqual(sent, []); }); -test("startSSH prefers latest sudo autofill password state over pending saved auth", async () => { +test("startSSH uses pending saved auth for sudo autofill on the first saved connection", async () => { let onData: ((data: string) => void) | null = null; const sent: string[] = []; const terminalBackend = { @@ -296,14 +337,15 @@ test("startSSH prefers latest sudo autofill password state over pending saved au }, }, terminalBackend, - sudoAutofillPasswordRef: { current: undefined }, + sudoAutofillPasswordRef: { current: "stale-secret" }, }); await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never); ctx.sudoAutofillRef.current?.armForCommand("sudo whoami"); onData?.("[sudo] password for alice: "); + ctx.sudoAutofillRef.current?.confirmFill(); - assert.deepEqual(sent, []); + assert.deepEqual(sent, ["pending-secret\n"]); }); test("startSSH does not use merged group default passwords for sudo autofill", async () => { diff --git a/components/terminal/runtime/createTerminalSessionStarters.ts b/components/terminal/runtime/createTerminalSessionStarters.ts index 68b47d37..a940d089 100644 --- a/components/terminal/runtime/createTerminalSessionStarters.ts +++ b/components/terminal/runtime/createTerminalSessionStarters.ts @@ -53,13 +53,13 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex }; const resolveSavedSudoAutofillPassword = (): string | undefined => { - if (ctx.sudoAutofillPasswordRef) { - return sanitizeCredentialValue(ctx.sudoAutofillPasswordRef.current); - } const pendingAuth = ctx.pendingAuthRef.current; if (pendingAuth?.savedToHost && pendingAuth.password) { return sanitizeCredentialValue(pendingAuth.password); } + if (ctx.sudoAutofillPasswordRef) { + return sanitizeCredentialValue(ctx.sudoAutofillPasswordRef.current); + } return sanitizeCredentialValue(ctx.sudoAutofillPassword); }; @@ -401,6 +401,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex sshDebugLogEnabled: ctx.sshDebugLogEnabled, identityFilePaths: attempt.password ? undefined : targetIdentityFilePaths, knownHosts: ctx.knownHosts, + sudoAutofillPassword: resolveSavedSudoAutofillPassword(), // Ask the bridge to reuse the source tab's authenticated connection // (issue #1204). Only honored on the very first connect attempt; the // bridge silently falls back to a fresh connection if the source is @@ -764,6 +765,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex // Lets the stats companion verify the host key before sending a saved // password (#1198), so it never discloses it to an unvetted host. knownHosts: ctx.knownHosts, + sudoAutofillPassword: resolveSavedSudoAutofillPassword(), cols: term.cols, rows: term.rows, charset: ctx.host.charset, @@ -1002,6 +1004,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex knownHosts: ctx.knownHosts, jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined, agentForwarding: ctx.host.agentForwarding, + sudoAutofillPassword: resolveSavedSudoAutofillPassword(), cols: term.cols, rows: term.rows, charset: ctx.host.charset, diff --git a/domain/remoteHistory.ts b/domain/remoteHistory.ts index d3129459..d50188f6 100644 --- a/domain/remoteHistory.ts +++ b/domain/remoteHistory.ts @@ -9,7 +9,7 @@ export function isNetcattyAiHistoryCommand(command: string): boolean { } const NETCATTY_MANAGED_STARTUP_COMMAND = - /^printf '\\033\[H\\033\[2J\\033\[3J';\s*exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)/; + /^(?:sh\s+-c\s+.*printf .*\\033\[H\\033\[2J\\033\[3J.*_nc_docker_err=.*\bdocker\s+inspect\b|printf '\\033\[H\\033\[2J\\033\[3J';\s*(?:_nc_docker_err=.*\bdocker\s+inspect\b|exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)))/; /** True when a shell history line came from a Netcatty-managed terminal launch. */ export function isNetcattyManagedStartupHistoryCommand(command: string): boolean { diff --git a/domain/systemManager/dockerShell.test.ts b/domain/systemManager/dockerShell.test.ts new file mode 100644 index 00000000..7ea1c39c --- /dev/null +++ b/domain/systemManager/dockerShell.test.ts @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildDockerExecShellCommand, buildDockerLogsCommand } from './dockerShell.ts'; + +test('buildDockerExecShellCommand probes plain Docker before sudo fallback', () => { + const command = buildDockerExecShellCommand('587abcdef123'); + + assert.match(command, /^sh -c /); + assert.match(command, /printf .*\\033\[H\\033\[2J\\033\[3J/); + assert.match(command, /docker inspect 587abcdef123/); + assert.match(command, /exec docker exec -it 587abcdef123/); + assert.match(command, /exec sudo docker exec -it 587abcdef123/); + assert.match(command, /permission\\ denied.*docker.sock.*docker.sock.*permission\\ denied/); + assert.doesNotMatch(command, /sudo -S/); + assert.equal(command.includes('\n'), false); +}); + +test('buildDockerLogsCommand probes plain Docker before sudo fallback', () => { + const command = buildDockerLogsCommand('587abcdef123'); + + assert.match(command, /^sh -c /); + assert.match(command, /printf .*\\033\[H\\033\[2J\\033\[3J/); + assert.match(command, /docker inspect 587abcdef123/); + assert.match(command, /exec docker logs -f --tail 200 587abcdef123/); + assert.match(command, /exec sudo docker logs -f --tail 200 587abcdef123/); + assert.match(command, /permission\\ denied.*docker.sock.*docker.sock.*permission\\ denied/); + assert.doesNotMatch(command, /sudo -S/); + assert.equal(command.includes('\n'), false); +}); diff --git a/domain/systemManager/dockerShell.ts b/domain/systemManager/dockerShell.ts index a25d77ce..a8a87e3f 100644 --- a/domain/systemManager/dockerShell.ts +++ b/domain/systemManager/dockerShell.ts @@ -5,15 +5,48 @@ export function sanitizeDockerContainerId(id: string): string { const CLEAR_STARTUP_OUTPUT = "printf '\\033[H\\033[2J\\033[3J';"; +function shQuote(value: string): string { + return `'${String(value).replace(/'/g, `'"'"'`)}'`; +} + +function buildDockerCommandWithSudoFallback(containerId: string, dockerArgs: string): string { + const plainCommand = `docker ${dockerArgs}`; + const sudoCommand = `sudo ${plainCommand}`; + const script = [ + CLEAR_STARTUP_OUTPUT, + `_nc_docker_err=$(docker inspect ${containerId} 2>&1 >/dev/null);`, + '_nc_docker_status=$?;', + `if [ "$_nc_docker_status" -eq 0 ]; then exec ${plainCommand}; fi;`, + '_nc_docker_lc=$(printf \'%s\' "$_nc_docker_err" | tr \'[:upper:]\' \'[:lower:]\');', + 'case "$_nc_docker_lc" in', + [ + '*permission\\ denied*docker\\ daemon*', + '*docker\\ daemon*permission\\ denied*', + '*permission\\ denied*docker.sock*', + '*docker.sock*permission\\ denied*', + '*permission\\ denied*/var/run/docker.sock*', + '*/var/run/docker.sock*permission\\ denied*', + '*permission\\ denied*connect\\ to\\ the\\ docker\\ daemon*', + '*connect\\ to\\ the\\ docker\\ daemon*permission\\ denied*', + ].join('|') + `) exec ${sudoCommand} ;;`, + '*) printf \'%s\\n\' "$_nc_docker_err" >&2; exit "$_nc_docker_status" ;;', + 'esac', + ].join(' '); + return `sh -c ${shQuote(script)}`; +} + /** Interactive shell into a container — prefer bash, fall back to sh. */ export function buildDockerExecShellCommand(containerId: string): string { const safeId = sanitizeDockerContainerId(containerId); if (!safeId) return 'echo "Invalid container id"'; - return `${CLEAR_STARTUP_OUTPUT} exec docker exec -it ${safeId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`; + return buildDockerCommandWithSudoFallback( + safeId, + `exec -it ${safeId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`, + ); } export function buildDockerLogsCommand(containerId: string): string { const safeId = sanitizeDockerContainerId(containerId); if (!safeId) return 'echo "Invalid container id"'; - return `${CLEAR_STARTUP_OUTPUT} exec docker logs -f --tail 200 ${safeId}`; + return buildDockerCommandWithSudoFallback(safeId, `logs -f --tail 200 ${safeId}`); } diff --git a/electron/bridges/sshBridge/startSession.cjs b/electron/bridges/sshBridge/startSession.cjs index 862f2c62..73b280fe 100644 --- a/electron/bridges/sshBridge/startSession.cjs +++ b/electron/bridges/sshBridge/startSession.cjs @@ -38,6 +38,9 @@ function createStartSessionApi(ctx) { hostname: options.host || options.hostname || '', username: options.username || '', label: options.label || '', + systemManagerSudoPassword: typeof options.sudoAutofillPassword === 'string' && options.sudoAutofillPassword.length > 0 + ? options.sudoAutofillPassword + : undefined, lastIdlePrompt: '', lastIdlePromptAt: 0, _promptTrackTail: '', diff --git a/electron/bridges/systemManager/dockerOps.cjs b/electron/bridges/systemManager/dockerOps.cjs index 22152eb5..893eeb50 100644 --- a/electron/bridges/systemManager/dockerOps.cjs +++ b/electron/bridges/systemManager/dockerOps.cjs @@ -19,6 +19,37 @@ function sanitizeImageRef(ref) { 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 buildSudoDockerCommand(args) { + return `sudo -S -p '' ${buildDockerCommand(args)}`; +} + function parseDockerContainers(stdout) { const containers = []; for (const line of (stdout || "").split("\n")) { @@ -132,15 +163,35 @@ function summarizeContainerInspect(info) { }; } -function createDockerOpsApi({ execOnSession }) { +function createDockerOpsApi({ execOnSession, getSession }) { async function runDocker(event, sessionId, args, timeoutMs = 15000) { - const cmd = `docker ${args}`; + const cmd = buildDockerCommand(args); const result = await execOnSession(event, sessionId, cmd, timeoutMs); + if (isSuccessfulCommandResult(result)) return result; + + const sudoPassword = getSessionSudoPassword(getSession?.(sessionId)); + + if (sudoPassword && isDockerSocketPermissionError(result)) { + const sudoResult = await execOnSession( + event, + sessionId, + buildSudoDockerCommand(args), + timeoutMs, + { stdin: `${sudoPassword}\n` }, + ); + if (isSuccessfulCommandResult(sudoResult)) return sudoResult; + return { + success: false, + error: dockerCommandError(sudoResult, `sudo docker exited with code ${sudoResult?.code}`), + stderr: sudoResult?.stderr, + }; + } + if (!result.success) return result; if (result.code !== 0 && result.code !== null && result.code !== undefined) { return { success: false, - error: (result.stderr || "").trim() || `docker exited with code ${result.code}`, + error: dockerCommandError(result, `docker exited with code ${result.code}`), stderr: result.stderr, }; } @@ -148,23 +199,13 @@ function createDockerOpsApi({ execOnSession }) { } async function listContainers(event, sessionId) { - const result = await execOnSession( - event, - sessionId, - "docker ps -a --format '{{json .}}'", - 12000, - ); + 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 execOnSession( - event, - sessionId, - "docker images --format '{{json .}}'", - 12000, - ); + 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) }; } @@ -174,10 +215,10 @@ function createDockerOpsApi({ execOnSession }) { 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 execOnSession( + const result = await runDocker( event, sessionId, - `docker stats --no-stream --format '{{json .}}' ${idArg}`.trim(), + `stats --no-stream --format '{{json .}}' ${idArg}`.trim(), 15000, ); if (!result.success) return { success: false, error: result.error }; @@ -188,7 +229,7 @@ function createDockerOpsApi({ execOnSession }) { const { sessionId, containerId } = payload || {}; if (!sessionId || !containerId) return { success: false, error: "Missing params" }; const safeId = sanitizeDockerId(containerId); - const result = await execOnSession(event, sessionId, `docker inspect ${safeId}`, 10000); + 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 || "[]"); @@ -203,7 +244,7 @@ function createDockerOpsApi({ execOnSession }) { const { sessionId, imageId } = payload || {}; if (!sessionId || !imageId) return { success: false, error: "Missing params" }; const safeId = sanitizeDockerId(imageId); - const result = await execOnSession(event, sessionId, `docker image inspect ${safeId}`, 10000); + 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 || "[]"); diff --git a/electron/bridges/systemManager/dockerOps.test.cjs b/electron/bridges/systemManager/dockerOps.test.cjs new file mode 100644 index 00000000..b9c0c59c --- /dev/null +++ b/electron/bridges/systemManager/dockerOps.test.cjs @@ -0,0 +1,186 @@ +"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, + }; + } + 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[0].execOptions, undefined); + assert.equal( + calls[1].command, + "sudo -S -p '' docker ps -a --format '{{json .}}'", + ); + assert.deepEqual(calls[1].execOptions, { stdin: "host-secret\n" }); +}); + +test("listContainers uses plain docker 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 }); + return { + success: true, + stdout: "", + stderr: "Got 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, 1); +}); + +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, 1); +}); + +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, + }; + } + 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, 2); + assert.equal( + calls[1].command, + "sudo -S -p '' docker ps -a --format '{{json .}}'", + ); + assert.deepEqual(calls[1].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, + }; + } + 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, 2); + assert.equal( + calls[1].command, + "sudo -S -p '' docker rmi sha256abc123", + ); + assert.deepEqual(calls[1].execOptions, { stdin: "pa'ss\n" }); +}); diff --git a/electron/bridges/systemManager/execOnSession.cjs b/electron/bridges/systemManager/execOnSession.cjs index 94d20fd9..09a532bb 100644 --- a/electron/bridges/systemManager/execOnSession.cjs +++ b/electron/bridges/systemManager/execOnSession.cjs @@ -78,7 +78,7 @@ function createExecOnSessionApi(ctx) { return conn; } - function execOnConnection(conn, command, timeoutMs) { + function execOnConnection(conn, command, timeoutMs, execOptions = {}) { return new Promise((resolve) => { let settled = false; let activeStream = null; @@ -106,6 +106,10 @@ function createExecOnSessionApi(ctx) { if (stream.stderr) { stream.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); } + if (typeof execOptions.stdin === "string") { + stream.write(execOptions.stdin); + stream.end(); + } stream.on("close", (code) => { settle({ success: true, stdout, stderr, code: code ?? 0 }); }); @@ -116,7 +120,7 @@ function createExecOnSessionApi(ctx) { }); } - async function execOnSshSession(session, sessionId, command, timeoutMs, event, allowCompanionRetry = true) { + 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" }; @@ -124,6 +128,7 @@ function createExecOnSessionApi(ctx) { return execOnEtSession(session, command, timeoutMs, { requireTrustedHost: true, knownHosts: session.etStatsAuth?.knownHosts, + stdin: execOptions.stdin, }); } @@ -135,7 +140,7 @@ function createExecOnSessionApi(ctx) { return { success: false, error: "Session not found or not connected" }; } - const result = await execOnConnection(conn, command, timeoutMs); + const result = await execOnConnection(conn, command, timeoutMs, execOptions); if ( allowCompanionRetry && !result.success @@ -143,18 +148,18 @@ function createExecOnSessionApi(ctx) { && isTransportExecError(result.error) ) { session.moshStatsConn = null; - return execOnSshSession(session, sessionId, command, timeoutMs, event, false); + return execOnSshSession(session, sessionId, command, timeoutMs, event, execOptions, false); } return result; } - async function execOnLocalMachine(command, timeoutMs) { + async function execOnLocalMachine(command, timeoutMs, execOptions = {}) { const { execFile } = require("node:child_process"); const platform = process.platform; if (platform === "win32") { return new Promise((resolve) => { - execFile( + const child = execFile( "powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, @@ -166,11 +171,14 @@ function createExecOnSessionApi(ctx) { 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) => { - execFile( + const child = execFile( "sh", ["-c", command], { timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, @@ -182,10 +190,13 @@ function createExecOnSessionApi(ctx) { 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) { + async function execOnSessionInner(event, sessionId, command, timeoutMs = 8000, execOptions = {}) { const session = getSession(sessionId); if (!session) { execQueues.delete(sessionId); @@ -193,18 +204,18 @@ function createExecOnSessionApi(ctx) { } if (session.protocol === "local" || session.type === "local") { - return execOnLocalMachine(command, timeoutMs); + return execOnLocalMachine(command, timeoutMs, execOptions); } if (session.conn || session.type === "mosh" || session.type === "et") { - return execOnSshSession(session, sessionId, command, timeoutMs, event); + 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) { - return enqueueExec(sessionId, () => execOnSessionInner(event, sessionId, command, timeoutMs)); + async function execOnSession(event, sessionId, command, timeoutMs = 8000, execOptions = {}) { + return enqueueExec(sessionId, () => execOnSessionInner(event, sessionId, command, timeoutMs, execOptions)); } function isLocalSession(sessionId) { diff --git a/electron/bridges/systemManager/execOnSession.stdin.test.cjs b/electron/bridges/systemManager/execOnSession.stdin.test.cjs new file mode 100644 index 00000000..7eaaa743 --- /dev/null +++ b/electron/bridges/systemManager/execOnSession.stdin.test.cjs @@ -0,0 +1,38 @@ +"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); +}); diff --git a/electron/bridges/systemManagerBridge.cjs b/electron/bridges/systemManagerBridge.cjs index 42e0b8c6..03fb85e4 100644 --- a/electron/bridges/systemManagerBridge.cjs +++ b/electron/bridges/systemManagerBridge.cjs @@ -9,7 +9,7 @@ const CAPABILITY_SCRIPT_POSIX = [ "'", 'printf "%s\\n" "__NC_OS__=$(uname -s)"; ', 'command -v tmux >/dev/null 2>&1 && printf "%s\\n" __NC_TMUX__=1; ', - '(docker info >/dev/null 2>&1 || (command -v docker >/dev/null 2>&1 && [ -r /var/run/docker.sock ])) && printf "%s\\n" __NC_DOCKER__=1', + 'command -v docker >/dev/null 2>&1 && printf "%s\\n" __NC_DOCKER__=1', "'", ].join(""); @@ -111,10 +111,10 @@ function createSystemManagerBridge(deps) { ensureMoshStatsConnection, }); - const { execOnSession, execOnLocalMachine, isLocalSession } = execApi; + const { execOnSession, execOnLocalMachine, isLocalSession, getSession } = execApi; const tmuxOps = createTmuxOpsApi({ execOnSession }); - const dockerOps = createDockerOpsApi({ execOnSession }); + const dockerOps = createDockerOpsApi({ execOnSession, getSession }); async function probeCapabilities(event, payload) { const sessionId = payload?.sessionId; @@ -136,7 +136,7 @@ function createSystemManagerBridge(deps) { 8000, ); if (!result.success) { - const fallback = await execOnLocalMachine("uname -s; command -v tmux; (docker info >/dev/null 2>&1 || (command -v docker >/dev/null 2>&1 && [ -r /var/run/docker.sock ])) && echo docker_ok", 8000); + const fallback = await execOnLocalMachine("uname -s; command -v tmux; command -v docker >/dev/null 2>&1 && echo docker_ok", 8000); if (!fallback.success) return { success: false, error: fallback.error || "Probe failed" }; const text = fallback.stdout || ""; return { diff --git a/electron/bridges/systemManagerBridge.processes.test.cjs b/electron/bridges/systemManagerBridge.processes.test.cjs index 09c0e357..82e6c639 100644 --- a/electron/bridges/systemManagerBridge.processes.test.cjs +++ b/electron/bridges/systemManagerBridge.processes.test.cjs @@ -46,3 +46,24 @@ test("listProcesses uses a ps format that works on CentOS 7 procps", async () => assert.equal(result.processes[0].pid, 1); assert.equal(result.processes[0].command, "/usr/lib/systemd/systemd --switched-root --system --deserialize 21"); }); + +test("probeCapabilities reports Docker when docker is installed even if plain docker access is denied", async () => { + const conn = { + exec(command, callback) { + assert.match(command, /command -v docker/); + assert.doesNotMatch(command, /docker info/); + assert.doesNotMatch(command, /docker\.sock/); + callback(null, createFakeExecStream("__NC_OS__=Linux\n__NC_DOCKER__=1\n")); + }, + }; + const sessions = new Map([["s1", { conn, type: "ssh" }]]); + const bridge = createSystemManagerBridge({ + getSessions: () => sessions, + process, + }); + + const result = await bridge.probeCapabilities(null, { sessionId: "s1" }); + + assert.equal(result.success, true); + assert.equal(result.capabilities.hasDocker, true); +}); diff --git a/electron/bridges/terminalBridge/etSession.cjs b/electron/bridges/terminalBridge/etSession.cjs index e3f3d0a1..26b078c6 100644 --- a/electron/bridges/terminalBridge/etSession.cjs +++ b/electron/bridges/terminalBridge/etSession.cjs @@ -654,7 +654,7 @@ main(); args.push(session.sshUserHost, command); return new Promise((resolve) => { - execFile(sshCmd, args, { + const child = execFile(sshCmd, args, { env: { ...process.env, ...session.sshEnv }, timeout: timeoutMs, encoding: "utf8", @@ -672,6 +672,9 @@ main(); resolve({ success: true, stdout: stdout || "", stderr: stderr || "", code: 0 }); } }); + if (typeof execOpts.stdin === "string") { + child.stdin?.end(execOpts.stdin); + } }); } @@ -791,6 +794,9 @@ main(); knownHosts: options.knownHosts, hasJumpHost: Array.isArray(options.jumpHosts) && options.jumpHosts.length > 0, }, + systemManagerSudoPassword: typeof options.sudoAutofillPassword === "string" && options.sudoAutofillPassword.length > 0 + ? options.sudoAutofillPassword + : undefined, flushPendingData: null, lastIdlePrompt: "", lastIdlePromptAt: 0, diff --git a/electron/bridges/terminalBridge/moshSession.cjs b/electron/bridges/terminalBridge/moshSession.cjs index 72726c6c..52b0fc45 100644 --- a/electron/bridges/terminalBridge/moshSession.cjs +++ b/electron/bridges/terminalBridge/moshSession.cjs @@ -572,6 +572,9 @@ function createMoshSessionApi(ctx) { // does not depend on this. knownHosts: options.knownHosts, }; + session.systemManagerSudoPassword = typeof options.sudoAutofillPassword === "string" && options.sudoAutofillPassword.length > 0 + ? options.sudoAutofillPassword + : undefined; if (process.platform !== "win32") { const decoder = new StringDecoder("utf8"); diff --git a/global.d.ts b/global.d.ts index fa019902..34ae12a0 100644 --- a/global.d.ts +++ b/global.d.ts @@ -119,6 +119,8 @@ declare global { algorithmOverrides?: import("./domain/models").HostAlgorithmOverrides; // Use sudo for SFTP server sudo?: boolean; + // Saved host password used by background system tools when they need sudo. + sudoAutofillPassword?: string; // Session log configuration for real-time streaming sessionLog?: { enabled: boolean; directory: string; format: string; timestampsEnabled?: boolean }; // SSH connection diagnostics. Does not capture terminal output. diff --git a/types/global/netcatty-bridge-session.d.ts b/types/global/netcatty-bridge-session.d.ts index daaad054..eaf413bf 100644 --- a/types/global/netcatty-bridge-session.d.ts +++ b/types/global/netcatty-bridge-session.d.ts @@ -29,6 +29,7 @@ declare global { moshServerPath?: string; moshClientPath?: string; agentForwarding?: boolean; + sudoAutofillPassword?: string; // Algorithm settings, forwarded so the host-info stats companion SSH // connection (issue #1198) negotiates the same KEX / cipher / host-key // set the interactive session would. @@ -63,6 +64,7 @@ declare global { knownHosts?: import("../../domain/models").KnownHost[]; jumpHosts?: NetcattyJumpHost[]; agentForwarding?: boolean; + sudoAutofillPassword?: string; cols?: number; rows?: number; charset?: string;