diff --git a/CLI/cmux.swift b/CLI/cmux.swift index dba5fa0d..21e8e022 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1899,7 +1899,10 @@ struct CMUXCLI { } parts.append(options.destination) parts.append(contentsOf: options.extraArguments) - return parts.map(shellQuote).joined(separator: " ") + let sshCommand = parts.map(shellQuote).joined(separator: " ") + // Scope Ghostty SSH niceties to `cmux ssh ...` launches only. + let shellFeatures = "GHOSTTY_SHELL_FEATURES=${GHOSTTY_SHELL_FEATURES:+$GHOSTTY_SHELL_FEATURES,}ssh-env,ssh-terminfo" + return shellFeatures + " " + sshCommand } private func shellQuote(_ value: String) -> String { diff --git a/Resources/shell-integration/.zshenv b/Resources/shell-integration/.zshenv index 6f6a5b69..21570241 100644 --- a/Resources/shell-integration/.zshenv +++ b/Resources/shell-integration/.zshenv @@ -29,19 +29,6 @@ fi [[ ! -r "$_cmux_file" ]] || builtin source -- "$_cmux_file" } always { if [[ -o interactive ]]; then - # Opt into Ghostty's SSH shell niceties by default so plain `ssh ...` - # gets TERM compatibility + remote terminfo setup. - if [[ -n "${GHOSTTY_SHELL_FEATURES:-}" ]]; then - builtin typeset _cmux_features=",$GHOSTTY_SHELL_FEATURES," - if [[ "$_cmux_features" != *",ssh-env,"* ]]; then - builtin export GHOSTTY_SHELL_FEATURES="${GHOSTTY_SHELL_FEATURES},ssh-env" - _cmux_features=",$GHOSTTY_SHELL_FEATURES," - fi - if [[ "$_cmux_features" != *",ssh-terminfo,"* ]]; then - builtin export GHOSTTY_SHELL_FEATURES="${GHOSTTY_SHELL_FEATURES},ssh-terminfo" - fi - fi - # We overwrote GhosttyKit's injected ZDOTDIR, so manually load Ghostty's # zsh integration if available. if [[ -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then @@ -56,5 +43,5 @@ fi fi fi - builtin unset _cmux_file _cmux_features _cmux_ghostty _cmux_integ + builtin unset _cmux_file _cmux_ghostty _cmux_integ } diff --git a/tests_v2/test_ssh_remote_cli_metadata.py b/tests_v2/test_ssh_remote_cli_metadata.py index 231a3b02..a5c81c87 100644 --- a/tests_v2/test_ssh_remote_cli_metadata.py +++ b/tests_v2/test_ssh_remote_cli_metadata.py @@ -87,6 +87,13 @@ def main() -> int: workspace_id = str(row.get("id") or "") break _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + ssh_command = str(payload.get("ssh_command") or "") + _must(bool(ssh_command), f"cmux ssh output missing ssh_command: {payload}") + _must( + "GHOSTTY_SHELL_FEATURES=${GHOSTTY_SHELL_FEATURES:+$GHOSTTY_SHELL_FEATURES,}ssh-env,ssh-terminfo" in ssh_command, + f"cmux ssh should scope ssh niceties to this command: {ssh_command!r}", + ) + _must("ssh -o StrictHostKeyChecking=accept-new" in ssh_command, f"ssh command prefix mismatch: {ssh_command!r}") listed_row = None deadline = time.time() + 8.0 diff --git a/tests_v2/test_ssh_shell_integration_features.py b/tests_v2/test_ssh_shell_integration_features.py deleted file mode 100644 index 6bde143c..00000000 --- a/tests_v2/test_ssh_shell_integration_features.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 -"""Regression: cmux shell integration enables ssh niceties by default.""" - -import os -import shlex -import tempfile -import time -from pathlib import Path - -import sys -sys.path.insert(0, str(Path(__file__).parent)) -from cmux import cmux, cmuxError - - -SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") - - -def _must(cond: bool, msg: str) -> None: - if not cond: - raise cmuxError(msg) - - -def _wait_for_surface(client: cmux, workspace_id: str, timeout_s: float = 8.0) -> str: - deadline = time.time() + timeout_s - while time.time() < deadline: - surfaces = client.list_surfaces(workspace_id) - if surfaces: - return str(surfaces[0][1]) - time.sleep(0.1) - raise cmuxError(f"workspace {workspace_id} did not create a terminal surface in time") - - -def main() -> int: - output_path = Path(tempfile.gettempdir()) / f"cmux_ssh_shell_features_{os.getpid()}_{int(time.time() * 1000)}.txt" - workspace_id = "" - - with cmux(SOCKET_PATH) as client: - try: - workspace_id = client.new_workspace() - surface_id = _wait_for_surface(client, workspace_id) - - probe = f"echo \"$GHOSTTY_SHELL_FEATURES\" > {shlex.quote(str(output_path))}\n" - deadline = time.time() + 8.0 - last_send = 0.0 - while time.time() < deadline and not output_path.exists(): - now = time.time() - # Surface creation can race the first shell prompt; retry until one sticks. - if now - last_send >= 0.5: - client.send_surface(surface_id, probe) - last_send = now - time.sleep(0.05) - _must(output_path.exists(), "Timed out waiting for shell feature probe output") - - raw = output_path.read_text(encoding="utf-8", errors="replace").strip() - features = {token.strip() for token in raw.split(",") if token.strip()} - _must("ssh-env" in features, f"GHOSTTY_SHELL_FEATURES missing ssh-env: {raw!r}") - _must("ssh-terminfo" in features, f"GHOSTTY_SHELL_FEATURES missing ssh-terminfo: {raw!r}") - finally: - if workspace_id: - try: - client.close_workspace(workspace_id) - except Exception: - pass - try: - output_path.unlink() - except FileNotFoundError: - pass - - print("PASS: shell integration defaults include ssh-env and ssh-terminfo") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main())