fix: use readline-capable shell for interactive terminal sessions

Container terminals were echoing raw ANSI escape sequences (^[[A, ^[[B,
etc.) instead of handling arrow keys because /bin/sh (dash/ash) lacks
readline support. Two changes fix this:

1. Bridge execPTY now directly exec's bare paths (e.g. /bin/bash) instead
   of always wrapping through "/bin/sh -c", preserving readline behavior.
2. Terminal handler detects bash/zsh in the container and prefers them
   over /bin/sh for interactive PTY sessions.
This commit is contained in:
Acbox
2026-03-29 19:31:24 +08:00
parent 7825b49ff3
commit 86d83108d9
2 changed files with 40 additions and 3 deletions
+22 -1
View File
@@ -194,7 +194,12 @@ func execPTY(stream pb.ContainerService_ExecServer, firstMsg *pb.ExecInput) erro
workDir = defaultWorkDir workDir = defaultWorkDir
} }
cmd := exec.CommandContext(stream.Context(), "/bin/sh", "-c", command) //nolint:gosec // G204: intentional var cmd *exec.Cmd
if isBarePath(command) {
cmd = exec.CommandContext(stream.Context(), command) //nolint:gosec // G204: intentional
} else {
cmd = exec.CommandContext(stream.Context(), "/bin/sh", "-c", command) //nolint:gosec // G204: intentional
}
cmd.Dir = workDir cmd.Dir = workDir
cmd.Env = append(os.Environ(), firstMsg.GetEnv()...) cmd.Env = append(os.Environ(), firstMsg.GetEnv()...)
cmd.Env = append(cmd.Env, "TERM=xterm-256color") cmd.Env = append(cmd.Env, "TERM=xterm-256color")
@@ -474,6 +479,22 @@ func (*containerServer) Rename(_ context.Context, req *pb.RenameRequest) (*pb.Re
return &pb.RenameResponse{}, nil return &pb.RenameResponse{}, nil
} }
// isBarePath returns true when the command string is a plain executable path
// (no spaces, shell operators, or arguments) so it can be exec'd directly
// without wrapping in "/bin/sh -c". This matters for interactive PTY shells
// where /bin/sh -c wrapping would strip readline support.
func isBarePath(cmd string) bool {
if cmd == "" {
return false
}
for _, c := range cmd {
if c == ' ' || c == '\t' || c == '|' || c == '&' || c == ';' || c == '>' || c == '<' || c == '$' || c == '(' || c == ')' || c == '`' {
return false
}
}
return strings.HasPrefix(cmd, "/") || !strings.Contains(cmd, "/")
}
func streamPipe(stream pb.ContainerService_ExecServer, r io.Reader, st pb.ExecOutput_Stream) { func streamPipe(stream pb.ContainerService_ExecServer, r io.Reader, st pb.ExecOutput_Stream) {
buf := make([]byte, 4096) buf := make([]byte, 4096)
for { for {
+18 -2
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"context"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -9,6 +10,7 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/memohai/memoh/internal/workspace/bridge"
pb "github.com/memohai/memoh/internal/workspace/bridgepb" pb "github.com/memohai/memoh/internal/workspace/bridgepb"
) )
@@ -50,9 +52,10 @@ func (h *ContainerdHandler) GetTerminalInfo(c echo.Context) error {
return c.JSON(http.StatusOK, terminalInfoResponse{Available: false}) return c.JSON(http.StatusOK, terminalInfoResponse{Available: false})
} }
shell := detectShell(ctx, client)
return c.JSON(http.StatusOK, terminalInfoResponse{ return c.JSON(http.StatusOK, terminalInfoResponse{
Available: true, Available: true,
Shell: "/bin/sh", Shell: shell,
}) })
} }
@@ -92,7 +95,8 @@ func (h *ContainerdHandler) HandleTerminalWS(c echo.Context) error {
} }
defer func() { _ = conn.Close() }() defer func() { _ = conn.Close() }()
execStream, err := client.ExecStreamPTY(ctx, "/bin/sh", "/data", cols, rows) shell := detectShell(ctx, client)
execStream, err := client.ExecStreamPTY(ctx, shell, "/data", cols, rows)
if err != nil { if err != nil {
_ = conn.WriteMessage(websocket.CloseMessage, _ = conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "exec failed")) websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "exec failed"))
@@ -154,6 +158,18 @@ func (h *ContainerdHandler) HandleTerminalWS(c echo.Context) error {
return nil return nil
} }
// detectShell probes the container for an interactive shell with readline support.
// Prefers bash > zsh > /bin/sh.
func detectShell(ctx context.Context, client *bridge.Client) string {
for _, sh := range []string{"/bin/bash", "/usr/bin/bash", "/bin/zsh", "/usr/bin/zsh"} {
result, err := client.Exec(ctx, "test -x "+sh, "/", 5)
if err == nil && result.ExitCode == 0 {
return sh
}
}
return "/bin/sh"
}
func parseUint32Query(c echo.Context, name string, fallback uint32) uint32 { func parseUint32Query(c echo.Context, name string, fallback uint32) uint32 {
raw := c.QueryParam(name) raw := c.QueryParam(name)
if raw == "" { if raw == "" {