Skip to content

fix(shell): support non-TTY stdin for qovery shell --command#636

Merged
Guimove merged 2 commits into
mainfrom
fix/shell-non-tty-command
May 7, 2026
Merged

fix(shell): support non-TTY stdin for qovery shell --command#636
Guimove merged 2 commits into
mainfrom
fix/shell-non-tty-command

Conversation

@Guimove
Copy link
Copy Markdown
Contributor

@Guimove Guimove commented May 7, 2026

Summary

Fix qovery shell --command ... so it can be invoked from non-interactive contexts (CI, scripted automation, AI agent runners) without panicking, and so piped commands like cat < file or sh -s < script.sh actually finish instead of hanging on stdin.

Two related changes on the same branch:

  • 08129c2 Skip PTY allocation when stdin is not a TTY. pkg/shell.go previously called containerd/console.Current() unconditionally; that function panics with provided file is not a console whenever the controlling terminal isn't available. The fix detects the TTY via golang.org/x/term.IsTerminal(os.Stdin.Fd()). When interactive, behavior is unchanged. When non-interactive, we pipe os.Stdin/os.Stdout straight through (no raw mode, no size detection) and leave TtyWidth/TtyHeight at zero. The read/write goroutines now take io.Reader/io.Writer instead of console.Console so the same code paths cover both modes.

  • 1986575 Propagate stdin EOF to the remote process via EOT. Without this, the non-interactive reader returned silently on EOF and the remote command kept waiting for input. The fix flushes any buffered bracketed-paste bytes, then sends 0x04 (EOT) through the existing stdIn channel before returning. The remote side (which always runs under AttachParams::interactive_tty() in rust-backend/shell-agent/src/lib/kubernetes.rs:90) translates EOT to EOF via canonical-mode line discipline. Same mechanism the agent itself uses at rust-backend/shell-agent/src/lib/shell.rs:79. The websocket stays open so remote stdout drains until the server closes the session.

Why this matters

Three real workflows are unblocked:

  1. CI / scripted automation: qovery shell --command "./run-migration.sh" from a pipeline
  2. Piped input: cat seed.sql | qovery shell --command "psql"
  3. AI agent runners launching tools (e.g. Claude Code CLI) inside a remote dev environment without a human terminal

Interactive qovery shell from a developer terminal is unchanged.

Test plan

  • gofmt -l pkg/shell.go clean
  • go vet ./pkg/ clean
  • go build ./pkg/... ./cmd/... ok
  • go test ./pkg/ (24 pass)
  • Smoke test: qovery shell from a real terminal still attaches with raw mode and resize
  • Smoke test: echo "hello" | qovery-cli shell --command cat -- ... outputs hello and exits cleanly
  • Smoke test: qovery shell --command "./script.sh" < script.sh runs to completion and exits

Notes

  • golang.org/x/term was already in the module graph as an indirect dep; go mod tidy promoted it to direct and cleaned up three stale qovery-client-go go.sum entries (collateral cleanup, not behavioral).
  • Considered sending a websocket Close frame from the CLI on stdin EOF instead of EOT, but it tangles with the reconnect loop in ExecShell and risks tearing down stdout before it drains. Pure data-plane EOT is symmetric with what the agent does and requires no server-side changes.

Fix #632

Guimove added 2 commits May 7, 2026 09:24
`qovery shell --command ...` panicked with "provided file is not a console"
when invoked from a non-interactive context (CI, scripted automation, AI
agent runners) because pkg/shell.go called console.Current() unconditionally.

Detect TTY via golang.org/x/term.IsTerminal. When stdin is not a terminal,
behave like `kubectl exec -i`: pipe os.Stdin/os.Stdout straight through, leave
TtyWidth/TtyHeight at zero, and on stdin EOF stop reading without cancelling
so the websocket loop can drain remote stdout until the server closes.

Read goroutines now take io.Reader / io.Writer instead of console.Console.
Follow-up to the non-TTY support: when piped stdin reached EOF the reader
just returned, leaving the websocket open with no signal sent to the remote
end. Commands that read until EOF (cat < file, sh -s < script.sh, psql
< seed.sql, ...) hung indefinitely because the remote process kept waiting
for input it would never receive.

The remote side runs under AttachParams::interactive_tty(), so the PTY line
discipline interprets a literal 0x04 (EOT) as canonical-mode EOF. The
shell-agent already uses this exact mechanism to tear down the remote shell
when the gRPC stdin stream ends. Doing the same from the CLI on local stdin
EOF is symmetric and requires no server-side changes: send EOT, keep the
websocket open so remote stdout can drain, exit when the server closes.

Pending bracketed-paste bytes are flushed first so we never drop trailing
input. All sends respect ctx.Done() to avoid blocking on cancellation.
@Guimove
Copy link
Copy Markdown
Contributor Author

Guimove commented May 7, 2026

Tests done :

  # === SANITY ===
  /tmp/qovery-cli-fix --help | head -5

  # === 1. Non-interactive, default sh, piped input (was the panic case) ===
  echo "echo hello-from-pipe; uname -a; id" | /tmp/qovery-cli-fix shell \
    --organization "Q Sandbox" --project GDS --environment Local \
    --service shell-test-fix --command sh
  # expected: "hello-from-pipe", uname output, uid=0, exit clean. NO panic.

  # === 2. cat < file (EOT propagation test) ===
  printf 'line1\nline2\nline3\n' > /tmp/input.txt
  time /tmp/qovery-cli-fix shell \
    --organization "Q Sandbox" --project GDS --environment Local \
    --service shell-test-fix --command cat < /tmp/input.txt
  # expected: 3 lines back, total runtime < 5s. >30s = hang = fix broken.

  # === 3. sh -s < script (heredoc / script execution) ===
  cat > /tmp/script.sh <<'EOF'
  echo "starting from script"
  ls -la /etc | head -5
  echo "done"
  EOF
  /tmp/qovery-cli-fix shell \
    --organization "Q Sandbox" --project GDS --environment Local \
    --service shell-test-fix --command sh -- -s < /tmp/script.sh
  # expected: 3 sections of output, exit clean.

  # === 4. /dev/null stdin (CI scenario, no input at all) ===
  /tmp/qovery-cli-fix shell \
    --organization "Q Sandbox" --project GDS --environment Local \
    --service shell-test-fix --command sh -- -c "echo 'agent-style invocation'; whoami" < /dev/null
  # expected: "agent-style invocation", "root", exit clean.

  # === 5. Interactive non-regression (run from a real terminal, no pipe) ===
  /tmp/qovery-cli-fix shell \
    --organization "Q Sandbox" --project GDS --environment Local \
    --service shell-test-fix
  # expected: prompt "/ #", raw mode, resize works, Ctrl-D exits.

  # === 6. Compare against the OLD installed binary (should panic) ===
  echo "" | qovery shell \
    --organization "Q Sandbox" --project GDS --environment Local \
    --service shell-test-fix --command sh
  # expected with OLD binary: panic: provided file is not a console
  # expected with FIXED binary (/tmp/qovery-cli-fix): runs cleanly

@Guimove Guimove merged commit 58a7851 into main May 7, 2026
6 checks passed
@Guimove Guimove deleted the fix/shell-non-tty-command branch May 7, 2026 09:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

qovery shell --command panics when no TTY is available

2 participants