From 7f29261634b55ce9508f646cbf0d475c10087906 Mon Sep 17 00:00:00 2001 From: Nicolas Beck Date: Mon, 5 Oct 2026 13:23:57 +0000 Subject: [PATCH 1/3] cmd/docker: Allow interactive cloud context resolution Forward terminal input to the cloud context provider when stdin and stderr are terminals so it can prompt during resolution. Pass the terminal files directly to preserve terminal detection and avoid intermediary buffering; piped or redirected input stays available to the requested command. Cover interactive resolution and input preservation with subprocess tests. Signed-off-by: Nicolas Beck --- cmd/docker/cloud.go | 14 ++++- cmd/docker/cloud_test.go | 122 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 2 deletions(-) diff --git a/cmd/docker/cloud.go b/cmd/docker/cloud.go index 72a37bd44d5b..9acc3601a256 100644 --- a/cmd/docker/cloud.go +++ b/cmd/docker/cloud.go @@ -43,8 +43,10 @@ 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 file-backed terminals; +// 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 +142,14 @@ 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() + stdin, stdinFile := dockerCli.In().File() + stderr, stderrFile := dockerCli.Err().File() + if stdinFile && stderrFile && dockerCli.In().IsTerminal() && dockerCli.Err().IsTerminal() { + // 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..6c2bef71ae74 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,127 @@ 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" +else + if IFS= read -r unexpected; then + echo 'resolver consumed command input' >&2 + exit 1 + fi +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) { From 1ed798e668118437d3c1930087f66ff4fc9b513a Mon Sep 17 00:00:00 2001 From: Nicolas Beck Date: Mon, 5 Oct 2026 13:35:19 +0000 Subject: [PATCH 2/3] cmd/docker: Avoid duplicate-word lint in resolver test Simplify the shell fixture conditional to avoid a duplicate-word lint warning while preserving the input-consumption checks. Signed-off-by: Nicolas Beck --- cmd/docker/cloud_test.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cmd/docker/cloud_test.go b/cmd/docker/cloud_test.go index 6c2bef71ae74..4f25a97b8b3e 100644 --- a/cmd/docker/cloud_test.go +++ b/cmd/docker/cloud_test.go @@ -524,11 +524,9 @@ if [ -t 0 ]; then printf 'Continue? ' >&2 IFS= read -r reply || exit 1 printf '%s\n' "$reply" > "$DOCKER_CONFIG/resolver-input" -else - if IFS= read -r unexpected; then - echo 'resolver consumed command input' >&2 - exit 1 - fi +elif IFS= read -r unexpected; then + echo 'resolver consumed command input' >&2 + exit 1 fi echo '{"DOCKER_CONTEXT":"resolved"}' `), 0o755)) From 293371f3323bb8afbf7093fbd9e921fff146654b Mon Sep 17 00:00:00 2001 From: Nicolas Beck Date: Mon, 5 Oct 2026 16:20:19 +0000 Subject: [PATCH 3/3] cmd/docker: Support wrapped Windows consoles in cloud resolution Use standard console handles when terminal emulation hides the underlying files. Preserve explicit file streams and leave noninteractive input for the requested command. Signed-off-by: Nicolas Beck --- cmd/docker/cloud.go | 15 ++------ cmd/docker/cloud_io.go | 35 +++++++++++++++++ cmd/docker/cloud_io_test.go | 77 +++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 cmd/docker/cloud_io.go create mode 100644 cmd/docker/cloud_io_test.go diff --git a/cmd/docker/cloud.go b/cmd/docker/cloud.go index 9acc3601a256..8e95096af563 100644 --- a/cmd/docker/cloud.go +++ b/cmd/docker/cloud.go @@ -44,8 +44,9 @@ import ( // The provider must provision into that context store without changing the saved // current context or recursively forwarding --cloud. // It inherits the environment and sends progress and prompts to stderr. Stdin -// is forwarded only when both stdin and stderr are file-backed terminals; -// otherwise the provider receives EOF and must not prompt or open a terminal +// 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: // @@ -141,15 +142,7 @@ 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() - stdin, stdinFile := dockerCli.In().File() - stderr, stderrFile := dockerCli.Err().File() - if stdinFile && stderrFile && dockerCli.In().IsTerminal() && dockerCli.Err().IsTerminal() { - // 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 - } + configureCloudResolverIO(cmd, dockerCli) out, err := cmd.Output() if err != nil { diff --git a/cmd/docker/cloud_io.go b/cmd/docker/cloud_io.go new file mode 100644 index 000000000000..222cf19091d2 --- /dev/null +++ b/cmd/docker/cloud_io.go @@ -0,0 +1,35 @@ +package main + +import ( + "os" + "os/exec" + "runtime" + + "github.com/docker/cli/cli/command" +) + +func configureCloudResolverIO(cmd *exec.Cmd, dockerCli *command.DockerCli) { + cmd.Stderr = dockerCli.Err() + if !dockerCli.In().IsTerminal() || !dockerCli.Err().IsTerminal() { + return + } + + 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 !stdinFile { + stdin, stdinFile = os.Stdin, true + } + if !stderrFile { + stderr, stderrFile = os.Stderr, true + } + } + if 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 + } +} diff --git a/cmd/docker/cloud_io_test.go b/cmd/docker/cloud_io_test.go new file mode 100644 index 000000000000..5feb9425d945 --- /dev/null +++ b/cmd/docker/cloud_io_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "bytes" + "io" + "os" + "os/exec" + "runtime" + "testing" + + "github.com/docker/cli/cli/command" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func TestConfigureCloudResolverIO(t *testing.T) { + stdin, err := os.CreateTemp(t.TempDir(), "stdin") + assert.NilError(t, err) + t.Cleanup(func() { _ = stdin.Close() }) + stderr, err := os.CreateTemp(t.TempDir(), "stderr") + assert.NilError(t, err) + t.Cleanup(func() { _ = stderr.Close() }) + + for _, tc := range []struct { + name string + wrapStdin bool + wrapStderr bool + stdinTerminal bool + stderrTerminal bool + }{ + {name: "terminal files", stdinTerminal: true, stderrTerminal: true}, + {name: "wrapped stdin", wrapStdin: true, stdinTerminal: true, stderrTerminal: true}, + {name: "wrapped stderr", wrapStderr: true, stdinTerminal: true, stderrTerminal: true}, + {name: "wrapped terminals", wrapStdin: true, wrapStderr: true, stdinTerminal: true, stderrTerminal: true}, + {name: "redirected stdin", stderrTerminal: true}, + {name: "redirected stderr", stdinTerminal: true}, + {name: "redirected stdin with wrapped stderr", wrapStderr: true, stderrTerminal: true}, + {name: "redirected stderr with wrapped stdin", wrapStdin: true, stdinTerminal: true}, + {name: "nonterminal wrappers", wrapStdin: true, wrapStderr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + var in io.ReadCloser = stdin + var errOut io.Writer = stderr + if tc.wrapStdin { + in = io.NopCloser(stdin) + } + if tc.wrapStderr { + errOut = struct{ io.Writer }{stderr} + } + dockerCli, err := command.NewDockerCli(command.WithInputStream(in), command.WithErrorStream(errOut)) + assert.NilError(t, err) + // Simulate terminal detection without requiring a console in the test runner. + dockerCli.In().SetIsTerminal(tc.stdinTerminal) + dockerCli.Err().SetIsTerminal(tc.stderrTerminal) + + var stdout bytes.Buffer + cmd := &exec.Cmd{Stdout: &stdout} + configureCloudResolverIO(cmd, dockerCli) + assert.Equal(t, cmd.Stdout, io.Writer(&stdout)) + if !tc.stdinTerminal || !tc.stderrTerminal || (runtime.GOOS != "windows" && (tc.wrapStdin || tc.wrapStderr)) { + assert.Assert(t, is.Nil(cmd.Stdin)) + assert.Equal(t, cmd.Stderr, io.Writer(dockerCli.Err())) + return + } + + wantStdin, wantStderr := stdin, stderr + if tc.wrapStdin { + wantStdin = os.Stdin + } + if tc.wrapStderr { + wantStderr = os.Stderr + } + assert.Equal(t, cmd.Stdin, io.Reader(wantStdin)) + assert.Equal(t, cmd.Stderr, io.Writer(wantStderr)) + }) + } +}