From bdfe2bc075a393cc3d56520db0836c73d97d803b Mon Sep 17 00:00:00 2001 From: emonq Date: Thu, 16 Jul 2026 22:06:02 +0800 Subject: [PATCH] fix(gateway): honor tty flag for interactive exec Pass the requested TTY mode through the interactive SSH relay. Skip PTY allocation and resize forwarding when TTY is disabled, and add regression coverage for both modes. Signed-off-by: emonq --- crates/openshell-server/src/grpc/sandbox.rs | 23 ++++++--- e2e/python/test_sandbox_api.py | 52 +++++++++++++++++++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 8e2f6d25c5..931912481b 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -1284,6 +1284,7 @@ pub(super) async fn handle_exec_sandbox_interactive( let command_str = build_remote_exec_command(&req) .map_err(|e| Status::invalid_argument(format!("command construction failed: {e}")))?; + let request_tty = req.tty; let timeout_seconds = req.timeout_seconds; let cols = if req.cols == 0 { 80 } else { req.cols }; let rows = if req.rows == 0 { 24 } else { req.rows }; @@ -1311,6 +1312,7 @@ pub(super) async fn handle_exec_sandbox_interactive( relay_stream, &command_str, input_stream, + request_tty, timeout_seconds, cols, rows, @@ -1631,6 +1633,7 @@ async fn stream_interactive_exec_over_relay( relay_stream: tokio::io::DuplexStream, command: &str, input_stream: tonic::Streaming, + request_tty: bool, timeout_seconds: u32, cols: u32, rows: u32, @@ -1656,6 +1659,7 @@ async fn stream_interactive_exec_over_relay( local_proxy_port, command, input_stream, + request_tty, cols, rows, tx.clone(), @@ -1707,6 +1711,7 @@ async fn run_interactive_exec_with_russh( local_proxy_port: u16, command: &str, mut input_stream: tonic::Streaming, + request_tty: bool, cols: u32, rows: u32, tx: mpsc::Sender>, @@ -1752,10 +1757,12 @@ async fn run_interactive_exec_with_russh( .await .map_err(|e| Status::internal(format!("failed to open ssh channel: {e}")))?; - channel - .request_pty(false, "xterm-256color", cols, rows, 0, 0, &[]) - .await - .map_err(|e| Status::internal(format!("failed to allocate PTY: {e}")))?; + if request_tty { + channel + .request_pty(false, "xterm-256color", cols, rows, 0, 0, &[]) + .await + .map_err(|e| Status::internal(format!("failed to allocate PTY: {e}")))?; + } channel .exec(true, command.as_bytes()) @@ -1773,9 +1780,11 @@ async fn run_interactive_exec_with_russh( } } Some(Payload::Resize(resize)) => { - let _ = write_half - .window_change(resize.cols, resize.rows, 0, 0) - .await; + if request_tty { + let _ = write_half + .window_change(resize.cols, resize.rows, 0, 0) + .await; + } } Some(Payload::Start(_)) | None => {} } diff --git a/e2e/python/test_sandbox_api.py b/e2e/python/test_sandbox_api.py index 929728d7c3..c52a4f0774 100644 --- a/e2e/python/test_sandbox_api.py +++ b/e2e/python/test_sandbox_api.py @@ -3,8 +3,11 @@ from __future__ import annotations +import threading from typing import TYPE_CHECKING +from openshell._proto import openshell_pb2 + if TYPE_CHECKING: from collections.abc import Callable @@ -60,6 +63,55 @@ def read(self, path: str) -> str: assert verify_file.stdout.strip() == "ok" +def test_sandbox_interactive_exec_honors_tty( + sandbox: Callable[..., Sandbox], + sandbox_client: SandboxClient, +) -> None: + def exec_interactive(sandbox_id: str, *, tty: bool) -> bytes: + request = openshell_pb2.ExecSandboxInput( + start=openshell_pb2.ExecSandboxRequest( + sandbox_id=sandbox_id, + command=[ + "/bin/sh", + "-c", + "[ -t 0 ] && printf T || printf N; " + "[ -t 1 ] && printf T || printf N; " + "[ -t 2 ] && printf T || printf N; printf '\\n'", + ], + tty=tty, + timeout_seconds=20, + ) + ) + + done = threading.Event() + + def requests(): + yield request + done.wait(timeout=30) + + output: list[bytes] = [] + exit_code: int | None = None + try: + events = sandbox_client._stub.ExecSandboxInteractive(requests(), timeout=30) + for event in events: + payload = event.WhichOneof("payload") + if payload == "stdout": + output.append(bytes(event.stdout.data)) + elif payload == "stderr": + output.append(bytes(event.stderr.data)) + elif payload == "exit": + exit_code = int(event.exit.exit_code) + finally: + done.set() + + assert exit_code == 0 + return b"".join(output) + + with sandbox(delete_on_exit=True) as sb: + assert b"NNN" in exec_interactive(sb.id, tty=False) + assert b"TTT" in exec_interactive(sb.id, tty=True) + + def test_sandbox_labels_and_selectors(sandbox_client: SandboxClient) -> None: import contextlib import uuid