diff --git a/Dockerfile b/Dockerfile index d449939b7..dfba6aa8e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -90,7 +90,7 @@ USER agent ENV DOCKER_AGENT_NO_TOUR=1 \ DOCKER_AGENT_HIDE_TELEMETRY_BANNER=1 \ COLORTERM=truecolor \ - LANG=en_US.UTF-8 + LANG=C.UTF-8 COPY --from=builder-linux --chmod=0755 /binaries/docker-agent-$TARGETOS-$TARGETARCH /usr/local/bin/docker-agent LABEL com.docker.sandboxes.flavor="docker-agent-docker" CMD [ "docker-agent" ] diff --git a/pkg/sandbox/sandbox.go b/pkg/sandbox/sandbox.go index cadc8c4bd..566703df4 100644 --- a/pkg/sandbox/sandbox.go +++ b/pkg/sandbox/sandbox.go @@ -241,11 +241,13 @@ func (b *Backend) BuildExecCmd(ctx context.Context, name, wd string, cagentArgs, execExtra := []string{"-it", "-w", wd} execExtra = append(execExtra, envFlags...) - // Improve the rendering of the TUI + // Improve the rendering of the TUI. C.UTF-8 is the only UTF-8 locale + // shipped by the template image; a locale it lacks (en_US.UTF-8) makes + // vim fall back to latin1 and mangle non-ASCII input. execExtra = append(execExtra, "-e", "TERM=xterm-256color", "-e", "COLORTERM=truecolor", - "-e", "LANG=en_US.UTF-8", + "-e", "LANG=C.UTF-8", name, "docker-agent", "run", ) execExtra = append(execExtra, cagentArgs...) diff --git a/pkg/sandbox/sandbox_test.go b/pkg/sandbox/sandbox_test.go index 20302b0dc..6bd942505 100644 --- a/pkg/sandbox/sandbox_test.go +++ b/pkg/sandbox/sandbox_test.go @@ -318,6 +318,55 @@ func TestAllowHosts_SkipsEmptyEntries(t *testing.T) { require.NoError(t, backend.AllowHosts(t.Context(), "sandbox-x", []string{"", " ", "\t"})) } +// BuildExecCmd must inject LANG=C.UTF-8 — the only UTF-8 locale shipped +// by the sandbox template image; a locale the image lacks (en_US.UTF-8) +// silently degrades vim to latin1 and mangles non-ASCII input (#3874). +// It must also hand the wrapper the real host stdio for the interactive +// TUI session. +func TestBuildExecCmd(t *testing.T) { + fakeDir := t.TempDir() + writeMockScript(t, fakeDir, "sbx", "exit 0") + t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + tests := []struct { + name string + backend *sandbox.Backend + wantPrefix []string + }{ + { + name: "docker", + backend: sandbox.NewBackend(false), + wantPrefix: []string{"docker", "sandbox", "exec"}, + }, + { + name: "sbx", + backend: sandbox.NewBackend(true), + wantPrefix: []string{"sbx", "exec"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := tt.backend.BuildExecCmd(t.Context(), "my-sbx", "/my/project", + []string{"agent.yaml", "--yolo"}, []string{"-e", "FOO"}, []string{"FOO=bar"}) + + want := append(tt.wantPrefix, + "-it", "-w", "/my/project", + "-e", "FOO", + "-e", "TERM=xterm-256color", + "-e", "COLORTERM=truecolor", + "-e", "LANG=C.UTF-8", + "my-sbx", "docker-agent", "run", + "agent.yaml", "--yolo", + ) + assert.Equal(t, want, cmd.Args) + assert.Same(t, os.Stdin, cmd.Stdin, "Stdin must be os.Stdin") + assert.Same(t, os.Stdout, cmd.Stdout, "Stdout must be os.Stdout") + assert.Same(t, os.Stderr, cmd.Stderr, "Stderr must be os.Stderr") + assert.Contains(t, cmd.Env, "FOO=bar") + }) + } +} + // writeMockScript writes a mock executable script to the given directory. // On Windows, it converts the POSIX shell script to a basic .bat script. // diff --git a/pkg/tui/internal/editorname/editorname.go b/pkg/tui/internal/editorname/editorname.go index ede8611e3..bde4f624d 100644 --- a/pkg/tui/internal/editorname/editorname.go +++ b/pkg/tui/internal/editorname/editorname.go @@ -98,6 +98,11 @@ func Command(path string) *exec.Cmd { // deliberately no shell evaluation), extra tokens become leading arguments, // and path is appended last. When neither variable yields a command, the // platform default is launched ("notepad" on Windows, "vi" elsewhere). +// +// Stdin/Stdout/Stderr are bound to the real terminal files: left nil, +// tea.ExecProcess fills them from the Program, whose output is the +// non-*os.File image-writer wrapper — the editor would then see a pipe +// instead of a TTY ("Vim: Warning: Output is not to a terminal"). func CommandFromEnv(visual, editorEnv, path string) *exec.Cmd { parts := strings.Fields(cmp.Or(visual, editorEnv)) if len(parts) == 0 { @@ -109,5 +114,9 @@ func CommandFromEnv(visual, editorEnv, path string) *exec.Cmd { } args := append(parts[1:], path) // The editor process is owned by tea.ExecProcess, so exec.Command is intentional. - return exec.Command(parts[0], args...) //nolint:noctx // owned by tea.ExecProcess + cmd := exec.Command(parts[0], args...) //nolint:noctx // owned by tea.ExecProcess + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd } diff --git a/pkg/tui/internal/editorname/editorname_test.go b/pkg/tui/internal/editorname/editorname_test.go index cb09ff519..8a11ec757 100644 --- a/pkg/tui/internal/editorname/editorname_test.go +++ b/pkg/tui/internal/editorname/editorname_test.go @@ -1,6 +1,8 @@ package editorname import ( + "os" + "os/exec" goruntime "runtime" "testing" @@ -206,6 +208,7 @@ func TestCommandFromEnv(t *testing.T) { cmd := CommandFromEnv(tt.visual, tt.editorEnv, "/tmp/draft.md") assert.Equal(t, tt.wantArgs, cmd.Args) + assertRealTerminalStdio(t, cmd) }) } } @@ -216,4 +219,16 @@ func TestCommandReadsEnvironment(t *testing.T) { cmd := Command("/tmp/draft.md") assert.Equal(t, []string{"code", "--wait", "/tmp/draft.md"}, cmd.Args) + assertRealTerminalStdio(t, cmd) +} + +// assertRealTerminalStdio checks that the command's stdio is exactly the +// process's OS files. Nil entries would let tea.ExecProcess substitute the +// Program's image-writer output, which is not an *os.File — the editor then +// runs against a pipe (#3873, "Vim: Warning: Output is not to a terminal"). +func assertRealTerminalStdio(t *testing.T, cmd *exec.Cmd) { + t.Helper() + assert.Same(t, os.Stdin, cmd.Stdin, "Stdin must be os.Stdin") + assert.Same(t, os.Stdout, cmd.Stdout, "Stdout must be os.Stdout") + assert.Same(t, os.Stderr, cmd.Stderr, "Stderr must be os.Stderr") }