Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
Expand Down
6 changes: 4 additions & 2 deletions pkg/sandbox/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand Down
49 changes: 49 additions & 0 deletions pkg/sandbox/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
11 changes: 10 additions & 1 deletion pkg/tui/internal/editorname/editorname.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
15 changes: 15 additions & 0 deletions pkg/tui/internal/editorname/editorname_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package editorname

import (
"os"
"os/exec"
goruntime "runtime"
"testing"

Expand Down Expand Up @@ -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)
})
}
}
Expand All @@ -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")
}
Loading