[codex] Enable sudo fallback for Docker panel (#1466)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
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 / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled

* 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
This commit is contained in:
陈大猫
2026-06-14 10:47:21 +08:00
committed by GitHub
parent 79ccf47655
commit ecadc1fc2d
16 changed files with 466 additions and 45 deletions

View File

@@ -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<string, unknown> | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async (options: Record<string, unknown>) => {
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 () => {

View File

@@ -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,

View File

@@ -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 {

View File

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

View File

@@ -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}`);
}

View File

@@ -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: '',

View File

@@ -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 || "[]");

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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,

View File

@@ -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");

2
global.d.ts vendored
View File

@@ -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.

View File

@@ -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;