From bdfe2bc075a393cc3d56520db0836c73d97d803b Mon Sep 17 00:00:00 2001 From: emonq Date: Thu, 16 Jul 2026 22:06:02 +0800 Subject: [PATCH 1/2] 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 From a34b6a52e8ccc1f99e135f7677b57ce9ab0aa45d Mon Sep 17 00:00:00 2001 From: emonq Date: Sat, 18 Jul 2026 13:53:35 +0800 Subject: [PATCH 2/2] test(gateway): improve `test_sandbox_interactive_exec_honors_tty` to test streamed stdin and stdout/stderr Signed-off-by: emonq --- e2e/python/test_sandbox_api.py | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/e2e/python/test_sandbox_api.py b/e2e/python/test_sandbox_api.py index c52a4f0774..f22346611f 100644 --- a/e2e/python/test_sandbox_api.py +++ b/e2e/python/test_sandbox_api.py @@ -67,7 +67,11 @@ def test_sandbox_interactive_exec_honors_tty( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, ) -> None: - def exec_interactive(sandbox_id: str, *, tty: bool) -> bytes: + stdin_sentinel = b"streamed-stdin-sentinel" + stdout_sentinel = b"stdout-sentinel" + stderr_sentinel = b"stderr-sentinel" + + def exec_interactive(sandbox_id: str, *, tty: bool) -> tuple[bytes, bytes]: request = openshell_pb2.ExecSandboxInput( start=openshell_pb2.ExecSandboxRequest( sandbox_id=sandbox_id, @@ -76,7 +80,11 @@ def exec_interactive(sandbox_id: str, *, tty: bool) -> bytes: "-c", "[ -t 0 ] && printf T || printf N; " "[ -t 1 ] && printf T || printf N; " - "[ -t 2 ] && printf T || printf N; printf '\\n'", + "[ -t 2 ] && printf T || printf N; printf '\\n'; " + "IFS= read -r stdin_value; " + "printf 'stdin:%s\\n' \"$stdin_value\"; " + "printf 'stdout-sentinel\\n'; " + "printf 'stderr-sentinel\\n' >&2", ], tty=tty, timeout_seconds=20, @@ -87,29 +95,39 @@ def exec_interactive(sandbox_id: str, *, tty: bool) -> bytes: def requests(): yield request + yield openshell_pb2.ExecSandboxInput(stdin=stdin_sentinel + b"\n") done.wait(timeout=30) - output: list[bytes] = [] + stdout: list[bytes] = [] + stderr: 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)) + stdout.append(bytes(event.stdout.data)) elif payload == "stderr": - output.append(bytes(event.stderr.data)) + stderr.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) + return b"".join(stdout), b"".join(stderr) 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) + stdout, stderr = exec_interactive(sb.id, tty=False) + assert b"NNN" in stdout + assert b"stdin:" + stdin_sentinel in stdout + assert stdout_sentinel in stdout + assert stdout_sentinel not in stderr + assert stderr_sentinel in stderr + assert stderr_sentinel not in stdout + + stdout, stderr = exec_interactive(sb.id, tty=True) + assert b"TTT" in stdout + stderr def test_sandbox_labels_and_selectors(sandbox_client: SandboxClient) -> None: