How to allow `Process` to receive user input when run as part of an executable (e.g. to enabled `sudo` commands)

I am creating a Swift package that has an executable that will run commands via Process. Some of these commands will require user input, such as sudo.

How can I ensure that the commands output is shown to the user, and also allow them to interact with the commands, such as typing in their password for sudo?

I have a few ways of running the command, which all have different shortcomings when called as run(["sudo", "echo", "$USER"]):

public func run(_ command: [String]) throws {
    try Process.run(URL(fileURLWithPath: "/usr/bin/env"), arguments: command) { process in
        print("Process terminated", process)
    }
}

Fails without user input:

Password:
sudo: unable to read password: Input/output error

I've also tried:

public func run(_ command: [String]) throws {
    let process = Process()
    process.launchPath = "/usr/bin/env"
    process.arguments = command

    let standardError = Pipe()
    process.standardError = standardError

    print("Running \(command.joined(separator: " "))")

    try process.run()
    process.waitUntilExit()

    if process.terminationStatus != 0 {
        let errorData = standardError.fileHandleForReading.readDataToEndOfFile()
        let error = String(data: errorData, encoding: .utf8)!
        throw CommandError(message: error, exitCode: process.terminationStatus)
    }
}

which will output Running sudo echo $USER and stall until ctrl+C is pressed, at which point the process exits and Password: is output:

Running sudo echo $USER
^CPassword:

Not setting process.standardError does the same, but outputs:

Running sudo echo $USER
^CPassword:
sudo: unable to read password: Input/output error

I'm assuming I need to pass stdin somehow, but the documentation for standardInput on Process states If this method isn’t used, the standard input is inherited from the process that created the receiver so I'm not sure why it's not working.

This code is all in an executable product being run via swift run executable-name.

1 Like

Most command-line tools that read a password interact directly with the terminal, primarily so that they can disable echo. Process is a (relatively) simple wrapper around posix_spawn, which doesn’t make it easy to interact with such tools. I know of three general approaches for dealing with this:

  • Setting up a pseudo-terminal (see pty and openpty man pages
  • Using expect (see its man page)

  • Using a tool-specific way to pass in the password

With regards the last point, sudo supports -A (to run a helper tool to get the password) and -S (to read the password from stdin). See its man page for details.


Finally, this question is more abouts the minutiae of macOS process management than about Swift the language. If you have follow-up questions, please pop on over to the Core OS > Processes area of DevForums and I can answer them there.

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

1 Like

Hi, I’m also running into this issue and ran into a bit of a wall in attempting to find a workaround. Some additional notes:

Python scripts correctly prompt for passwords when invoking sudo using subprocess, despite it also using posix_spawn.

python -c 'import subprocess; subprocess.run(["sudo", "echo", "foo"])'

Rust binaries can invoke sudo using Command, which also uses posix_spawn.

cat ./cmd.rs
use std::error::Error;
use std::io::Write;
use std::process::Command;
use std::{io, process};

type Result<T> = ::std::result::Result<T, Box<dyn Error>>;

fn main() {
    try_main().unwrap_or_else(|err| {
        eprintln!("{}", err);
        process::exit(1)
    });
}

fn try_main() -> Result<()> {
    let output = Command::new("/bin/sh")
        .arg("-c")
        .arg("sudo echo foo")
        .output()
        .map_err(|err| format!("Failed to invoke shell command. {}", err))?;

    io::stdout().write_all(&output.stdout)?;
    io::stderr().write_all(&output.stderr)?;

    if output.status.success() {
        Ok(())
    } else {
        match output.status.code() {
            Some(code) => process::exit(code),
            None => Err("Process terminated by signal".into()),
        }
    }
}
# Correctly prompts for password using sudo.
rustc ./cmd.rs && ./cmd

Calling posix_spawn directly from Swift also seems to work without this issue:

import Foundation

func withCStrings(_ strings: [String], scoped: ([UnsafeMutablePointer<CChar>?]) throws -> Void) rethrows {
    let cStrings = strings.map { strdup($0) }
    try scoped(cStrings + [nil])
    cStrings.forEach { free($0) }
}

enum RunCommandError: Error {
    case WaitPIDError
    case POSIXSpawnError(Int32)
}

func runCommand(_ command: String, completion: ((Int32) -> Void)? = nil) throws {
    var pid: pid_t = 0
    let args = ["sh", "-c", command]
    let envs = ProcessInfo().environment.map { k, v in "\(k)=\(v)" }
    try withCStrings(args) { cArgs in
        try withCStrings(envs) { cEnvs in
            var status = posix_spawn(&pid, "/bin/sh", nil, nil, cArgs, cEnvs)
            if status == 0 {
                if (waitpid(pid, &status, 0) != -1) {
                    completion?(status)
                } else {
                    throw RunCommandError.WaitPIDError
                }
            } else {
                throw RunCommandError.POSIXSpawnError(status)
            }
        }
    }
}

// Correctly prompts for password using sudo.
// From https://gist.github.com/dduan/d4e967f3fc2801d3736b726cd34446bc
try runCommand("sudo echo foo")

// Hangs without prompting until exiting via CTRL-C (and doesn't accept input).
let process = Process()
process.launchPath = "/bin/sh"
process.arguments = ["-c", "sudo echo foo"]
process.launch()
process.waitUntilExit()

As pointed out in the previous reply, this looks similar to what’s being done internally via _CFPosixSpawn.

In an attempt to diagnose the issue further, I tried building the SwiftFoundation framework manually. Oddly enough, just linking the manually built framework directly fixes the issue with no other changes. This happens with either master or swift-5.2.4-RELEASE checked out, in either the debug or release configurations.

git clone https://github.com/apple/swift-corelibs-foundation.git
git switch --detach swift-5.2.4-RELEASE
xcodebuild -project ./Foundation.xcodeproj -derivedDataPath ./build -scheme SwiftFoundation -configuration Debug # (or release)
cd ../example
cat ./Package.swift
// swift-tools-version:5.2
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(name: "example")
package.platforms = [
    .macOS(.v10_13),
]
package.products = [
    .executable(name: "example", targets: ["main"]),
]
package.dependencies = []
package.targets = [
    .target(
        name: "main", 
        dependencies: []
    ),
]
cat ./Sources/main/main.swift
import SwiftFoundation

let process = Process()
process.launchPath = "/bin/sh"
process.arguments = ["-c", "sudo echo foo"]
process.launch()
process.waitUntilExit()
swift build -Xswiftc -F../swift-corelibs-foundation/build/Build/Products/Debug -c debug # (or release)
install_name_tool -add_rpath ../swift-corelibs-foundation/build/Build/Products/Debug ./.build/debug/example

# Correctly prompts for password using sudo.
./.build/debug/example

Anyone have more perspective on what’s going on here?

1 Like

This topic was never answered and as all the others before me I run into the same problem.

I have discovered that the process that will be started does not get connected to STDIN despite the description.

If file is an NSPipe object, launching the receiver automatically closes the read end of the pipe in the current task. Don’t create a handle for the pipe and pass that as the argument, or the read end of the pipe won’t be closed automatically.

If this method isn’t used, the standard input is inherited from the process that created the receiver. This method raises an NSInvalidArgumentException if the receiver has already been launched.

So when you type in your data on the keyboard it is received by the launching application and not by the launched application.

I used the following approach to get it working:

// Create the process
let task = Process()
// Setup the process
process.launchPath = "/bin/sh"
process.arguments = ["-c", "sudo echo foo"]
// Create a pipe that connects to stdin of the new process
let pipe = Pipe()
process.standardInput = pipe
// Create a process that reads from STDIN and writes that read 
// data into the pipe
let filehandle = FileHandle(fileDescriptor: STDIN_FILENO)
fileHandle.readabilityHandler = { handle in
    let data = handle.availableData
    if data.count > 0 {
       pipe.fileHandleForWriting.write(data)
    }
}
// Launch the process
process.launch()
2 Likes

@freda 's solution worked perfectly for me, thank you!

One issue though, it does mean that passwords are shown in plaintext when input is passed through. Is there any way to detect if the child process is asking for secure input and if so get secure input from stdin?

1 Like

A pipe doesn't know the concept of secure data. It is just data. So it is up to the processes at both sides of the pipe to manage hiding secure data.

This is likely happens because Swift's Process uses posix_spawn(2), which has no support for setting terminal process group TPGID, at least on Darwin.

An easy fix would be to call tcsetpgrp(3) after process.run() as follows:

tcsetpgrp(STDIN_FILENO, process.processIdentifier)

See macos - Process cannot read from the standard input in Swift - Stack Overflow for more details.

2 Likes