diff --git a/rock/actions/sandbox/request.py b/rock/actions/sandbox/request.py index ecfab29d2..b276e45af 100644 --- a/rock/actions/sandbox/request.py +++ b/rock/actions/sandbox/request.py @@ -25,6 +25,19 @@ class CreateBashSessionRequest(BaseModel): env: dict[str, str] | None = Field(default=None) remote_user: str | None = Field(default=None) + # Terminal settings + term: str | None = Field(default=None) + """Terminal type (TERM environment variable). If None, TERM is not set.""" + + columns: int = Field(default=80, ge=1) + """Terminal width in columns. Must be positive.""" + + lines: int = Field(default=24, ge=1) + """Terminal height in lines. Must be positive.""" + + lang: str | None = Field(default=None) + """Language and encoding (LANG environment variable). If None, LANG is not set.""" + CreateSessionRequest = Annotated[CreateBashSessionRequest, Field(discriminator="session_type")] """Union type for all create session requests. Do not use this directly.""" diff --git a/rock/rocklet/local_sandbox.py b/rock/rocklet/local_sandbox.py index 1bc1c1190..9aae6aae1 100644 --- a/rock/rocklet/local_sandbox.py +++ b/rock/rocklet/local_sandbox.py @@ -177,6 +177,13 @@ async def start(self) -> CreateBashSessionResponse: else: env = {} env.update({"PS1": self._ps1, "PS2": "", "PS0": ""}) + + # Set terminal environment variables only if specified + if self.request.term is not None: + env["TERM"] = self.request.term + if self.request.lang is not None: + env["LANG"] = self.request.lang + if self.request.env is not None: env.update(self.request.env) logger.info(f"env:{env}") @@ -190,6 +197,7 @@ async def start(self) -> CreateBashSessionResponse: echo=False, env=env, # type: ignore maxread=self.request.max_read_size, + dimensions=(self.request.lines, self.request.columns), ) time.sleep(0.3) cmds = [] diff --git a/tests/unit/rocklet/test_local_sandbox_runtime.py b/tests/unit/rocklet/test_local_sandbox_runtime.py index b2e7d73e8..b24888daa 100644 --- a/tests/unit/rocklet/test_local_sandbox_runtime.py +++ b/tests/unit/rocklet/test_local_sandbox_runtime.py @@ -79,3 +79,120 @@ async def test_prompt_command(local_runtime: LocalSandboxRuntime): with_prompt_command = await local_runtime.run_in_session(BashAction(command="echo hello", action_type="bash")) assert with_prompt_command.output.__contains__("ROCK") await local_runtime.close_session(CloseBashSessionRequest(session_type="bash")) + + +# ========== Terminal Settings Tests ========== + + +@pytest.mark.asyncio +async def test_default_terminal_settings(local_runtime: LocalSandboxRuntime): + """Test that default terminal settings do not explicitly set TERM/LANG (backward compatibility). + + Note: bash/pexpect may still set TERM=dumb by default, but we don't explicitly set it. + The key test is that we can explicitly override TERM/LANG when needed. + """ + import uuid + session_name = f"term_default_{uuid.uuid4().hex[:8]}" + await local_runtime.create_session( + CreateBashSessionRequest(session_type="bash", session=session_name, startup_timeout=5.0) + ) + + # Check TERM - pexpect/bash defaults to "dumb" when not explicitly set + obs = await local_runtime.run_in_session( + BashAction(command="echo $TERM", action_type="bash", session=session_name, timeout=10) + ) + # pexpect/bash defaults to "dumb" when TERM is not set + assert "dumb" in obs.output + + # Check LANG - should not be set by default (empty or system default) + obs = await local_runtime.run_in_session( + BashAction(command="echo $LANG", action_type="bash", session=session_name, timeout=10) + ) + # LANG may be empty or set to system default; we just verify we don't explicitly set it + + await local_runtime.close_session(CloseBashSessionRequest(session_type="bash", session=session_name)) + + +@pytest.mark.asyncio +async def test_custom_terminal_settings(local_runtime: LocalSandboxRuntime): + """Test that custom terminal settings are applied correctly.""" + import uuid + session_name = f"term_custom_{uuid.uuid4().hex[:8]}" + await local_runtime.create_session( + CreateBashSessionRequest( + session_type="bash", + session=session_name, + startup_timeout=5.0, + term="screen", + columns=120, + lines=40, + lang="zh_CN.UTF-8", + ) + ) + + # Check TERM + obs = await local_runtime.run_in_session( + BashAction(command="echo $TERM", action_type="bash", session=session_name, timeout=10) + ) + assert "screen" in obs.output + + # Check LANG + obs = await local_runtime.run_in_session( + BashAction(command="echo $LANG", action_type="bash", session=session_name, timeout=10) + ) + assert "zh_CN.UTF-8" in obs.output + + await local_runtime.close_session(CloseBashSessionRequest(session_type="bash", session=session_name)) + + +@pytest.mark.asyncio +async def test_terminal_size_stty(local_runtime: LocalSandboxRuntime): + """Test that terminal size is correctly set via stty size.""" + import uuid + session_name = f"term_stty_{uuid.uuid4().hex[:8]}" + await local_runtime.create_session( + CreateBashSessionRequest( + session_type="bash", + session=session_name, + startup_timeout=5.0, + columns=100, + lines=30, + ) + ) + + # stty size outputs "lines columns" + obs = await local_runtime.run_in_session( + BashAction(command="stty size", action_type="bash", session=session_name, timeout=10) + ) + assert "30 100" in obs.output + + await local_runtime.close_session(CloseBashSessionRequest(session_type="bash", session=session_name)) + + +@pytest.mark.asyncio +async def test_env_overrides_terminal_params(local_runtime: LocalSandboxRuntime): + """Test that env parameter takes priority over terminal params.""" + import uuid + session_name = f"term_override_{uuid.uuid4().hex[:8]}" + await local_runtime.create_session( + CreateBashSessionRequest( + session_type="bash", + session=session_name, + startup_timeout=5.0, + term="xterm", + env={"TERM": "vt100", "LANG": "C"}, + ) + ) + + # env should override term param + obs = await local_runtime.run_in_session( + BashAction(command="echo $TERM", action_type="bash", session=session_name, timeout=10) + ) + assert "vt100" in obs.output + + obs = await local_runtime.run_in_session( + BashAction(command="echo $LANG", action_type="bash", session=session_name, timeout=10) + ) + assert "C" in obs.output + + await local_runtime.close_session(CloseBashSessionRequest(session_type="bash", session=session_name))