From 488292ca1f1a6529098639bf801368106f553d9e Mon Sep 17 00:00:00 2001 From: Nicolas Beck Date: Mon, 5 Oct 2026 19:59:25 +0000 Subject: [PATCH] cmd/docker: Allow interactive cloud context resolution Forward stdin and stderr to cloud context providers when both are terminals, allowing prompts during --cloud resolution. Pass file handles directly to preserve terminal detection and leave piped or redirected input available to the requested command. Use the standard console handles when Windows terminal wrappers hide their underlying files. Keep stdout reserved for the JSON response. Signed-off-by: Nicolas Beck --- cmd/docker/cloud.go | 28 ++++++++- cmd/docker/cloud_test.go | 120 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 2 deletions(-) diff --git a/cmd/docker/cloud.go b/cmd/docker/cloud.go index 72a37bd44d5b..cc3a775e49a7 100644 --- a/cmd/docker/cloud.go +++ b/cmd/docker/cloud.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "os/exec" + "runtime" "strconv" "strings" @@ -43,8 +44,11 @@ import ( // The effective config directory is also passed as DOCKER_CONFIG. // The provider must provision into that context store without changing the saved // current context or recursively forwarding --cloud. -// It inherits the environment, receives no interactive stdin, and sends progress -// to stderr. +// It inherits the environment and sends progress and prompts to stderr. Stdin +// is forwarded only when both stdin and stderr are terminals with file handles; +// wrapped Windows consoles use the corresponding standard handles. Otherwise +// the provider receives EOF and must not prompt or open a terminal +// separately. Piped and redirected input belongs to the requested command. // Stdout must contain exactly one JSON object: // // {"DOCKER_CONTEXT":"provisioned-context"} @@ -140,6 +144,26 @@ func resolveCloudContext(ctx context.Context, dockerCli *command.DockerCli, root cmd := exec.CommandContext(ctx, plugin.Path, "--config="+config.Dir(), plugin.Name, "__resolve-context", "--", name) // #nosec G204 -- executable validated through CLI plugin discovery cmd.Env = append(os.Environ(), config.EnvOverrideConfigDir+"="+config.Dir(), metadata.ReexecEnvvar+"="+os.Args[0]) cmd.Stderr = dockerCli.Err() + stdinTerminal := dockerCli.In().IsTerminal() + stderrTerminal := dockerCli.Err().IsTerminal() + stdin, stdinFile := dockerCli.In().File() + stderr, stderrFile := dockerCli.Err().File() + if runtime.GOOS == "windows" { + // term.StdStreams can wrap console handles for terminal emulation, + // preventing File from exposing them to the child process. + if stdinTerminal && !stdinFile { + stdin, stdinFile = os.Stdin, true + } + if stderrTerminal && !stderrFile { + stderr, stderrFile = os.Stderr, true + } + } + if stdinTerminal && stderrTerminal && stdinFile && stderrFile { + // Pass files directly: wrapping them makes os/exec copy through pipes, + // hiding terminal identity and potentially consuming the command's input. + cmd.Stdin = stdin + cmd.Stderr = stderr + } out, err := cmd.Output() if err != nil { diff --git a/cmd/docker/cloud_test.go b/cmd/docker/cloud_test.go index 9f6b1ab463d5..4f25a97b8b3e 100644 --- a/cmd/docker/cloud_test.go +++ b/cmd/docker/cloud_test.go @@ -16,6 +16,7 @@ import ( "testing" "time" + "github.com/creack/pty" "github.com/docker/cli/cli-plugins/metadata" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/config" @@ -488,6 +489,125 @@ printf '%s\n' "$@" > "$CLOUD_TEST_ARGS" } } +func TestCloudResolverInput(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fixture plugins use shell scripts and pseudo-terminals") + } + + executable, err := os.Executable() + assert.NilError(t, err) + + for _, tc := range []struct { + name string + stdinType string + terminalErr bool + prompt bool + }{ + {name: "interactive", stdinType: "terminal", terminalErr: true, prompt: true}, + {name: "piped stdin", stdinType: "pipe", terminalErr: true}, + {name: "redirected stdin", stdinType: "file", terminalErr: true}, + {name: "redirected stderr", stdinType: "terminal"}, + {name: "noninteractive", stdinType: "pipe"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("DOCKER_CLI_HOOKS", "false") + configDir := t.TempDir() + pluginDir := filepath.Join(configDir, "cli-plugins") + assert.NilError(t, os.MkdirAll(pluginDir, 0o755)) + assert.NilError(t, os.WriteFile(filepath.Join(pluginDir, "docker-offload"), []byte(`#!/bin/sh +if [ "$1" = docker-cli-plugin-metadata ]; then + echo '{"SchemaVersion":"0.1.0","Vendor":"test","Features":{"cloud-context-resolver":true}}' + exit 0 +fi +if [ -t 0 ]; then + [ -t 2 ] || exit 1 + printf 'Continue? ' >&2 + IFS= read -r reply || exit 1 + printf '%s\n' "$reply" > "$DOCKER_CONFIG/resolver-input" +elif IFS= read -r unexpected; then + echo 'resolver consumed command input' >&2 + exit 1 +fi +echo '{"DOCKER_CONTEXT":"resolved"}' +`), 0o755)) + assert.NilError(t, os.WriteFile(filepath.Join(pluginDir, "docker-cloudtest"), []byte(`#!/bin/sh +if [ "$1" = docker-cli-plugin-metadata ]; then + echo '{"SchemaVersion":"0.1.0","Vendor":"test"}' + exit 0 +fi +IFS= read -r input || exit 1 +printf '%s\n' "$input" +`), 0o755)) + + contextStore := store.New(filepath.Join(configDir, "contexts"), command.DefaultContextStoreConfig()) + assert.NilError(t, contextStore.CreateOrUpdate(store.Metadata{ + Name: "resolved", + Endpoints: map[string]any{contextdocker.DockerEndpoint: contextdocker.EndpointMeta{Host: "tcp://127.0.0.1:1"}}, + })) + + terminal, tty, err := pty.Open() + assert.NilError(t, err) + t.Cleanup(func() { + _ = tty.Close() + _ = terminal.Close() + }) + + const commandInput = "input for the original command\n" + var stdin *os.File + switch tc.stdinType { + case "terminal": + stdin = tty + input := commandInput + if tc.prompt { + input = "yes\n" + input + } + _, err = terminal.WriteString(input) + assert.NilError(t, err) + case "pipe": + var writer *os.File + stdin, writer, err = os.Pipe() + assert.NilError(t, err) + t.Cleanup(func() { _ = stdin.Close() }) + t.Cleanup(func() { _ = writer.Close() }) + _, err = writer.WriteString(commandInput) + assert.NilError(t, err) + assert.NilError(t, writer.Close()) + case "file": + inputPath := filepath.Join(configDir, "command-input") + assert.NilError(t, os.WriteFile(inputPath, []byte(commandInput), 0o600)) + stdin, err = os.Open(inputPath) + assert.NilError(t, err) + t.Cleanup(func() { _ = stdin.Close() }) + } + + payload, err := json.Marshal([]string{"docker", "--config=" + configDir, "--cloud", "cloudtest"}) + assert.NilError(t, err) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + var stdout, stderr bytes.Buffer + cmd := exec.CommandContext(ctx, executable, "-test.run=^TestCloudCommandProcess$") + cmd.Env = append(os.Environ(), "CLOUD_TEST_COMMAND="+string(payload)) + cmd.Stdin = stdin + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.WaitDelay = time.Second + if tc.terminalErr { + cmd.Stderr = tty + } + assert.NilError(t, cmd.Run(), stderr.String()) + assert.Equal(t, stdout.String(), commandInput) + + reply, err := os.ReadFile(filepath.Join(configDir, "resolver-input")) + if tc.prompt { + assert.NilError(t, err) + assert.Equal(t, string(reply), "yes\n") + } else { + assert.Assert(t, os.IsNotExist(err)) + } + }) + } +} + // Run startup in a separate process because it installs process-wide signal // handlers that intentionally outlive an individual command. func TestCloudCommandProcess(t *testing.T) {