From 488292ca1f1a6529098639bf801368106f553d9e Mon Sep 17 00:00:00 2001 From: Nicolas Beck Date: Mon, 5 Oct 2026 19:59:25 +0000 Subject: [PATCH 1/2] 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) { From 8503a98b68c761c2b537a34dc0dd8bd47865250d Mon Sep 17 00:00:00 2001 From: Nick Sieger Date: Mon, 5 Oct 2026 15:29:43 -0500 Subject: [PATCH 2/2] refactor(cmd/docker): split resolveCloudContext to satisfy gocyclo - extract setResolverStdio for resolver terminal wiring - extract validateResolvedContext for context/endpoint checks Signed-off-by: Nick Sieger --- cmd/docker/cloud.go | 68 ++++++++++++++++++++++++++++----------------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/cmd/docker/cloud.go b/cmd/docker/cloud.go index cc3a775e49a7..1999e70be08b 100644 --- a/cmd/docker/cloud.go +++ b/cmd/docker/cloud.go @@ -143,6 +143,38 @@ 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]) + setResolverStdio(cmd, dockerCli) + + out, err := cmd.Output() + if err != nil { + if ctx.Err() != nil { + return "", ctx.Err() + } + return "", fmt.Errorf("cloud resolver failed: %w", err) + } + + var response struct { + DockerContext string `json:"DOCKER_CONTEXT"` + } + if err := json.Unmarshal(out, &response); err != nil { + return "", fmt.Errorf("invalid cloud resolver response: %w", err) + } + response.DockerContext = strings.TrimSpace(response.DockerContext) + if response.DockerContext == "" || response.DockerContext == command.DefaultContextName { + return "", errors.New("cloud resolver must return a non-default DOCKER_CONTEXT") + } + + if err := validateResolvedContext(dockerCli, response.DockerContext); err != nil { + return "", err + } + + return response.DockerContext, nil +} + +// setResolverStdio connects the resolver to the terminal when both stdin and +// stderr are terminals, so the plugin can prompt interactively. Otherwise +// stderr is forwarded and stdin is left unset. +func setResolverStdio(cmd *exec.Cmd, dockerCli *command.DockerCli) { cmd.Stderr = dockerCli.Err() stdinTerminal := dockerCli.In().IsTerminal() stderrTerminal := dockerCli.Err().IsTerminal() @@ -164,41 +196,25 @@ func resolveCloudContext(ctx context.Context, dockerCli *command.DockerCli, root cmd.Stdin = stdin cmd.Stderr = stderr } +} - out, err := cmd.Output() - if err != nil { - if ctx.Err() != nil { - return "", ctx.Err() - } - return "", fmt.Errorf("cloud resolver failed: %w", err) - } - - var response struct { - DockerContext string `json:"DOCKER_CONTEXT"` - } - if err := json.Unmarshal(out, &response); err != nil { - return "", fmt.Errorf("invalid cloud resolver response: %w", err) - } - response.DockerContext = strings.TrimSpace(response.DockerContext) - if response.DockerContext == "" || response.DockerContext == command.DefaultContextName { - return "", errors.New("cloud resolver must return a non-default DOCKER_CONTEXT") - } - - // Do not allow a missing context or endpoint to fall back to the local engine. - meta, err := dockerCli.ContextStore().GetMetadata(response.DockerContext) +// validateResolvedContext checks that the context exists and has a Docker +// endpoint host, so a missing context or endpoint cannot fall back to the +// local engine. +func validateResolvedContext(dockerCli *command.DockerCli, name string) error { + meta, err := dockerCli.ContextStore().GetMetadata(name) if err != nil { - return "", fmt.Errorf("loading resolved context %q: %w", response.DockerContext, err) + return fmt.Errorf("loading resolved context %q: %w", name, err) } endpoint, err := contextdocker.EndpointFromContext(meta) if err != nil { - return "", fmt.Errorf("invalid resolved context %q: %w", response.DockerContext, err) + return fmt.Errorf("invalid resolved context %q: %w", name, err) } if endpoint.Host == "" { - return "", fmt.Errorf("resolved context %q has no Docker endpoint host", response.DockerContext) + return fmt.Errorf("resolved context %q has no Docker endpoint host", name) } - - return response.DockerContext, nil + return nil } // cloudHelpRequest avoids provisioning for help and shell completion.