From 31b29631b62ca1ce4a0c39d1fab513d261aa162f Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 22 Jun 2026 22:57:16 -0400 Subject: [PATCH] fix(macos-container): forward terminal capability env --- bot_bottle/backend/macos_container/bottle.py | 34 ++++++++++++++---- .../backend/macos_container/pty_forward.py | 18 +++++++--- tests/unit/test_macos_container_bottle.py | 36 +++++++++++++------ .../unit/test_macos_container_pty_forward.py | 33 ++++++++++++++--- 4 files changed, 95 insertions(+), 26 deletions(-) diff --git a/bot_bottle/backend/macos_container/bottle.py b/bot_bottle/backend/macos_container/bottle.py index 1d6c79c..1a6f8c7 100644 --- a/bot_bottle/backend/macos_container/bottle.py +++ b/bot_bottle/backend/macos_container/bottle.py @@ -14,6 +14,29 @@ from . import pty_forward as _pty_forward _PTY_FORWARD_SCRIPT = _pty_forward.__file__ +_TERMINAL_ENV_NAMES = ( + "TERM", + "COLORTERM", + "TERM_PROGRAM", + "TERM_PROGRAM_VERSION", + "KITTY_WINDOW_ID", + "KITTY_PID", + "WEZTERM_PANE", + "WEZTERM_UNIX_SOCKET", + "GHOSTTY_BIN_DIR", + "GHOSTTY_RESOURCES_DIR", + "ITERM_SESSION_ID", + "VTE_VERSION", + "KONSOLE_VERSION", + "ALACRITTY_WINDOW_ID", +) + + +def _terminal_env_names() -> tuple[str, ...]: + return tuple( + name for name in _TERMINAL_ENV_NAMES + if name == "TERM" or os.environ.get(name) + ) class MacosContainerBottle(Bottle): @@ -53,12 +76,11 @@ class MacosContainerBottle(Bottle): container_exec = ["container", "exec"] if tty: container_exec.extend(["--interactive", "--tty"]) - # Forward TERM so Claude Code can enable modifier-key protocols - # (e.g. modifyOtherKeys / kitty protocol). Without it the inner - # PTY session has no terminal-type context and Shift+Enter is - # indistinguishable from plain Enter. - term = os.environ.get("TERM", "xterm-256color") - container_exec.extend(["--env", f"TERM={term}"]) + # Forward terminal capability hints so TUIs can enable modified-key + # protocols. Use bare env names: values stay in the child env, not + # on argv, and pty_forward supplies a TERM fallback when needed. + for name in _terminal_env_names(): + container_exec.extend(["--env", name]) if self.agent_workdir and self.agent_workdir != "/home/node": container_exec.extend(["--workdir", self.agent_workdir]) container_exec.extend([self.name, self.agent_command, *full_argv]) diff --git a/bot_bottle/backend/macos_container/pty_forward.py b/bot_bottle/backend/macos_container/pty_forward.py index 63d96fc..193ca7f 100644 --- a/bot_bottle/backend/macos_container/pty_forward.py +++ b/bot_bottle/backend/macos_container/pty_forward.py @@ -27,6 +27,16 @@ import termios import tty +def _inner_env() -> dict[str, str]: + env = dict(os.environ) + env.setdefault("TERM", "xterm-256color") + return env + + +def _run_inner(inner: list[str]) -> int: + return subprocess.run(inner, check=False, env=_inner_env()).returncode + + def main(argv: list[str]) -> int: """Entry point. ``argv`` shape: ``-- ``.""" if len(argv) < 2 or argv[0] != "--": @@ -39,19 +49,19 @@ def main(argv: list[str]) -> int: try: fd = sys.stdin.fileno() except OSError: - return subprocess.run(inner, check=False).returncode + return _run_inner(inner) if not os.isatty(fd): - return subprocess.run(inner, check=False).returncode + return _run_inner(inner) try: old = termios.tcgetattr(fd) except termios.error: - return subprocess.run(inner, check=False).returncode + return _run_inner(inner) try: tty.setraw(fd) - return subprocess.run(inner, check=False).returncode + return _run_inner(inner) finally: termios.tcsetattr(fd, termios.TCSADRAIN, old) diff --git a/tests/unit/test_macos_container_bottle.py b/tests/unit/test_macos_container_bottle.py index e5ceb82..543cd74 100644 --- a/tests/unit/test_macos_container_bottle.py +++ b/tests/unit/test_macos_container_bottle.py @@ -18,13 +18,13 @@ class TestMacosContainerBottle(unittest.TestCase): None, agent_command="codex", ) - with patch.dict(bottle_mod.os.environ, {"TERM": "xterm-256color"}, clear=False): + with patch.dict(bottle_mod.os.environ, {}, clear=True): argv = bottle.agent_argv(["run"]) self.assertEqual( [ sys.executable, _PTY_FORWARD_SCRIPT, "--", "container", "exec", "--interactive", "--tty", - "--env", "TERM=xterm-256color", + "--env", "TERM", "bot-bottle-dev-abc", "codex", "run", ], argv, @@ -37,31 +37,45 @@ class TestMacosContainerBottle(unittest.TestCase): None, agent_workdir="/home/node/workspace", ) - with patch.dict(bottle_mod.os.environ, {"TERM": "xterm-256color"}, clear=False): + with patch.dict(bottle_mod.os.environ, {}, clear=True): argv = bottle.agent_argv([]) self.assertEqual( [ sys.executable, _PTY_FORWARD_SCRIPT, "--", "container", "exec", "--interactive", "--tty", - "--env", "TERM=xterm-256color", + "--env", "TERM", "--workdir", "/home/node/workspace", "bot-bottle-dev-abc", "claude", ], argv, ) - def test_agent_argv_uses_host_term_value(self): + def test_agent_argv_forwards_terminal_env_names_without_values(self): bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None) - with patch.dict(bottle_mod.os.environ, {"TERM": "screen-256color"}, clear=False): + with patch.dict( + bottle_mod.os.environ, + { + "TERM": "screen-256color", + "TERM_PROGRAM": "WezTerm", + "WEZTERM_PANE": "pane-id", + "SHELL": "/bin/zsh", + }, + clear=True, + ): argv = bottle.agent_argv([]) - self.assertIn("TERM=screen-256color", argv) + self.assertIn("TERM", argv) + self.assertIn("TERM_PROGRAM", argv) + self.assertIn("WEZTERM_PANE", argv) + self.assertNotIn("SHELL", argv) + self.assertNotIn("TERM=screen-256color", argv) + self.assertNotIn("TERM_PROGRAM=WezTerm", argv) + self.assertNotIn("WEZTERM_PANE=pane-id", argv) - def test_agent_argv_term_falls_back_to_xterm_256color(self): + def test_agent_argv_always_forwards_term_name(self): bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None) - env_without_term = {k: v for k, v in bottle_mod.os.environ.items() if k != "TERM"} - with patch.object(bottle_mod.os, "environ", env_without_term): + with patch.dict(bottle_mod.os.environ, {}, clear=True): argv = bottle.agent_argv([]) - self.assertIn("TERM=xterm-256color", argv) + self.assertIn("TERM", argv) def test_agent_argv_no_tty_omits_wrapper_and_tty_flags(self): bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None) diff --git a/tests/unit/test_macos_container_pty_forward.py b/tests/unit/test_macos_container_pty_forward.py index 0fc22ca..e4b9ca2 100644 --- a/tests/unit/test_macos_container_pty_forward.py +++ b/tests/unit/test_macos_container_pty_forward.py @@ -42,7 +42,9 @@ class TestArgvParsing(unittest.TestCase): run.return_value.returncode = 0 rc = pty_forward.main(["--", "container", "exec"]) self.assertEqual(0, rc) - run.assert_called_once_with(["container", "exec"], check=False) + run.assert_called_once() + self.assertEqual(["container", "exec"], run.call_args.args[0]) + self.assertFalse(run.call_args.kwargs["check"]) class TestNonTtyFallback(unittest.TestCase): @@ -57,10 +59,12 @@ class TestNonTtyFallback(unittest.TestCase): ["--", "container", "exec", "--interactive", "--tty", "c", "claude"] ) self.assertEqual(42, rc) - run.assert_called_once_with( + run.assert_called_once() + self.assertEqual( ["container", "exec", "--interactive", "--tty", "c", "claude"], - check=False, + run.call_args.args[0], ) + self.assertFalse(run.call_args.kwargs["check"]) def test_fileno_error_runs_inner_directly(self): bad_stdin = MagicMock() @@ -71,7 +75,9 @@ class TestNonTtyFallback(unittest.TestCase): ): run.return_value.returncode = 0 rc = pty_forward.main(["--", "container", "exec"]) - run.assert_called_once_with(["container", "exec"], check=False) + run.assert_called_once() + self.assertEqual(["container", "exec"], run.call_args.args[0]) + self.assertFalse(run.call_args.kwargs["check"]) self.assertEqual(0, rc) @@ -128,9 +134,26 @@ class TestRawModeSetupAndRestore(unittest.TestCase): rc = pty_forward.main(["--", "container", "exec"]) setraw.assert_not_called() - run.assert_called_once_with(["container", "exec"], check=False) + run.assert_called_once() + self.assertEqual(["container", "exec"], run.call_args.args[0]) + self.assertFalse(run.call_args.kwargs["check"]) self.assertEqual(0, rc) + def test_inner_run_sets_term_default_without_mutating_process_env(self): + with ( + patch.dict(pty_forward.os.environ, {}, clear=True), + patch.object(pty_forward.subprocess, "run") as run, + ): + run.return_value.returncode = 0 + rc = pty_forward._run_inner(["container", "exec"]) + + self.assertNotIn("TERM", pty_forward.os.environ) + + self.assertEqual(0, rc) + child_env = run.call_args.kwargs["env"] + self.assertEqual(["TERM"], sorted(child_env.keys())) + self.assertEqual("xterm-256color", child_env["TERM"]) + if __name__ == "__main__": unittest.main()