Skip to content

[Bug]: ExecSandboxInteractive ignores tty=false and always allocates a PTY #2228

Description

@emonq

Agent Diagnostic

I traced the ExecSandboxInteractive request from the protobuf API through the gateway SSH relay.

The start message reuses ExecSandboxRequest, which contains a tty boolean described as requesting a pseudo-terminal. However, the gateway's interactive exec implementation currently calls request_pty() unconditionally and does not use the value of req.tty.

As a result, an interactive streaming request with tty=false still runs inside a PTY.

This prevents clients from using the bidirectional exec RPC for pipe-based protocols such as stdio MCP, which require:

  • persistent streaming stdin;
  • stdout and stderr to remain separate;
  • no terminal echo or line discipline;
  • no PTY newline or control-sequence processing.

Description

ExecSandboxInteractive should respect the tty field in its initial ExecSandboxRequest.

When tty=true, the gateway should request a PTY and forward window resize events.

When tty=false, the gateway should skip the SSH PTY request and ignore resize events while continuing to stream stdin, stdout, and stderr.

Reproduction Steps

The public Python SDK does not currently expose a bidirectional exec method. The following reproduction therefore invokes the generated gRPC client directly to test the gateway's public protobuf RPC.

This is not intended as an example of supported Python SDK usage. It demonstrates the server-side behavior of ExecSandboxInteractive when its start request contains tty=false.

  1. Create a ready sandbox.
  2. Invoke ExecSandboxInteractive through a generated gRPC client:
import sys
import threading

from openshell import SandboxClient
from openshell._proto import openshell_pb2 as pb


PROBE = (
    "[ -t 0 ] && echo STDIN_TTY || echo STDIN_NOTTY; "
    "[ -t 1 ] && echo STDOUT_TTY || echo STDOUT_NOTTY; "
    "[ -t 2 ] && echo STDERR_TTY || echo STDERR_NOTTY"
)


def run_interactive(client, sandbox_id, tty):
    done = threading.Event()

    request = pb.ExecSandboxRequest(
        sandbox_id=sandbox_id,
        command=["/bin/sh", "-c", PROBE],
        tty=tty,
        timeout_seconds=20,
    )

    def requests():
        yield pb.ExecSandboxInput(start=request)
        done.wait(timeout=30)

    print(f"\nRPC: ExecSandboxInteractive")
    print(f"Requested tty: {tty}")
    print("Remote result:")

    try:
        responses = client._stub.ExecSandboxInteractive(
            requests(),
            timeout=40,
        )

        for event in responses:
            payload = event.WhichOneof("payload")

            if payload == "stdout":
                sys.stdout.buffer.write(event.stdout.data)
                sys.stdout.buffer.flush()

            elif payload == "stderr":
                sys.stderr.buffer.write(event.stderr.data)
                sys.stderr.buffer.flush()

            elif payload == "exit":
                print(f"Exit code: {event.exit.exit_code}")
                break
    finally:
        done.set()


def main():
    if len(sys.argv) != 2:
        raise SystemExit(
            f"Usage: {sys.argv[0]} <sandbox-name>"
        )

    client = SandboxClient.from_active_cluster()

    try:
        sandbox = client.get(sys.argv[1])

        run_interactive(
            client,
            sandbox.id,
            tty=False,
        )

        run_interactive(
            client,
            sandbox.id,
            tty=True,
        )
    finally:
        client.close()


if __name__ == "__main__":
    main()
  1. Run the script and observe the output.

Current behavior

The gateway accepts the request but unconditionally sends an SSH PTY allocation request. The process therefore observes terminal-backed file descriptors despite tty=false. The result generated from the above script would be:

RPC: ExecSandboxInteractive
Requested tty: False
Remote result:
STDIN_TTY
STDOUT_TTY
STDERR_TTY
Exit code: 0

RPC: ExecSandboxInteractive
Requested tty: True
Remote result:
STDIN_TTY
STDOUT_TTY
STDERR_TTY
Exit code: 0

The result shows that whatever value tty is set, pty is always allocated, which may incur unexpected behaviors in some applications.

Expected contract

One of the following behaviors should be explicitly implemented:

  1. Honor tty=false by executing with ordinary pipes and preserving separate stdout and stderr streams; or
  2. Reject tty=false with INVALID_ARGUMENT and document that ExecSandboxInteractive is PTY-only.

Silently accepting and ignoring the field makes the protobuf contract ambiguous.

Environment

  • OS: Ubuntu 24.04.4 LTS
  • Docker: Docker version 29.5.3
  • OpenShell: 0.0.80
  • Latest Release Checked: yes
  • Possible duplicates checked: yes

Logs

Agent-First Checklist

  • I pointed my agent at the repo and had it investigate this issue
  • I loaded relevant skills (e.g., debug-openshell-cluster, debug-inference, openshell-cli)
  • I checked the latest OpenShell release and either reproduced the issue there or explained why I cannot upgrade/test it
  • I searched existing issues for possible duplicates or explained why I could not
  • My agent could not resolve this — the diagnostic above explains why

Metadata

Metadata

Assignees

No one assigned

    Labels

    state:triage-neededOpened without agent diagnostics and needs triage

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions