Skip to content

bug(cli): sandbox exec requests a PTY without sending terminal size on the auto-detected TTY path (v0.0.106) #3080

Description

@Dongni-Yang

Summary

On v0.0.106, openshell sandbox exec allocates a remote pseudo-terminal but never sends the client's terminal size whenever TTY mode is auto-detected rather than requested with an explicit --tty. The remote pty is created with cols = 0, rows = 0, and stty size inside the sandbox reports 1 1 instead of the real terminal dimensions.

The wire protocol already carries the size. Only the auto-detect branch of the CLI drops it.

Root cause

crates/openshell-cli/src/run.rs (v0.0.106, blob sha256 63f983e8090ef9f9ae6223c54a83502a013979b6516beca2c3200fa7c852ec54):

// run.rs:1465-1467
// Resolve TTY mode: explicit --tty / --no-tty wins, otherwise auto-detect.
let tty = tty_override
    .unwrap_or_else(|| std::io::stdin().is_terminal() && std::io::stdout().is_terminal());

// run.rs:1469 — the sized path is gated on an EXPLICIT --tty
if tty_override == Some(true) && std::io::stdin().is_terminal() {
    return sandbox_exec_interactive_grpc(...).await;
}

// run.rs:1482-1493 — fallthrough: tty may still be true here
let mut stream = client
    .exec_sandbox(ExecSandboxRequest {
        sandbox_id: ...,
        command: command.to_vec(),
        workdir: ...,
        environment: environment.clone(),
        timeout_seconds,
        stdin: stdin_payload,
        tty,                    // <-- can be true via auto-detection
        ..Default::default()    // <-- cols = 0, rows = 0
    })

The two branches disagree about who owns the window size:

  • sandbox_exec_interactive_grpc (run.rs:1833) reads the real size at run.rs:1846 — crossterm::terminal::size().unwrap_or((80, 24)) — populates cols/rows at run.rs:1861-1862, and forwards later ExecSandboxWindowResize events at run.rs:1915-1917.
  • The exec_sandbox fallthrough sets tty but leaves cols/rows to ..Default::default(), i.e. 0.

proto/openshell.proto at the same tag documents 0 as "use default":

// proto/openshell.proto:1203-1210
// Request a pseudo-terminal for the remote command.
bool tty = 7;
// Initial terminal columns (used when tty=true, 0 = use default).
uint32 cols = 8;
// Initial terminal rows (used when tty=true, 0 = use default).
uint32 rows = 9;

So a pty is requested and the size fields are left at their sentinel, and the resulting pty reports 1 1.

The gate is the problem: the sized path requires tty_override == Some(true). A caller that lets the CLI auto-detect gets tty = true with no size. Requesting a pty and sending its size are decided independently, when they should be decided together.

Reproduction

From a real interactive terminal (verified 120x40), against a Ready sandbox:

$ openshell sandbox exec -n my-sandbox -- stty size
1 1

For contrast, the same command with a pty explicitly requested takes the sized path:

$ openshell sandbox exec -n my-sandbox --tty -- stty size
40 120

Expected

sandbox exec reports the client's real terminal dimensions whenever it allocates a pty, regardless of whether TTY mode was auto-detected or requested explicitly. A pty that is requested should always be sized.

Actual

1 1, silently. Two visible consequences for anything that inspects the terminal:

  • Width-formatted output degrades. Notably dpkg -l routes through a pager when stdout is a tty, and a 1-row pty makes the pager emit zero bytes while still exiting 0 — the command looks like it did nothing despite succeeding.
  • stderr is merged into stdout by the pty, so an outer 2>/dev/null on the exec invocation stops suppressing the remote command's stderr.

Suggested fix

Populate cols/rows on the exec_sandbox request whenever tty resolves to true, using the same crossterm::terminal::size() call the interactive path already makes at run.rs:1846. That keeps the two branches consistent and needs no protocol change, since the fields already exist.

If routing every pty-allocating exec through sandbox_exec_interactive_grpc is preferred, the gate at run.rs:1469 would need to become tty == true && std::io::stdin().is_terminal() rather than testing tty_override.

A related site worth checking in the same pass: the SSH-backed exec at run.rs:974-998 resolves tty the same way and forwards it as a bare bool to sandbox_exec / ssh::sandbox_exec_without_exec with no size argument.

Notes

  • Found while investigating [Ubuntu 24.04][CLI&UX] nemoclaw exec does not propagate real terminal size, silently empties width-formatted output NemoClaw#10753, where the downstream CLI wraps openshell sandbox exec and passes no explicit tty flag, so it always lands on the auto-detect branch.
  • Reported against v0.0.106. The ExecSandboxInteractive method is present in the 0.0.44 binary's gRPC method table, so the sized path is not new; the unsized auto-detect fallthrough is what this issue is about.
  • I read the source at tag v0.0.106 and confirmed the blob hash, but I could not run a live gateway on my host, so the console output above is from the downstream QA report rather than my own terminal. Happy to re-run it if you want an independent capture.

Activity

  1. added
    area:cliCLI-related work
    and removed
    state:triage-neededOpened without agent diagnostics and needs triage
    on Sep 1, 2026
  2. elezar commented on Sep 1, 2026

    @elezar
    Member

    📋 triage-agent

    Triage Assessment

    Classification: validated-bug

    Summary

    Auto-detected interactive terminals request a PTY but take the unary execution path, which does not supply terminal dimensions. The current CLI and gateway code confirm the defect; PR #3084 contains the focused correction.

    Investigation

    sandbox_exec_grpc resolves tty from explicit flags or local terminal detection, but previously chose ExecSandboxInteractive only for explicit --tty. Auto-detected terminal sessions therefore used unary ExecSandbox, whose SSH relay requests the PTY with (0, 0). The interactive path already sends initial dimensions and forwards resize events. The correction makes routing follow the resolved tty value while retaining the stdin-terminal guard.

    The behavior remains present after v0.0.106; no released fix was identified through v0.0.116. Related issue #2228 concerns the separate interactive-RPC tty=false contract and is not a duplicate.

    Impact Signals

    • Affected users/scope: Interactive sandbox exec users and wrappers relying on automatic TTY detection.
    • Regression: Unknown.
    • Workaround: Explicit --tty uses the sized interactive path, but does not suit integrations that rely on defaults.
    • Evidence quality: High; the client-to-server execution path is directly traceable. A live reproduction was not independently rerun during triage.

    Human Decision Required

    Decide whether OpenShell should address this issue. If yes, apply state:accepted, associate it with a roadmap item, or do both, and decide whether the work remains human-owned. Either action records acceptance; roadmap placement additionally records sequencing.

  3. elezar commented on Sep 1, 2026

    @elezar
    Member

    📋 triage-agent

    Implementation Update

    PR #3084 now addresses the issue at both protocol paths:

    • Auto-detected interactive terminals use ExecSandboxInteractive, which sends initial dimensions and forwards resize events.
    • Unary ExecSandbox requests include locally available dimensions when a TTY is requested.
    • The gateway normalizes zero cols or rows to the documented 80×24 default before either execution path requests an SSH PTY.

    The final safeguard also covers SDK and direct RPC callers that omit dimensions, preventing the gateway from requesting a zero-sized PTY.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions