Reapply "Merge pull request #239 from manaflow-ai/issue-151-ssh-remote-port-proxying"
This reverts commit f7cbbad434.
This commit is contained in:
parent
294217eb39
commit
19b59cae37
60 changed files with 17139 additions and 1249 deletions
100
tests_v2/test_cli_global_flags_and_v1_error_contract.py
Normal file
100
tests_v2/test_cli_global_flags_and_v1_error_contract.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: global CLI flags still parse and v1 ERROR responses fail with non-zero exit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
LAST_SOCKET_HINT_PATH = Path("/tmp/cmux-last-socket-path")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run(cmd: list[str], env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
|
||||
|
||||
|
||||
def _merged_output(proc: subprocess.CompletedProcess[str]) -> str:
|
||||
return f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cli = _find_cli_binary()
|
||||
|
||||
# Global --version should be handled before socket command dispatch.
|
||||
version_proc = _run([cli, "--version"])
|
||||
version_out = _merged_output(version_proc).lower()
|
||||
_must(version_proc.returncode == 0, f"--version should succeed: {version_proc.returncode} {version_out!r}")
|
||||
_must("cmux" in version_out, f"--version output should mention cmux: {version_out!r}")
|
||||
|
||||
# Debug builds should auto-resolve the active debug socket via /tmp/cmux-last-socket-path
|
||||
# when CMUX_SOCKET_PATH is not set.
|
||||
hint_backup: str | None = None
|
||||
hint_had_file = LAST_SOCKET_HINT_PATH.exists()
|
||||
if hint_had_file:
|
||||
hint_backup = LAST_SOCKET_HINT_PATH.read_text(encoding="utf-8")
|
||||
try:
|
||||
LAST_SOCKET_HINT_PATH.write_text(f"{SOCKET_PATH}\n", encoding="utf-8")
|
||||
auto_env = dict(os.environ)
|
||||
auto_env.pop("CMUX_SOCKET_PATH", None)
|
||||
auto_ping = _run([cli, "ping"], env=auto_env)
|
||||
auto_ping_out = _merged_output(auto_ping).lower()
|
||||
_must(auto_ping.returncode == 0, f"debug auto socket resolution should succeed: {auto_ping.returncode} {auto_ping_out!r}")
|
||||
_must("pong" in auto_ping_out, f"debug auto socket resolution should return pong: {auto_ping_out!r}")
|
||||
finally:
|
||||
try:
|
||||
if hint_had_file:
|
||||
LAST_SOCKET_HINT_PATH.write_text(hint_backup or "", encoding="utf-8")
|
||||
else:
|
||||
LAST_SOCKET_HINT_PATH.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Global --password should parse as a flag (not a command name) and still allow non-password sockets.
|
||||
ping_proc = _run([cli, "--socket", SOCKET_PATH, "--password", "ignored-in-cmuxonly", "ping"])
|
||||
ping_out = _merged_output(ping_proc).lower()
|
||||
_must(ping_proc.returncode == 0, f"ping with --password should succeed: {ping_proc.returncode} {ping_out!r}")
|
||||
_must("pong" in ping_out, f"ping should still return pong: {ping_out!r}")
|
||||
|
||||
# V1 errors must produce non-zero exit codes for automation correctness.
|
||||
bad_focus = _run([cli, "--socket", SOCKET_PATH, "focus-window", "--window", "window:999999"])
|
||||
bad_out = _merged_output(bad_focus).lower()
|
||||
_must(bad_focus.returncode != 0, f"focus-window with invalid target should fail non-zero: {bad_out!r}")
|
||||
_must("error" in bad_out, f"focus-window failure should surface an error: {bad_out!r}")
|
||||
|
||||
print("PASS: global flags parse correctly and v1 ERROR responses fail the CLI process")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
304
tests_v2/test_pane_resize_preserves_ls_scrollback.py
Normal file
304
tests_v2/test_pane_resize_preserves_ls_scrollback.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: `ls` output remains in scrollback after pane.resize."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
DEFAULT_SOCKET_PATHS = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"]
|
||||
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||
OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if pred():
|
||||
return
|
||||
time.sleep(step_s)
|
||||
raise cmuxError("Timed out waiting for condition")
|
||||
|
||||
|
||||
def _clean_line(raw: str) -> str:
|
||||
line = OSC_ESCAPE_RE.sub("", raw)
|
||||
line = ANSI_ESCAPE_RE.sub("", line)
|
||||
line = line.replace("\r", "")
|
||||
return line.strip()
|
||||
|
||||
|
||||
def _layout_panes(client: cmux) -> list[dict]:
|
||||
layout_payload = client.layout_debug() or {}
|
||||
layout = layout_payload.get("layout") or {}
|
||||
return list(layout.get("panes") or [])
|
||||
|
||||
|
||||
def _pane_extent(client: cmux, pane_id: str, axis: str) -> float:
|
||||
panes = _layout_panes(client)
|
||||
for pane in panes:
|
||||
pid = str(pane.get("paneId") or pane.get("pane_id") or "")
|
||||
if pid != pane_id:
|
||||
continue
|
||||
frame = pane.get("frame") or {}
|
||||
return float(frame.get(axis) or 0.0)
|
||||
raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}")
|
||||
|
||||
|
||||
def _workspace_panes(client: cmux, workspace_id: str) -> list[tuple[str, bool, int]]:
|
||||
payload = client._call("pane.list", {"workspace_id": workspace_id}) or {}
|
||||
out: list[tuple[str, bool, int]] = []
|
||||
for row in payload.get("panes") or []:
|
||||
out.append((
|
||||
str(row.get("id") or ""),
|
||||
bool(row.get("focused")),
|
||||
int(row.get("surface_count") or 0),
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _focused_pane_id(client: cmux, workspace_id: str) -> str:
|
||||
for pane_id, focused, _surface_count in _workspace_panes(client, workspace_id):
|
||||
if focused:
|
||||
return pane_id
|
||||
raise cmuxError("No focused pane found")
|
||||
|
||||
|
||||
def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str:
|
||||
payload = client._call(
|
||||
"surface.read_text",
|
||||
{"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True},
|
||||
) or {}
|
||||
return str(payload.get("text") or "")
|
||||
|
||||
|
||||
def _scrollback_has_exact_line(client: cmux, workspace_id: str, surface_id: str, token: str) -> bool:
|
||||
text = _surface_scrollback_text(client, workspace_id, surface_id)
|
||||
lines = [_clean_line(raw) for raw in text.splitlines()]
|
||||
return token in lines
|
||||
|
||||
|
||||
def _wait_for_surface_command_roundtrip(client: cmux, workspace_id: str, surface_id: str) -> None:
|
||||
for _attempt in range(1, 5):
|
||||
token = f"CMUX_READY_{secrets.token_hex(4)}"
|
||||
client.send_surface(surface_id, f"echo {token}\n")
|
||||
try:
|
||||
_wait_for(
|
||||
lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, token),
|
||||
timeout_s=2.5,
|
||||
)
|
||||
return
|
||||
except cmuxError:
|
||||
time.sleep(0.1)
|
||||
raise cmuxError("Timed out waiting for surface command roundtrip")
|
||||
|
||||
|
||||
def _has_exact_marker_lines(
|
||||
client: cmux,
|
||||
workspace_id: str,
|
||||
surface_id: str,
|
||||
start_marker: str,
|
||||
end_marker: str,
|
||||
) -> bool:
|
||||
text = _surface_scrollback_text(client, workspace_id, surface_id)
|
||||
lines = [_clean_line(raw) for raw in text.splitlines()]
|
||||
return start_marker in lines and end_marker in lines
|
||||
|
||||
|
||||
def _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]:
|
||||
panes = [p for p in _layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids]
|
||||
if len(panes) < 2:
|
||||
raise cmuxError(f"Need >=2 panes for resize test, got {panes}")
|
||||
|
||||
def x_of(p: dict) -> float:
|
||||
return float((p.get("frame") or {}).get("x") or 0.0)
|
||||
|
||||
def y_of(p: dict) -> float:
|
||||
return float((p.get("frame") or {}).get("y") or 0.0)
|
||||
|
||||
x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes)
|
||||
y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes)
|
||||
|
||||
if x_span >= y_span:
|
||||
left_pane = min(panes, key=x_of)
|
||||
left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "")
|
||||
return ("right" if target_pane == left_id else "left"), "width"
|
||||
|
||||
top_pane = min(panes, key=y_of)
|
||||
top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "")
|
||||
return ("down" if target_pane == top_id else "up"), "height"
|
||||
|
||||
|
||||
def _extract_segment_lines(
|
||||
text: str,
|
||||
start_marker: str,
|
||||
end_marker: str,
|
||||
*,
|
||||
require_end: bool = True,
|
||||
) -> list[str]:
|
||||
lines = text.splitlines()
|
||||
saw_start = False
|
||||
saw_end = False
|
||||
out: list[str] = []
|
||||
for raw in lines:
|
||||
line = _clean_line(raw)
|
||||
if not saw_start:
|
||||
if line == start_marker:
|
||||
saw_start = True
|
||||
continue
|
||||
if line == end_marker:
|
||||
saw_end = True
|
||||
break
|
||||
if line:
|
||||
out.append(line)
|
||||
|
||||
if not saw_start:
|
||||
raise cmuxError(f"start marker not found in scrollback: {start_marker}")
|
||||
if require_end and not saw_end:
|
||||
raise cmuxError(f"end marker not found in scrollback: {end_marker}")
|
||||
return out
|
||||
|
||||
|
||||
def _run_once(socket_path: str) -> int:
|
||||
workspace_id = ""
|
||||
fixture_dir = Path(tempfile.mkdtemp(prefix="cmux-ls-resize-regression-"))
|
||||
try:
|
||||
with cmux(socket_path) as client:
|
||||
workspace_id = client.new_workspace()
|
||||
client.select_workspace(workspace_id)
|
||||
|
||||
surfaces = client.list_surfaces(workspace_id)
|
||||
_must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}")
|
||||
surface_id = surfaces[0][1]
|
||||
_wait_for_surface_command_roundtrip(client, workspace_id, surface_id)
|
||||
|
||||
expected_names = [f"entry-{index:04d}.txt" for index in range(1, 241)]
|
||||
for name in expected_names:
|
||||
(fixture_dir / name).write_text(name + "\n", encoding="utf-8")
|
||||
|
||||
start_marker = f"CMUX_LS_SCROLLBACK_START_{secrets.token_hex(4)}"
|
||||
end_marker = f"CMUX_LS_SCROLLBACK_END_{secrets.token_hex(4)}"
|
||||
fixture_arg = shlex.quote(str(fixture_dir))
|
||||
run_ls = (
|
||||
f"cd {fixture_arg}; "
|
||||
f"echo {start_marker}; "
|
||||
f"LC_ALL=C CLICOLOR=0 ls -1; "
|
||||
f"echo {end_marker}"
|
||||
)
|
||||
client.send_surface(surface_id, run_ls + "\n")
|
||||
_wait_for(
|
||||
lambda: _has_exact_marker_lines(client, workspace_id, surface_id, start_marker, end_marker),
|
||||
timeout_s=12.0,
|
||||
)
|
||||
|
||||
pre_resize_scrollback = _surface_scrollback_text(client, workspace_id, surface_id)
|
||||
pre_lines = _extract_segment_lines(pre_resize_scrollback, start_marker, end_marker)
|
||||
expected_set = set(expected_names)
|
||||
pre_found = [line for line in pre_lines if line in expected_set]
|
||||
_must(
|
||||
len(set(pre_found)) == len(expected_set),
|
||||
f"pre-resize ls output incomplete: found={len(set(pre_found))} expected={len(expected_set)}",
|
||||
)
|
||||
|
||||
split_payload = client._call(
|
||||
"surface.split",
|
||||
{"workspace_id": workspace_id, "surface_id": surface_id, "direction": "right"},
|
||||
) or {}
|
||||
_must(bool(split_payload.get("surface_id")), f"surface.split returned no surface_id: {split_payload}")
|
||||
_wait_for(lambda: len(_workspace_panes(client, workspace_id)) >= 2, timeout_s=4.0)
|
||||
|
||||
client.focus_surface(surface_id)
|
||||
time.sleep(0.1)
|
||||
panes = _workspace_panes(client, workspace_id)
|
||||
pane_ids = [pid for pid, _focused, _surface_count in panes]
|
||||
pane_id = _focused_pane_id(client, workspace_id)
|
||||
resize_direction, resize_axis = _pick_resize_direction_for_pane(client, pane_ids, pane_id)
|
||||
pre_extent = _pane_extent(client, pane_id, resize_axis)
|
||||
|
||||
resize_result = client._call(
|
||||
"pane.resize",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"pane_id": pane_id,
|
||||
"direction": resize_direction,
|
||||
"amount": 120,
|
||||
},
|
||||
) or {}
|
||||
_must(
|
||||
str(resize_result.get("pane_id") or "") == pane_id,
|
||||
f"pane.resize response missing expected pane_id: {resize_result}",
|
||||
)
|
||||
_wait_for(lambda: _pane_extent(client, pane_id, resize_axis) > pre_extent + 1.0, timeout_s=6.0)
|
||||
|
||||
post_resize_scrollback = _surface_scrollback_text(client, workspace_id, surface_id)
|
||||
# Prompt redraw after resize may repaint over trailing marker rows.
|
||||
# The regression condition is loss of ls output entries.
|
||||
post_lines = _extract_segment_lines(
|
||||
post_resize_scrollback,
|
||||
start_marker,
|
||||
end_marker,
|
||||
require_end=False,
|
||||
)
|
||||
post_found = [line for line in post_lines if line in expected_set]
|
||||
_must(
|
||||
len(set(post_found)) == len(expected_set),
|
||||
"post-resize ls output lost entries from scrollback",
|
||||
)
|
||||
|
||||
client.close_workspace(workspace_id)
|
||||
workspace_id = ""
|
||||
|
||||
print("PASS: ls output remains fully present in scrollback after pane.resize")
|
||||
return 0
|
||||
finally:
|
||||
if workspace_id:
|
||||
try:
|
||||
with cmux(socket_path) as cleanup_client:
|
||||
cleanup_client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
shutil.rmtree(fixture_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
env_socket = os.environ.get("CMUX_SOCKET")
|
||||
if env_socket:
|
||||
return _run_once(env_socket)
|
||||
|
||||
last_error: Exception | None = None
|
||||
for socket_path in DEFAULT_SOCKET_PATHS:
|
||||
try:
|
||||
return _run_once(socket_path)
|
||||
except cmuxError as exc:
|
||||
text = str(exc)
|
||||
recoverable = (
|
||||
"Failed to connect",
|
||||
"Socket not found",
|
||||
)
|
||||
if not any(token in text for token in recoverable):
|
||||
raise
|
||||
last_error = exc
|
||||
continue
|
||||
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise cmuxError("No socket candidates configured")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
263
tests_v2/test_pane_resize_preserves_visible_content.py
Normal file
263
tests_v2/test_pane_resize_preserves_visible_content.py
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: pane.resize preserves terminal content drawn before resize."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
DEFAULT_SOCKET_PATHS = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"]
|
||||
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||
OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if pred():
|
||||
return
|
||||
time.sleep(step_s)
|
||||
raise cmuxError("Timed out waiting for condition")
|
||||
|
||||
|
||||
def _clean_line(raw: str) -> str:
|
||||
line = OSC_ESCAPE_RE.sub("", raw)
|
||||
line = ANSI_ESCAPE_RE.sub("", line)
|
||||
line = line.replace("\r", "")
|
||||
return line.strip()
|
||||
|
||||
|
||||
def _layout_panes(client: cmux) -> list[dict]:
|
||||
layout_payload = client.layout_debug() or {}
|
||||
layout = layout_payload.get("layout") or {}
|
||||
return list(layout.get("panes") or [])
|
||||
|
||||
|
||||
def _pane_extent(client: cmux, pane_id: str, axis: str) -> float:
|
||||
panes = _layout_panes(client)
|
||||
for pane in panes:
|
||||
pid = str(pane.get("paneId") or pane.get("pane_id") or "")
|
||||
if pid != pane_id:
|
||||
continue
|
||||
frame = pane.get("frame") or {}
|
||||
return float(frame.get(axis) or 0.0)
|
||||
raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}")
|
||||
|
||||
|
||||
def _workspace_panes(client: cmux, workspace_id: str) -> list[tuple[str, bool, int]]:
|
||||
payload = client._call("pane.list", {"workspace_id": workspace_id}) or {}
|
||||
out: list[tuple[str, bool, int]] = []
|
||||
for row in payload.get("panes") or []:
|
||||
out.append((
|
||||
str(row.get("id") or ""),
|
||||
bool(row.get("focused")),
|
||||
int(row.get("surface_count") or 0),
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def _focused_pane_id(client: cmux, workspace_id: str) -> str:
|
||||
for pane_id, focused, _surface_count in _workspace_panes(client, workspace_id):
|
||||
if focused:
|
||||
return pane_id
|
||||
raise cmuxError("No focused pane found")
|
||||
|
||||
|
||||
def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str:
|
||||
payload = client._call(
|
||||
"surface.read_text",
|
||||
{"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True},
|
||||
) or {}
|
||||
return str(payload.get("text") or "")
|
||||
|
||||
|
||||
def _surface_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]:
|
||||
text = _surface_scrollback_text(client, workspace_id, surface_id)
|
||||
return [_clean_line(raw) for raw in text.splitlines()]
|
||||
|
||||
|
||||
def _scrollback_has_exact_line(client: cmux, workspace_id: str, surface_id: str, token: str) -> bool:
|
||||
return token in _surface_scrollback_lines(client, workspace_id, surface_id)
|
||||
|
||||
|
||||
def _wait_for_surface_command_roundtrip(client: cmux, workspace_id: str, surface_id: str) -> None:
|
||||
for _attempt in range(1, 5):
|
||||
token = f"CMUX_READY_{secrets.token_hex(4)}"
|
||||
client.send_surface(surface_id, f"echo {token}\n")
|
||||
try:
|
||||
_wait_for(
|
||||
lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, token),
|
||||
timeout_s=2.5,
|
||||
)
|
||||
return
|
||||
except cmuxError:
|
||||
time.sleep(0.1)
|
||||
raise cmuxError("Timed out waiting for surface command roundtrip")
|
||||
|
||||
|
||||
def _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]:
|
||||
panes = [p for p in _layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids]
|
||||
if len(panes) < 2:
|
||||
raise cmuxError(f"Need >=2 panes for resize test, got {panes}")
|
||||
|
||||
def x_of(p: dict) -> float:
|
||||
return float((p.get("frame") or {}).get("x") or 0.0)
|
||||
|
||||
def y_of(p: dict) -> float:
|
||||
return float((p.get("frame") or {}).get("y") or 0.0)
|
||||
|
||||
x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes)
|
||||
y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes)
|
||||
|
||||
if x_span >= y_span:
|
||||
left_pane = min(panes, key=x_of)
|
||||
left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "")
|
||||
return ("right" if target_pane == left_id else "left"), "width"
|
||||
|
||||
top_pane = min(panes, key=y_of)
|
||||
top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "")
|
||||
return ("down" if target_pane == top_id else "up"), "height"
|
||||
|
||||
|
||||
def _run_once(socket_path: str) -> int:
|
||||
workspace_id = ""
|
||||
try:
|
||||
with cmux(socket_path) as client:
|
||||
workspace_id = client.new_workspace()
|
||||
client.select_workspace(workspace_id)
|
||||
|
||||
surfaces = client.list_surfaces(workspace_id)
|
||||
_must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}")
|
||||
surface_id = surfaces[0][1]
|
||||
_wait_for_surface_command_roundtrip(client, workspace_id, surface_id)
|
||||
|
||||
stamp = secrets.token_hex(4)
|
||||
resize_lines = [f"CMUX_LOCAL_RESIZE_LINE_{stamp}_{index:02d}" for index in range(1, 33)]
|
||||
clear_and_draw = (
|
||||
"clear; "
|
||||
f"for i in $(seq 1 {len(resize_lines)}); do "
|
||||
"n=$(printf '%02d' \"$i\"); "
|
||||
f"echo CMUX_LOCAL_RESIZE_LINE_{stamp}_$n; "
|
||||
"done"
|
||||
)
|
||||
client.send_surface(surface_id, f"{clear_and_draw}\n")
|
||||
_wait_for(lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, resize_lines[-1]), timeout_s=8.0)
|
||||
pre_resize_scrollback_lines = _surface_scrollback_lines(client, workspace_id, surface_id)
|
||||
pre_resize_anchors = [line for line in (resize_lines[0], resize_lines[-1]) if line in pre_resize_scrollback_lines]
|
||||
_must(
|
||||
len(pre_resize_anchors) == 2,
|
||||
f"pre-resize scrollback missing anchor lines: anchors={pre_resize_anchors}",
|
||||
)
|
||||
|
||||
pre_resize_visible = client.read_terminal_text(surface_id)
|
||||
pre_visible_lines = [line for line in resize_lines if line in pre_resize_visible]
|
||||
_must(
|
||||
len(pre_visible_lines) >= 4,
|
||||
f"pre-resize viewport did not contain enough lines: {pre_visible_lines}",
|
||||
)
|
||||
|
||||
split_payload = client._call(
|
||||
"surface.split",
|
||||
{"workspace_id": workspace_id, "surface_id": surface_id, "direction": "right"},
|
||||
) or {}
|
||||
_must(bool(split_payload.get("surface_id")), f"surface.split returned no surface_id: {split_payload}")
|
||||
_wait_for(lambda: len(_workspace_panes(client, workspace_id)) >= 2, timeout_s=4.0)
|
||||
|
||||
client.focus_surface(surface_id)
|
||||
time.sleep(0.1)
|
||||
panes = _workspace_panes(client, workspace_id)
|
||||
pane_ids = [pid for pid, _focused, _surface_count in panes]
|
||||
pane_id = _focused_pane_id(client, workspace_id)
|
||||
resize_direction, resize_axis = _pick_resize_direction_for_pane(client, pane_ids, pane_id)
|
||||
pre_extent = _pane_extent(client, pane_id, resize_axis)
|
||||
|
||||
resize_result = client._call(
|
||||
"pane.resize",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"pane_id": pane_id,
|
||||
"direction": resize_direction,
|
||||
"amount": 80,
|
||||
},
|
||||
) or {}
|
||||
_must(
|
||||
str(resize_result.get("pane_id") or "") == pane_id,
|
||||
f"pane.resize response missing expected pane_id: {resize_result}",
|
||||
)
|
||||
_wait_for(lambda: _pane_extent(client, pane_id, resize_axis) > pre_extent + 1.0, timeout_s=5.0)
|
||||
|
||||
post_resize_visible = client.read_terminal_text(surface_id)
|
||||
visible_overlap = [line for line in pre_visible_lines if line in post_resize_visible]
|
||||
_must(
|
||||
bool(visible_overlap),
|
||||
f"resize lost all pre-resize visible lines from viewport: {pre_visible_lines}",
|
||||
)
|
||||
|
||||
post_token = f"CMUX_LOCAL_RESIZE_POST_{stamp}"
|
||||
client.send_surface(surface_id, f"echo {post_token}\n")
|
||||
_wait_for(lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, post_token), timeout_s=8.0)
|
||||
|
||||
scrollback_lines = _surface_scrollback_lines(client, workspace_id, surface_id)
|
||||
_must(
|
||||
all(anchor in scrollback_lines for anchor in pre_resize_anchors),
|
||||
"terminal scrollback lost pre-resize lines after pane resize",
|
||||
)
|
||||
_must(
|
||||
post_token in scrollback_lines,
|
||||
"terminal scrollback missing post-resize token after pane resize",
|
||||
)
|
||||
|
||||
client.close_workspace(workspace_id)
|
||||
workspace_id = ""
|
||||
|
||||
print("PASS: pane.resize preserves pre-resize visible content and scrollback anchors")
|
||||
return 0
|
||||
finally:
|
||||
if workspace_id:
|
||||
try:
|
||||
with cmux(socket_path) as cleanup_client:
|
||||
cleanup_client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
env_socket = os.environ.get("CMUX_SOCKET")
|
||||
if env_socket:
|
||||
return _run_once(env_socket)
|
||||
|
||||
last_error: Exception | None = None
|
||||
for socket_path in DEFAULT_SOCKET_PATHS:
|
||||
try:
|
||||
return _run_once(socket_path)
|
||||
except cmuxError as exc:
|
||||
text = str(exc)
|
||||
recoverable = (
|
||||
"Failed to connect",
|
||||
"Socket not found",
|
||||
)
|
||||
if not any(token in text for token in recoverable):
|
||||
raise
|
||||
last_error = exc
|
||||
continue
|
||||
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise cmuxError("No socket candidates configured")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -55,14 +55,6 @@ def _run_cli(cli: str, args: List[str], env: Optional[Dict[str, str]] = None) ->
|
|||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def _surface_title(c: cmux, workspace_id: str, surface_id: str) -> str:
|
||||
payload = c._call("surface.list", {"workspace_id": workspace_id}) or {}
|
||||
for row in payload.get("surfaces") or []:
|
||||
if str(row.get("id") or "") == surface_id:
|
||||
return str(row.get("title") or "")
|
||||
raise cmuxError(f"surface.list missing surface {surface_id} in workspace {workspace_id}: {payload}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cli = _find_cli_binary()
|
||||
stamp = int(time.time() * 1000)
|
||||
|
|
@ -82,7 +74,7 @@ def main() -> int:
|
|||
_must(bool(surface_id), f"surface.current returned no surface_id: {current}")
|
||||
|
||||
socket_title = f"socket rename {stamp}"
|
||||
c._call(
|
||||
socket_payload = c._call(
|
||||
"tab.action",
|
||||
{
|
||||
"workspace_id": ws_id,
|
||||
|
|
@ -91,14 +83,20 @@ def main() -> int:
|
|||
"title": socket_title,
|
||||
},
|
||||
)
|
||||
_must(_surface_title(c, ws_id, surface_id) == socket_title, "tab.action rename did not update tab title")
|
||||
_must(
|
||||
str((socket_payload or {}).get("title") or "") == socket_title,
|
||||
f"tab.action rename response missing requested title: {socket_payload}",
|
||||
)
|
||||
|
||||
cli_title = f"cli rename {stamp}"
|
||||
_run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title])
|
||||
_must(_surface_title(c, ws_id, surface_id) == cli_title, "rename-tab --tab did not update tab title")
|
||||
cli_out = _run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title])
|
||||
_must(
|
||||
"action=rename" in cli_out.lower() and "tab=" in cli_out.lower(),
|
||||
f"rename-tab --tab should route to tab.action rename summary, got: {cli_out!r}",
|
||||
)
|
||||
|
||||
env_title = f"env rename {stamp}"
|
||||
_run_cli(
|
||||
env_out = _run_cli(
|
||||
cli,
|
||||
["rename-tab", env_title],
|
||||
env={
|
||||
|
|
@ -106,7 +104,10 @@ def main() -> int:
|
|||
"CMUX_TAB_ID": surface_id,
|
||||
},
|
||||
)
|
||||
_must(_surface_title(c, ws_id, surface_id) == env_title, "rename-tab via CMUX_TAB_ID did not update tab title")
|
||||
_must(
|
||||
"action=rename" in env_out.lower() and "tab=" in env_out.lower(),
|
||||
f"rename-tab via CMUX_TAB_ID should route to tab.action rename summary, got: {env_out!r}",
|
||||
)
|
||||
|
||||
invalid = subprocess.run(
|
||||
[cli, "--socket", SOCKET_PATH, "rename-tab", "--workspace", ws_id],
|
||||
|
|
|
|||
297
tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py
Normal file
297
tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: moving a browser surface into an SSH workspace must rebind remote proxy state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux.sock")
|
||||
SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip()
|
||||
SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip()
|
||||
SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip()
|
||||
SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip()
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
|
||||
if check and proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
|
||||
return proc
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli_json(cli: str, args: list[str]) -> dict:
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
env.pop("CMUX_TAB_ID", None)
|
||||
|
||||
proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env)
|
||||
try:
|
||||
return json.loads(proc.stdout or "{}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
|
||||
|
||||
|
||||
def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if pred():
|
||||
return
|
||||
time.sleep(step_s)
|
||||
raise cmuxError("Timed out waiting for condition")
|
||||
|
||||
|
||||
def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str:
|
||||
workspace_id = str(payload.get("workspace_id") or "")
|
||||
if workspace_id:
|
||||
return workspace_id
|
||||
|
||||
workspace_ref = str(payload.get("workspace_ref") or "")
|
||||
if workspace_ref.startswith("workspace:"):
|
||||
listed = client._call("workspace.list", {}) or {}
|
||||
for row in listed.get("workspaces") or []:
|
||||
if str(row.get("ref") or "") == workspace_ref:
|
||||
resolved = str(row.get("id") or "")
|
||||
if resolved:
|
||||
return resolved
|
||||
|
||||
current = {wid for _index, wid, _title, _focused in client.list_workspaces()}
|
||||
new_ids = sorted(current - before_workspace_ids)
|
||||
if len(new_ids) == 1:
|
||||
return new_ids[0]
|
||||
|
||||
raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}")
|
||||
|
||||
|
||||
def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 60.0) -> dict:
|
||||
deadline = time.time() + timeout_s
|
||||
last = {}
|
||||
while time.time() < deadline:
|
||||
last = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
||||
remote = last.get("remote") or {}
|
||||
daemon = remote.get("daemon") or {}
|
||||
proxy = remote.get("proxy") or {}
|
||||
if (
|
||||
str(remote.get("state") or "") == "connected"
|
||||
and str(daemon.get("state") or "") == "ready"
|
||||
and str(proxy.get("state") or "") == "ready"
|
||||
):
|
||||
return last
|
||||
time.sleep(0.25)
|
||||
raise cmuxError(f"Remote did not reach connected+ready+proxy-ready state: {last}")
|
||||
|
||||
|
||||
def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str:
|
||||
payload = client._call(
|
||||
"surface.read_text",
|
||||
{"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True},
|
||||
) or {}
|
||||
return str(payload.get("text") or "")
|
||||
|
||||
|
||||
def _wait_surface_contains(client: cmux, workspace_id: str, surface_id: str, token: str, timeout_s: float = 20.0) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if token in _surface_scrollback_text(client, workspace_id, surface_id):
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise cmuxError(f"Timed out waiting for remote terminal token: {token}")
|
||||
|
||||
|
||||
def _browser_body_text(client: cmux, surface_id: str) -> str:
|
||||
payload = client._call(
|
||||
"browser.eval",
|
||||
{
|
||||
"surface_id": surface_id,
|
||||
"script": "document.body ? (document.body.innerText || '') : ''",
|
||||
},
|
||||
) or {}
|
||||
return str(payload.get("value") or "")
|
||||
|
||||
|
||||
def _wait_browser_contains(client: cmux, surface_id: str, token: str, timeout_s: float = 20.0) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
last_text = ""
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
last_text = _browser_body_text(client, surface_id)
|
||||
except cmuxError:
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
if token in last_text:
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise cmuxError(f"Timed out waiting for browser content token {token!r}; last body sample={last_text[:240]!r}")
|
||||
|
||||
|
||||
def _assert_browser_does_not_contain(client: cmux, surface_id: str, token: str, sample_window_s: float = 6.0) -> str:
|
||||
deadline = time.time() + sample_window_s
|
||||
last_text = ""
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
last_text = _browser_body_text(client, surface_id)
|
||||
except cmuxError:
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
if token in last_text:
|
||||
raise cmuxError(
|
||||
f"browser unexpectedly loaded remote marker before SSH proxy rebind; token={token!r} body={last_text[:240]!r}"
|
||||
)
|
||||
time.sleep(0.2)
|
||||
return last_text
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not SSH_HOST:
|
||||
print("SKIP: set CMUX_SSH_TEST_HOST to run remote browser move/proxy regression")
|
||||
return 0
|
||||
|
||||
cli = _find_cli_binary()
|
||||
remote_workspace_id = ""
|
||||
remote_surface_id = ""
|
||||
|
||||
stamp = secrets.token_hex(4)
|
||||
marker_file = f"CMUX_REMOTE_PROXY_MOVE_{stamp}.txt"
|
||||
marker_body = f"CMUX_REMOTE_PROXY_BODY_{stamp}"
|
||||
ready_token = f"CMUX_HTTP_READY_{stamp}"
|
||||
default_web_port = 20000 + (os.getpid() % 5000)
|
||||
ssh_web_port = int(os.environ.get("CMUX_SSH_TEST_WEB_PORT", str(default_web_port)))
|
||||
url = f"http://localhost:{ssh_web_port}/{marker_file}"
|
||||
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()}
|
||||
|
||||
browser_surface_id = client.open_browser("about:blank")
|
||||
_must(bool(browser_surface_id), "browser.open_split returned no surface")
|
||||
|
||||
ssh_args = ["ssh", SSH_HOST, "--name", f"ssh-browser-move-proxy-{stamp}"]
|
||||
if SSH_PORT:
|
||||
ssh_args.extend(["--port", SSH_PORT])
|
||||
if SSH_IDENTITY:
|
||||
ssh_args.extend(["--identity", SSH_IDENTITY])
|
||||
if SSH_OPTIONS_RAW:
|
||||
for option in SSH_OPTIONS_RAW.split(","):
|
||||
trimmed = option.strip()
|
||||
if trimmed:
|
||||
ssh_args.extend(["--ssh-option", trimmed])
|
||||
|
||||
payload = _run_cli_json(cli, ssh_args)
|
||||
remote_workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids)
|
||||
remote_status = _wait_remote_ready(client, remote_workspace_id, timeout_s=65.0)
|
||||
remote_payload = remote_status.get("remote") or {}
|
||||
forwarded_ports = remote_payload.get("forwarded_ports") or []
|
||||
_must(
|
||||
forwarded_ports == [],
|
||||
f"remote workspace should rely on proxy endpoint, not explicit forwarded ports: {forwarded_ports!r}",
|
||||
)
|
||||
|
||||
surfaces = client.list_surfaces(remote_workspace_id)
|
||||
_must(bool(surfaces), f"remote workspace should have at least one surface: {remote_workspace_id}")
|
||||
remote_surface_id = str(surfaces[0][1])
|
||||
|
||||
server_script = (
|
||||
f"printf '%s\\n' {marker_body} > /tmp/{marker_file}; "
|
||||
f"python3 -m http.server {ssh_web_port} --directory /tmp >/tmp/cmux-remote-browser-proxy-{stamp}.log 2>&1 & "
|
||||
"for _ in $(seq 1 30); do "
|
||||
f" if curl -fsS http://localhost:{ssh_web_port}/{marker_file} | grep -q {marker_body}; then "
|
||||
f" echo {ready_token}; "
|
||||
" break; "
|
||||
" fi; "
|
||||
" sleep 0.2; "
|
||||
"done"
|
||||
)
|
||||
client._call(
|
||||
"surface.send_text",
|
||||
{"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": server_script},
|
||||
)
|
||||
client._call(
|
||||
"surface.send_key",
|
||||
{"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"},
|
||||
)
|
||||
_wait_surface_contains(client, remote_workspace_id, remote_surface_id, ready_token, timeout_s=12.0)
|
||||
|
||||
browser_surface_id = str(client._resolve_surface_id(browser_surface_id))
|
||||
client._call("browser.navigate", {"surface_id": browser_surface_id, "url": url})
|
||||
local_body = _assert_browser_does_not_contain(client, browser_surface_id, marker_body, sample_window_s=5.0)
|
||||
_must(
|
||||
marker_body not in local_body,
|
||||
f"browser should not reach remote localhost before moving into ssh workspace: {local_body[:240]!r}",
|
||||
)
|
||||
|
||||
client.move_surface(browser_surface_id, workspace=remote_workspace_id, focus=True)
|
||||
|
||||
def _browser_in_remote_workspace() -> bool:
|
||||
for _idx, sid, _focused in client.list_surfaces(remote_workspace_id):
|
||||
if str(sid) == browser_surface_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
_wait_for(_browser_in_remote_workspace, timeout_s=10.0, step_s=0.15)
|
||||
|
||||
client._call("browser.navigate", {"surface_id": browser_surface_id, "url": url})
|
||||
_wait_browser_contains(client, browser_surface_id, marker_body, timeout_s=20.0)
|
||||
|
||||
body = _browser_body_text(client, browser_surface_id)
|
||||
_must(marker_body in body, f"browser did not load remote localhost content over SSH proxy: {body[:240]!r}")
|
||||
_must("Can't reach this page" not in body, f"browser rendered local error page instead of remote content: {body[:240]!r}")
|
||||
|
||||
print(
|
||||
"PASS: browser proxy stays scoped to SSH workspace surfaces, uses proxy endpoint without explicit forwarded ports, "
|
||||
"and reaches remote localhost after move"
|
||||
)
|
||||
return 0
|
||||
finally:
|
||||
if remote_surface_id and remote_workspace_id:
|
||||
try:
|
||||
cleanup = f"pkill -f 'python3 -m http.server {ssh_web_port}' >/dev/null 2>&1 || true"
|
||||
with cmux(SOCKET_PATH) as cleanup_client:
|
||||
cleanup_client._call(
|
||||
"surface.send_text",
|
||||
{"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": cleanup},
|
||||
)
|
||||
cleanup_client._call(
|
||||
"surface.send_key",
|
||||
{"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"},
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
630
tests_v2/test_ssh_remote_cli_metadata.py
Normal file
630
tests_v2/test_ssh_remote_cli_metadata.py
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: `cmux ssh` creates a remote-tagged workspace with remote metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
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 _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli(cli: str, args: list[str], *, json_output: bool, extra_env: dict[str, str] | None = None) -> str:
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
env.pop("CMUX_TAB_ID", None)
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
|
||||
cmd = [cli, "--socket", SOCKET_PATH]
|
||||
if json_output:
|
||||
cmd.append("--json")
|
||||
cmd.extend(args)
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
|
||||
if proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _run_cli_json(cli: str, args: list[str], *, extra_env: dict[str, str] | None = None) -> dict:
|
||||
output = _run_cli(cli, args, json_output=True, extra_env=extra_env)
|
||||
try:
|
||||
return json.loads(output or "{}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {output!r} ({exc})")
|
||||
|
||||
|
||||
def _extract_control_path(ssh_command: str) -> str:
|
||||
match = re.search(r"ControlPath=([^\s]+)", ssh_command)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def _has_ssh_option_key(options: list[str], key: str) -> bool:
|
||||
lowered_key = key.lower()
|
||||
for option in options:
|
||||
token = re.split(r"[=\s]+", str(option).strip(), maxsplit=1)[0].strip().lower()
|
||||
if token == lowered_key:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _read_any_terminal_text(client: cmux, workspace_id: str, timeout: float = 8.0) -> str | None:
|
||||
deadline = time.time() + timeout
|
||||
last_exc: Exception | None = None
|
||||
while time.time() < deadline:
|
||||
surfaces = client.list_surfaces(workspace_id)
|
||||
for _, surface_id, _ in surfaces:
|
||||
try:
|
||||
return client.read_terminal_text(surface_id)
|
||||
except cmuxError as exc:
|
||||
text = str(exc).lower()
|
||||
if "terminal surface not found" in text:
|
||||
last_exc = exc
|
||||
continue
|
||||
raise
|
||||
time.sleep(0.1)
|
||||
print(f"WARN: readable terminal surface unavailable in workspace {workspace_id}; skipping transcript assertion ({last_exc})")
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_workspace_id_from_payload(client: cmux, payload: dict) -> str:
|
||||
workspace_id = str(payload.get("workspace_id") or "")
|
||||
if workspace_id:
|
||||
return workspace_id
|
||||
|
||||
workspace_ref = str(payload.get("workspace_ref") or "")
|
||||
if not workspace_ref.startswith("workspace:"):
|
||||
return ""
|
||||
|
||||
listed = client._call("workspace.list", {}) or {}
|
||||
for row in listed.get("workspaces") or []:
|
||||
if str(row.get("ref") or "") == workspace_ref:
|
||||
return str(row.get("id") or "")
|
||||
return ""
|
||||
|
||||
|
||||
def _append_workspace_to_cleanup(workspaces_to_close: list[str], workspace_id: str) -> str:
|
||||
if workspace_id:
|
||||
workspaces_to_close.append(workspace_id)
|
||||
return workspace_id
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cli = _find_cli_binary()
|
||||
help_text = _run_cli(cli, ["ssh", "--help"], json_output=False)
|
||||
_must("cmux ssh" in help_text, "ssh --help output should include command header")
|
||||
_must("Create a new workspace" in help_text, "ssh --help output should describe workspace creation")
|
||||
|
||||
workspace_id = ""
|
||||
workspace_id_without_name = ""
|
||||
workspace_id_strict_override = ""
|
||||
workspace_id_case_override = ""
|
||||
workspace_id_invalid_proxy_port = ""
|
||||
workspaces_to_close: list[str] = []
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
try:
|
||||
payload = _run_cli_json(
|
||||
cli,
|
||||
["ssh", "127.0.0.1", "--port", "1", "--name", "ssh-meta-test"],
|
||||
)
|
||||
workspace_id = _append_workspace_to_cleanup(
|
||||
workspaces_to_close,
|
||||
_resolve_workspace_id_from_payload(client, payload),
|
||||
)
|
||||
_must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}")
|
||||
selected_workspace_id = ""
|
||||
deadline_select = time.time() + 5.0
|
||||
while time.time() < deadline_select:
|
||||
try:
|
||||
selected_workspace_id = client.current_workspace()
|
||||
except cmuxError:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
if selected_workspace_id == workspace_id:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
_must(
|
||||
selected_workspace_id == workspace_id,
|
||||
f"cmux ssh should select the newly created workspace: expected {workspace_id}, got {selected_workspace_id}",
|
||||
)
|
||||
remote_relay_port = payload.get("remote_relay_port")
|
||||
_must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}")
|
||||
remote_socket_addr = f"127.0.0.1:{int(remote_relay_port)}"
|
||||
ssh_command = str(payload.get("ssh_command") or "")
|
||||
_must(bool(ssh_command), f"cmux ssh output missing ssh_command: {payload}")
|
||||
_must(
|
||||
ssh_command.startswith("ssh "),
|
||||
f"cmux ssh should emit plain ssh command text (env is passed via workspace.create initial_env): {ssh_command!r}",
|
||||
)
|
||||
ssh_startup_command = str(payload.get("ssh_startup_command") or "")
|
||||
_must(
|
||||
ssh_startup_command.startswith("/bin/zsh -ilc "),
|
||||
f"cmux ssh should launch startup command via interactive zsh for shell integration: {ssh_startup_command!r}",
|
||||
)
|
||||
ssh_env_overrides = payload.get("ssh_env_overrides") or {}
|
||||
_must(
|
||||
str(ssh_env_overrides.get("GHOSTTY_SHELL_FEATURES") or "").endswith("ssh-env,ssh-terminfo"),
|
||||
f"cmux ssh should pass shell niceties via ssh_env_overrides: {payload}",
|
||||
)
|
||||
_must(not ssh_command.startswith("env "), f"ssh command should not include env prefix: {ssh_command!r}")
|
||||
_must("-o StrictHostKeyChecking=accept-new" in ssh_command, f"ssh command prefix mismatch: {ssh_command!r}")
|
||||
_must("-o ControlMaster=auto" in ssh_command, f"ssh command should opt into connection reuse: {ssh_command!r}")
|
||||
_must("-o ControlPersist=600" in ssh_command, f"ssh command should keep master alive for reuse: {ssh_command!r}")
|
||||
_must("ControlPath=/tmp/cmux-ssh-" in ssh_command, f"ssh command should use shared control path template: {ssh_command!r}")
|
||||
_must(
|
||||
(
|
||||
f"RemoteCommand=export PATH=\"$HOME/.cmux/bin:$PATH\"; "
|
||||
f"export CMUX_SOCKET_PATH={remote_socket_addr}; "
|
||||
"exec \"${SHELL:-/bin/zsh}\" -l"
|
||||
) in ssh_command,
|
||||
f"cmux ssh should use -o RemoteCommand for PATH/bootstrap env pinning (not positional command): {ssh_command!r}",
|
||||
)
|
||||
|
||||
listed_row = None
|
||||
deadline = time.time() + 8.0
|
||||
while time.time() < deadline:
|
||||
listed = client._call("workspace.list", {}) or {}
|
||||
for row in listed.get("workspaces") or []:
|
||||
if str(row.get("id") or "") == workspace_id:
|
||||
listed_row = row
|
||||
break
|
||||
if listed_row is not None:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
_must(listed_row is not None, f"workspace.list did not include {workspace_id}")
|
||||
remote = listed_row.get("remote") or {}
|
||||
_must(bool(remote.get("enabled")) is True, f"workspace should be marked remote-enabled: {listed_row}")
|
||||
_must(str(remote.get("destination") or "") == "127.0.0.1", f"remote destination mismatch: {remote}")
|
||||
_must(str(listed_row.get("title") or "") == "ssh-meta-test", f"workspace title mismatch: {listed_row}")
|
||||
_must(
|
||||
str(remote.get("state") or "") in {"connecting", "connected", "error", "disconnected"},
|
||||
f"unexpected remote state: {remote}",
|
||||
)
|
||||
proxy = remote.get("proxy") or {}
|
||||
_must(
|
||||
str(proxy.get("state") or "") in {"connecting", "ready", "error", "unavailable"},
|
||||
f"remote payload should include proxy state metadata: {remote}",
|
||||
)
|
||||
remote_ssh_options = [str(item) for item in (remote.get("ssh_options") or [])]
|
||||
_must(
|
||||
_has_ssh_option_key(remote_ssh_options, "ControlMaster"),
|
||||
f"workspace.remote.configure should include ControlMaster default: {remote}",
|
||||
)
|
||||
_must(
|
||||
_has_ssh_option_key(remote_ssh_options, "ControlPersist"),
|
||||
f"workspace.remote.configure should include ControlPersist default: {remote}",
|
||||
)
|
||||
_must(
|
||||
_has_ssh_option_key(remote_ssh_options, "ControlPath"),
|
||||
f"workspace.remote.configure should include ControlPath default: {remote}",
|
||||
)
|
||||
# Regression: cmux ssh should launch through initial_command, not visibly type a giant command into the shell.
|
||||
terminal_text = _read_any_terminal_text(client, workspace_id)
|
||||
if terminal_text is not None:
|
||||
_must("ControlPersist=600" not in terminal_text, f"cmux ssh should not inject raw ssh command text: {terminal_text!r}")
|
||||
_must("GHOSTTY_SHELL_FEATURES=" not in terminal_text, f"cmux ssh should not inject env assignment text: {terminal_text!r}")
|
||||
|
||||
status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
||||
status_remote = status.get("remote") or {}
|
||||
_must(bool(status_remote.get("enabled")) is True, f"workspace.remote.status should report enabled remote: {status}")
|
||||
daemon = status_remote.get("daemon") or {}
|
||||
_must(
|
||||
str(daemon.get("state") or "") in {"unavailable", "bootstrapping", "ready", "error"},
|
||||
f"workspace.remote.status should include daemon state metadata: {status_remote}",
|
||||
)
|
||||
# Fail-fast regression: unreachable SSH target should surface bootstrap error explicitly.
|
||||
deadline_daemon = time.time() + 12.0
|
||||
last_status = status
|
||||
while time.time() < deadline_daemon:
|
||||
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
||||
last_remote = last_status.get("remote") or {}
|
||||
last_daemon = last_remote.get("daemon") or {}
|
||||
if str(last_daemon.get("state") or "") == "error":
|
||||
break
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
raise cmuxError(f"unreachable host should drive daemon state to error: {last_status}")
|
||||
|
||||
last_remote = last_status.get("remote") or {}
|
||||
last_daemon = last_remote.get("daemon") or {}
|
||||
detail = str(last_daemon.get("detail") or "")
|
||||
_must("bootstrap failed" in detail.lower(), f"daemon error should mention bootstrap failure: {last_status}")
|
||||
_must(re.search(r"retry\s+\d+", detail.lower()) is not None, f"daemon error should include retry count: {last_status}")
|
||||
|
||||
# Lifecycle regression: disconnect with clear should reset remote/daemon metadata.
|
||||
disconnected = client._call(
|
||||
"workspace.remote.disconnect",
|
||||
{"workspace_id": workspace_id, "clear": True},
|
||||
) or {}
|
||||
disconnected_remote = disconnected.get("remote") or {}
|
||||
disconnected_daemon = disconnected_remote.get("daemon") or {}
|
||||
_must(bool(disconnected_remote.get("enabled")) is False, f"remote config should be cleared: {disconnected}")
|
||||
_must(str(disconnected_remote.get("state") or "") == "disconnected", f"remote state should be disconnected: {disconnected}")
|
||||
_must(str(disconnected_daemon.get("state") or "") == "unavailable", f"daemon state should reset to unavailable: {disconnected}")
|
||||
try:
|
||||
client._call("workspace.remote.reconnect", {"workspace_id": workspace_id})
|
||||
raise cmuxError("workspace.remote.reconnect should fail when remote config was cleared")
|
||||
except cmuxError as exc:
|
||||
text = str(exc).lower()
|
||||
_must("invalid_state" in text, f"workspace.remote.reconnect missing invalid_state for cleared config: {exc}")
|
||||
_must("not configured" in text, f"workspace.remote.reconnect should explain missing remote config: {exc}")
|
||||
|
||||
# Regression: --name is optional.
|
||||
payload2 = _run_cli_json(
|
||||
cli,
|
||||
["ssh", "127.0.0.1", "--port", "1"],
|
||||
)
|
||||
workspace_id_without_name = _append_workspace_to_cleanup(
|
||||
workspaces_to_close,
|
||||
_resolve_workspace_id_from_payload(client, payload2),
|
||||
)
|
||||
ssh_command_without_name = str(payload2.get("ssh_command") or "")
|
||||
|
||||
_must(bool(workspace_id_without_name), f"cmux ssh without --name should still create workspace: {payload2}")
|
||||
_must(
|
||||
"ControlPath=/tmp/cmux-ssh-" in ssh_command_without_name,
|
||||
f"cmux ssh without --name should still include control path defaults: {ssh_command_without_name!r}",
|
||||
)
|
||||
_must(
|
||||
_extract_control_path(ssh_command) != _extract_control_path(ssh_command_without_name),
|
||||
f"distinct cmux ssh workspaces should get distinct control paths: {ssh_command!r} vs {ssh_command_without_name!r}",
|
||||
)
|
||||
row2 = None
|
||||
listed2 = client._call("workspace.list", {}) or {}
|
||||
for row in listed2.get("workspaces") or []:
|
||||
if str(row.get("id") or "") == workspace_id_without_name:
|
||||
row2 = row
|
||||
break
|
||||
_must(row2 is not None, f"workspace created without --name missing from workspace.list: {workspace_id_without_name}")
|
||||
_must(bool(str((row2 or {}).get("title") or "").strip()), f"workspace title should not be empty without --name: {row2}")
|
||||
reconnected = client._call("workspace.remote.reconnect", {"workspace_id": workspace_id_without_name}) or {}
|
||||
reconnected_remote = reconnected.get("remote") or {}
|
||||
_must(bool(reconnected_remote.get("enabled")) is True, f"workspace.remote.reconnect should keep remote enabled: {reconnected}")
|
||||
_must(
|
||||
str(reconnected_remote.get("state") or "") in {"connecting", "connected", "error"},
|
||||
f"workspace.remote.reconnect should transition into an active state: {reconnected}",
|
||||
)
|
||||
|
||||
payload_strict_override = _run_cli_json(
|
||||
cli,
|
||||
[
|
||||
"ssh",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"1",
|
||||
"--name",
|
||||
"ssh-meta-strict-override",
|
||||
"--ssh-option",
|
||||
"StrictHostKeyChecking=no",
|
||||
],
|
||||
)
|
||||
workspace_id_strict_override = _append_workspace_to_cleanup(
|
||||
workspaces_to_close,
|
||||
_resolve_workspace_id_from_payload(client, payload_strict_override),
|
||||
)
|
||||
_must(
|
||||
bool(workspace_id_strict_override),
|
||||
f"cmux ssh with StrictHostKeyChecking override should create workspace: {payload_strict_override}",
|
||||
)
|
||||
ssh_command_strict_override = str(payload_strict_override.get("ssh_command") or "")
|
||||
_must(
|
||||
"-o StrictHostKeyChecking=no" in ssh_command_strict_override,
|
||||
f"ssh command should include user StrictHostKeyChecking override: {ssh_command_strict_override!r}",
|
||||
)
|
||||
_must(
|
||||
"-o StrictHostKeyChecking=accept-new" not in ssh_command_strict_override,
|
||||
f"ssh command should not force default StrictHostKeyChecking when override is supplied: {ssh_command_strict_override!r}",
|
||||
)
|
||||
strict_override_remote = payload_strict_override.get("remote") or {}
|
||||
strict_override_options = [str(item) for item in (strict_override_remote.get("ssh_options") or [])]
|
||||
_must(
|
||||
any(item.lower() == "stricthostkeychecking=no" for item in strict_override_options),
|
||||
f"workspace.remote.configure should preserve explicit StrictHostKeyChecking override: {strict_override_remote}",
|
||||
)
|
||||
|
||||
payload_case_override = _run_cli_json(
|
||||
cli,
|
||||
[
|
||||
"ssh",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"1",
|
||||
"--name",
|
||||
"ssh-meta-case-override",
|
||||
"--ssh-option",
|
||||
"stricthostkeychecking=no",
|
||||
"--ssh-option",
|
||||
"controlmaster=no",
|
||||
"--ssh-option",
|
||||
"controlpersist=0",
|
||||
"--ssh-option",
|
||||
"controlpath=/tmp/cmux-ssh-%C-custom",
|
||||
],
|
||||
)
|
||||
workspace_id_case_override = _append_workspace_to_cleanup(
|
||||
workspaces_to_close,
|
||||
_resolve_workspace_id_from_payload(client, payload_case_override),
|
||||
)
|
||||
_must(
|
||||
bool(workspace_id_case_override),
|
||||
f"cmux ssh with lowercase SSH option overrides should create workspace: {payload_case_override}",
|
||||
)
|
||||
ssh_command_case_override = str(payload_case_override.get("ssh_command") or "")
|
||||
ssh_command_case_override_lower = ssh_command_case_override.lower()
|
||||
_must(
|
||||
"-o stricthostkeychecking=no" in ssh_command_case_override_lower,
|
||||
f"ssh command should preserve lowercase StrictHostKeyChecking override: {ssh_command_case_override!r}",
|
||||
)
|
||||
_must(
|
||||
"stricthostkeychecking=accept-new" not in ssh_command_case_override_lower,
|
||||
f"ssh command should not force default StrictHostKeyChecking when lowercase override is supplied: {ssh_command_case_override!r}",
|
||||
)
|
||||
_must(
|
||||
"-o controlmaster=no" in ssh_command_case_override_lower,
|
||||
f"ssh command should preserve lowercase ControlMaster override: {ssh_command_case_override!r}",
|
||||
)
|
||||
_must(
|
||||
"controlmaster=auto" not in ssh_command_case_override_lower,
|
||||
f"ssh command should not force default ControlMaster when lowercase override is supplied: {ssh_command_case_override!r}",
|
||||
)
|
||||
_must(
|
||||
"-o controlpersist=0" in ssh_command_case_override_lower,
|
||||
f"ssh command should preserve lowercase ControlPersist override: {ssh_command_case_override!r}",
|
||||
)
|
||||
_must(
|
||||
"controlpersist=600" not in ssh_command_case_override_lower,
|
||||
f"ssh command should not force default ControlPersist when lowercase override is supplied: {ssh_command_case_override!r}",
|
||||
)
|
||||
_must(
|
||||
"controlpath=/tmp/cmux-ssh-%c-custom" in ssh_command_case_override_lower,
|
||||
f"ssh command should preserve lowercase ControlPath override value: {ssh_command_case_override!r}",
|
||||
)
|
||||
_must(
|
||||
ssh_command_case_override_lower.count("controlpath=") == 1,
|
||||
f"ssh command should include exactly one ControlPath when lowercase override is supplied: {ssh_command_case_override!r}",
|
||||
)
|
||||
case_override_remote = payload_case_override.get("remote") or {}
|
||||
case_override_options = [str(item) for item in (case_override_remote.get("ssh_options") or [])]
|
||||
_must(
|
||||
any(item.lower() == "stricthostkeychecking=no" for item in case_override_options),
|
||||
f"workspace.remote.configure should preserve lowercase StrictHostKeyChecking override: {case_override_remote}",
|
||||
)
|
||||
_must(
|
||||
not any(item.lower() == "stricthostkeychecking=accept-new" for item in case_override_options),
|
||||
f"workspace.remote.configure should not inject default StrictHostKeyChecking when lowercase override is supplied: {case_override_remote}",
|
||||
)
|
||||
_must(
|
||||
any(item.lower() == "controlmaster=no" for item in case_override_options),
|
||||
f"workspace.remote.configure should preserve lowercase ControlMaster override: {case_override_remote}",
|
||||
)
|
||||
_must(
|
||||
not any(item.lower() == "controlmaster=auto" for item in case_override_options),
|
||||
f"workspace.remote.configure should not inject default ControlMaster when lowercase override is supplied: {case_override_remote}",
|
||||
)
|
||||
_must(
|
||||
any(item.lower() == "controlpersist=0" for item in case_override_options),
|
||||
f"workspace.remote.configure should preserve lowercase ControlPersist override: {case_override_remote}",
|
||||
)
|
||||
_must(
|
||||
not any(item.lower() == "controlpersist=600" for item in case_override_options),
|
||||
f"workspace.remote.configure should not inject default ControlPersist when lowercase override is supplied: {case_override_remote}",
|
||||
)
|
||||
_must(
|
||||
any(item.lower() == "controlpath=/tmp/cmux-ssh-%c-custom" for item in case_override_options),
|
||||
f"workspace.remote.configure should preserve lowercase ControlPath override: {case_override_remote}",
|
||||
)
|
||||
_must(
|
||||
sum(1 for item in case_override_options if item.lower().startswith("controlpath=")) == 1,
|
||||
f"workspace.remote.configure should include exactly one ControlPath when lowercase override is supplied: {case_override_remote}",
|
||||
)
|
||||
|
||||
payload3 = _run_cli_json(
|
||||
cli,
|
||||
["ssh", "127.0.0.1", "--port", "1", "--name", "ssh-meta-features"],
|
||||
extra_env={"GHOSTTY_SHELL_FEATURES": "cursor,title"},
|
||||
)
|
||||
payload3_env = payload3.get("ssh_env_overrides") or {}
|
||||
merged_features = str(payload3_env.get("GHOSTTY_SHELL_FEATURES") or "")
|
||||
_must(
|
||||
merged_features == "cursor,title,ssh-env,ssh-terminfo",
|
||||
f"cmux ssh should merge existing shell features when present: {payload3!r}",
|
||||
)
|
||||
workspace_id3 = _append_workspace_to_cleanup(
|
||||
workspaces_to_close,
|
||||
_resolve_workspace_id_from_payload(client, payload3),
|
||||
)
|
||||
if workspace_id3:
|
||||
try:
|
||||
client.close_workspace(workspace_id3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
invalid_proxy_port_workspace = client._call("workspace.create", {"initial_command": "echo invalid-local-proxy-port"}) or {}
|
||||
workspace_id_invalid_proxy_port = str(invalid_proxy_port_workspace.get("workspace_id") or "")
|
||||
if workspace_id_invalid_proxy_port:
|
||||
workspaces_to_close.append(workspace_id_invalid_proxy_port)
|
||||
_must(bool(workspace_id_invalid_proxy_port), f"workspace.create missing workspace_id: {invalid_proxy_port_workspace}")
|
||||
|
||||
configured_with_string_ports = client._call(
|
||||
"workspace.remote.configure",
|
||||
{
|
||||
"workspace_id": workspace_id_invalid_proxy_port,
|
||||
"destination": "127.0.0.1",
|
||||
"port": "2222",
|
||||
"local_proxy_port": "31338",
|
||||
"auto_connect": False,
|
||||
},
|
||||
) or {}
|
||||
configured_with_string_ports_remote = configured_with_string_ports.get("remote") or {}
|
||||
_must(
|
||||
int(configured_with_string_ports_remote.get("port") or 0) == 2222,
|
||||
f"workspace.remote.configure should parse numeric string port values: {configured_with_string_ports}",
|
||||
)
|
||||
_must(
|
||||
int(configured_with_string_ports_remote.get("local_proxy_port") or 0) == 31338,
|
||||
f"workspace.remote.configure should parse numeric string local_proxy_port values: {configured_with_string_ports}",
|
||||
)
|
||||
|
||||
valid_local_proxy_port = 31337
|
||||
configured_with_local_proxy_port = client._call(
|
||||
"workspace.remote.configure",
|
||||
{
|
||||
"workspace_id": workspace_id_invalid_proxy_port,
|
||||
"destination": "127.0.0.1",
|
||||
"port": 2222,
|
||||
"local_proxy_port": valid_local_proxy_port,
|
||||
"auto_connect": False,
|
||||
},
|
||||
) or {}
|
||||
configured_remote = configured_with_local_proxy_port.get("remote") or {}
|
||||
_must(
|
||||
int(configured_remote.get("port") or 0) == 2222,
|
||||
f"workspace.remote.configure should echo explicit port in remote payload: {configured_with_local_proxy_port}",
|
||||
)
|
||||
_must(
|
||||
int(configured_remote.get("local_proxy_port") or 0) == valid_local_proxy_port,
|
||||
f"workspace.remote.configure should echo local_proxy_port in remote payload: {configured_with_local_proxy_port}",
|
||||
)
|
||||
|
||||
configured_with_null_ports = client._call(
|
||||
"workspace.remote.configure",
|
||||
{
|
||||
"workspace_id": workspace_id_invalid_proxy_port,
|
||||
"destination": "127.0.0.1",
|
||||
"port": None,
|
||||
"local_proxy_port": None,
|
||||
"auto_connect": False,
|
||||
},
|
||||
) or {}
|
||||
configured_with_null_ports_remote = configured_with_null_ports.get("remote") or {}
|
||||
_must(
|
||||
configured_with_null_ports_remote.get("port") is None,
|
||||
f"workspace.remote.configure should allow null to clear port: {configured_with_null_ports}",
|
||||
)
|
||||
_must(
|
||||
configured_with_null_ports_remote.get("local_proxy_port") is None,
|
||||
f"workspace.remote.configure should allow null to clear local_proxy_port: {configured_with_null_ports}",
|
||||
)
|
||||
status_after_null_ports = client._call(
|
||||
"workspace.remote.status",
|
||||
{"workspace_id": workspace_id_invalid_proxy_port},
|
||||
) or {}
|
||||
status_after_null_ports_remote = status_after_null_ports.get("remote") or {}
|
||||
_must(
|
||||
status_after_null_ports_remote.get("port") is None,
|
||||
f"workspace.remote.status should reflect cleared port: {status_after_null_ports}",
|
||||
)
|
||||
_must(
|
||||
status_after_null_ports_remote.get("local_proxy_port") is None,
|
||||
f"workspace.remote.status should reflect cleared local_proxy_port: {status_after_null_ports}",
|
||||
)
|
||||
|
||||
for invalid_local_proxy_port in [0, 65536, "abc", True, 22.5]:
|
||||
try:
|
||||
client._call(
|
||||
"workspace.remote.configure",
|
||||
{
|
||||
"workspace_id": workspace_id_invalid_proxy_port,
|
||||
"destination": "127.0.0.1",
|
||||
"local_proxy_port": invalid_local_proxy_port,
|
||||
"auto_connect": False,
|
||||
},
|
||||
)
|
||||
raise cmuxError(
|
||||
f"workspace.remote.configure should reject local_proxy_port={invalid_local_proxy_port!r}"
|
||||
)
|
||||
except cmuxError as exc:
|
||||
text = str(exc)
|
||||
lowered = text.lower()
|
||||
_must(
|
||||
"invalid_params" in lowered,
|
||||
f"workspace.remote.configure should return invalid_params for local_proxy_port={invalid_local_proxy_port!r}: {exc}",
|
||||
)
|
||||
_must(
|
||||
"local_proxy_port must be 1-65535" in text,
|
||||
f"workspace.remote.configure should include validation hint for local_proxy_port={invalid_local_proxy_port!r}: {exc}",
|
||||
)
|
||||
|
||||
for invalid_port in [0, 65536, "abc", True, 22.5]:
|
||||
try:
|
||||
client._call(
|
||||
"workspace.remote.configure",
|
||||
{
|
||||
"workspace_id": workspace_id_invalid_proxy_port,
|
||||
"destination": "127.0.0.1",
|
||||
"port": invalid_port,
|
||||
"auto_connect": False,
|
||||
},
|
||||
)
|
||||
raise cmuxError(
|
||||
f"workspace.remote.configure should reject port={invalid_port!r}"
|
||||
)
|
||||
except cmuxError as exc:
|
||||
text = str(exc)
|
||||
lowered = text.lower()
|
||||
_must(
|
||||
"invalid_params" in lowered,
|
||||
f"workspace.remote.configure should return invalid_params for port={invalid_port!r}: {exc}",
|
||||
)
|
||||
_must(
|
||||
"port must be 1-65535" in text,
|
||||
f"workspace.remote.configure should include validation hint for port={invalid_port!r}: {exc}",
|
||||
)
|
||||
|
||||
try:
|
||||
client.close_workspace(workspace_id_invalid_proxy_port)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
workspace_id_invalid_proxy_port = ""
|
||||
finally:
|
||||
for workspace_id_to_close in dict.fromkeys(workspaces_to_close):
|
||||
if not workspace_id_to_close:
|
||||
continue
|
||||
try:
|
||||
client.close_workspace(workspace_id_to_close)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("PASS: cmux ssh marks workspace as remote, exposes remote metadata, and does not require --name")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
392
tests_v2/test_ssh_remote_cli_relay.py
Normal file
392
tests_v2/test_ssh_remote_cli_relay.py
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Docker integration: verify cmux CLI commands work over SSH via reverse socket forwarding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
# Keep the fixture's extra HTTP server below 1024 so there are no eligible
|
||||
# (>1023) ports to auto-forward. This guards the "connecting forever" regression.
|
||||
REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "81"))
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
|
||||
if check and proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
|
||||
return proc
|
||||
|
||||
|
||||
def _run_cli_json(cli: str, args: list[str]) -> dict:
|
||||
env = dict(os.environ)
|
||||
# Ensure --socket is what drives the relay path during tests.
|
||||
env.pop("CMUX_SOCKET_PATH", None)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
env.pop("CMUX_TAB_ID", None)
|
||||
|
||||
proc = _run([cli, "--socket", SOCKET_PATH, "--json", "--id-format", "both", *args], env=env)
|
||||
try:
|
||||
return json.loads(proc.stdout or "{}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
|
||||
|
||||
|
||||
def _docker_available() -> bool:
|
||||
if shutil.which("docker") is None:
|
||||
return False
|
||||
probe = _run(["docker", "info"], check=False)
|
||||
return probe.returncode == 0
|
||||
|
||||
|
||||
def _parse_host_port(docker_port_output: str) -> int:
|
||||
text = docker_port_output.strip()
|
||||
if not text:
|
||||
raise cmuxError("docker port output was empty")
|
||||
last = text.split(":")[-1]
|
||||
return int(last)
|
||||
|
||||
|
||||
def _shell_single_quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
return _run(
|
||||
[
|
||||
"ssh",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "ConnectTimeout=5",
|
||||
"-p", str(host_port),
|
||||
"-i", str(key_path),
|
||||
host,
|
||||
f"sh -lc {_shell_single_quote(script)}",
|
||||
],
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
probe = _ssh_run(host, host_port, key_path, "echo ready", check=False)
|
||||
if probe.returncode == 0 and "ready" in probe.stdout:
|
||||
return
|
||||
time.sleep(0.5)
|
||||
raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready")
|
||||
|
||||
|
||||
def _wait_for_remote_ready(client, workspace_id: str, timeout: float = 45.0) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
last_status = {}
|
||||
while time.time() < deadline:
|
||||
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
||||
remote = last_status.get("remote") or {}
|
||||
daemon = remote.get("daemon") or {}
|
||||
state = str(remote.get("state") or "")
|
||||
daemon_state = str(daemon.get("state") or "")
|
||||
if state == "connected" and daemon_state == "ready":
|
||||
return last_status
|
||||
time.sleep(0.5)
|
||||
raise cmuxError(f"Remote daemon did not become ready: {last_status}")
|
||||
|
||||
|
||||
def _assert_remote_ping(host: str, host_port: int, key_path: Path, remote_socket_addr: str, *, label: str) -> None:
|
||||
ping_result = _ssh_run(
|
||||
host, host_port, key_path,
|
||||
f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux ping",
|
||||
check=False,
|
||||
)
|
||||
_must(
|
||||
ping_result.returncode == 0 and "pong" in ping_result.stdout.lower(),
|
||||
f"{label} cmux ping failed: rc={ping_result.returncode} stdout={ping_result.stdout!r} stderr={ping_result.stderr!r}",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not _docker_available():
|
||||
print("SKIP: docker is not available")
|
||||
return 0
|
||||
|
||||
cli = _find_cli_binary()
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote"
|
||||
_must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}")
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-cli-relay-"))
|
||||
image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}"
|
||||
container_name = f"cmux-ssh-cli-relay-{secrets.token_hex(4)}"
|
||||
workspace_id = ""
|
||||
workspace_id_2 = ""
|
||||
|
||||
try:
|
||||
# Generate SSH key pair
|
||||
key_path = temp_dir / "id_ed25519"
|
||||
_run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)])
|
||||
pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip()
|
||||
_must(bool(pubkey), "Generated SSH public key was empty")
|
||||
|
||||
# Build and start Docker container
|
||||
_run(["docker", "build", "-t", image_tag, str(fixture_dir)])
|
||||
_run([
|
||||
"docker", "run", "-d", "--rm",
|
||||
"--name", container_name,
|
||||
"-e", f"AUTHORIZED_KEY={pubkey}",
|
||||
"-e", f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}",
|
||||
"-p", "127.0.0.1::22",
|
||||
image_tag,
|
||||
])
|
||||
|
||||
port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout
|
||||
host_ssh_port = _parse_host_port(port_info)
|
||||
host = "root@127.0.0.1"
|
||||
_wait_for_ssh(host, host_ssh_port, key_path)
|
||||
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
# Create SSH workspace (this sets up the reverse socket forward)
|
||||
payload = _run_cli_json(
|
||||
cli,
|
||||
[
|
||||
"ssh",
|
||||
host,
|
||||
"--name", "docker-cli-relay",
|
||||
"--port", str(host_ssh_port),
|
||||
"--identity", str(key_path),
|
||||
"--ssh-option", "UserKnownHostsFile=/dev/null",
|
||||
"--ssh-option", "StrictHostKeyChecking=no",
|
||||
],
|
||||
)
|
||||
workspace_id = str(payload.get("workspace_id") or "")
|
||||
workspace_ref = str(payload.get("workspace_ref") or "")
|
||||
if not workspace_id and workspace_ref.startswith("workspace:"):
|
||||
listed = client._call("workspace.list", {}) or {}
|
||||
for row in listed.get("workspaces") or []:
|
||||
if str(row.get("ref") or "") == workspace_ref:
|
||||
workspace_id = str(row.get("id") or "")
|
||||
break
|
||||
_must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}")
|
||||
remote_relay_port = payload.get("remote_relay_port")
|
||||
_must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}")
|
||||
remote_relay_port = int(remote_relay_port)
|
||||
_must(49152 <= remote_relay_port <= 65535, f"remote_relay_port should be in ephemeral range: {remote_relay_port}")
|
||||
remote_socket_addr = f"127.0.0.1:{remote_relay_port}"
|
||||
startup_cmd = str(payload.get("ssh_startup_command") or "")
|
||||
_must(
|
||||
'PATH="$HOME/.cmux/bin:$PATH"' in startup_cmd,
|
||||
f"ssh startup command should prepend ~/.cmux/bin for remote cmux CLI: {startup_cmd!r}",
|
||||
)
|
||||
_must(
|
||||
f"CMUX_SOCKET_PATH={remote_socket_addr}" in startup_cmd,
|
||||
f"ssh startup command should pin CMUX_SOCKET_PATH to workspace relay: {startup_cmd!r}",
|
||||
)
|
||||
workspace_window_id = payload.get("window_id")
|
||||
current_params = {"window_id": workspace_window_id} if isinstance(workspace_window_id, str) and workspace_window_id else {}
|
||||
current = client._call("workspace.current", current_params) or {}
|
||||
current_workspace_id = str(current.get("workspace_id") or "")
|
||||
_must(
|
||||
current_workspace_id == workspace_id,
|
||||
f"cmux ssh should focus created workspace: current={current_workspace_id!r} created={workspace_id!r}",
|
||||
)
|
||||
|
||||
# Wait for daemon to be ready
|
||||
first_status = _wait_for_remote_ready(client, workspace_id)
|
||||
first_remote = first_status.get("remote") or {}
|
||||
# Regression: should transition to connected even with no eligible
|
||||
# (>1023, non-ephemeral) remote ports.
|
||||
_must(
|
||||
not (first_remote.get("detected_ports") or []),
|
||||
f"expected no eligible detected ports in fixture: {first_status}",
|
||||
)
|
||||
_must(
|
||||
not (first_remote.get("forwarded_ports") or []),
|
||||
f"expected no forwarded ports when none are eligible: {first_status}",
|
||||
)
|
||||
|
||||
# Verify remote cmux wrapper + relay-specific daemon mapping were installed.
|
||||
wrapper_check = None
|
||||
wrapper_deadline = time.time() + 10.0
|
||||
while time.time() < wrapper_deadline:
|
||||
wrapper_check = _ssh_run(
|
||||
host, host_ssh_port, key_path,
|
||||
f"test -x \"$HOME/.cmux/bin/cmux\" && test -f \"$HOME/.cmux/bin/cmux\" && "
|
||||
f"map=\"$HOME/.cmux/relay/{remote_relay_port}.daemon_path\" && "
|
||||
"daemon=\"$(cat \"$map\" 2>/dev/null || true)\" && "
|
||||
"test -n \"$daemon\" && test -x \"$daemon\" && echo wrapper-ok",
|
||||
check=False,
|
||||
)
|
||||
if "wrapper-ok" in (wrapper_check.stdout or ""):
|
||||
break
|
||||
time.sleep(0.4)
|
||||
_must(
|
||||
wrapper_check is not None and "wrapper-ok" in (wrapper_check.stdout or ""),
|
||||
f"Expected remote cmux wrapper+relay mapping to exist: {wrapper_check.stdout if wrapper_check else ''} {wrapper_check.stderr if wrapper_check else ''}",
|
||||
)
|
||||
|
||||
# Start a second SSH workspace to the same destination and verify both
|
||||
# relays remain healthy (regression: same-host workspaces killed each other).
|
||||
payload_2 = _run_cli_json(
|
||||
cli,
|
||||
[
|
||||
"ssh",
|
||||
host,
|
||||
"--name", "docker-cli-relay-2",
|
||||
"--port", str(host_ssh_port),
|
||||
"--identity", str(key_path),
|
||||
"--ssh-option", "UserKnownHostsFile=/dev/null",
|
||||
"--ssh-option", "StrictHostKeyChecking=no",
|
||||
],
|
||||
)
|
||||
workspace_id_2 = str(payload_2.get("workspace_id") or "")
|
||||
workspace_ref_2 = str(payload_2.get("workspace_ref") or "")
|
||||
if not workspace_id_2 and workspace_ref_2.startswith("workspace:"):
|
||||
listed_2 = client._call("workspace.list", {}) or {}
|
||||
for row in listed_2.get("workspaces") or []:
|
||||
if str(row.get("ref") or "") == workspace_ref_2:
|
||||
workspace_id_2 = str(row.get("id") or "")
|
||||
break
|
||||
_must(bool(workspace_id_2), f"second cmux ssh output missing workspace_id: {payload_2}")
|
||||
|
||||
remote_relay_port_2 = payload_2.get("remote_relay_port")
|
||||
_must(remote_relay_port_2 is not None, f"second cmux ssh output missing remote_relay_port: {payload_2}")
|
||||
remote_relay_port_2 = int(remote_relay_port_2)
|
||||
_must(49152 <= remote_relay_port_2 <= 65535, f"second remote_relay_port out of range: {remote_relay_port_2}")
|
||||
_must(
|
||||
remote_relay_port_2 != remote_relay_port,
|
||||
f"relay ports should differ per workspace: {remote_relay_port_2} vs {remote_relay_port}",
|
||||
)
|
||||
remote_socket_addr_2 = f"127.0.0.1:{remote_relay_port_2}"
|
||||
startup_cmd_2 = str(payload_2.get("ssh_startup_command") or "")
|
||||
_must(
|
||||
f"CMUX_SOCKET_PATH={remote_socket_addr_2}" in startup_cmd_2,
|
||||
f"second ssh startup command should pin CMUX_SOCKET_PATH to second relay: {startup_cmd_2!r}",
|
||||
)
|
||||
_ = _wait_for_remote_ready(client, workspace_id_2)
|
||||
|
||||
stability_deadline = time.time() + 8.0
|
||||
while time.time() < stability_deadline:
|
||||
_assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr, label="first relay")
|
||||
_assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr_2, label="second relay")
|
||||
time.sleep(0.5)
|
||||
|
||||
# Test 1: cmux ping (v1)
|
||||
_assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr, label="cmux")
|
||||
|
||||
# Test 2: cmux list-workspaces --json (v2)
|
||||
list_ws_result = _ssh_run(
|
||||
host, host_ssh_port, key_path,
|
||||
f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux --json list-workspaces",
|
||||
check=False,
|
||||
)
|
||||
_must(
|
||||
list_ws_result.returncode == 0,
|
||||
f"cmux list-workspaces failed: rc={list_ws_result.returncode} stderr={list_ws_result.stderr!r}",
|
||||
)
|
||||
try:
|
||||
ws_data = json.loads(list_ws_result.stdout.strip())
|
||||
_must(isinstance(ws_data, dict), f"list-workspaces should return JSON object: {list_ws_result.stdout!r}")
|
||||
except json.JSONDecodeError:
|
||||
raise cmuxError(f"list-workspaces returned invalid JSON: {list_ws_result.stdout!r}")
|
||||
|
||||
# Test 3: cmux new-window (v1)
|
||||
new_win_result = _ssh_run(
|
||||
host, host_ssh_port, key_path,
|
||||
f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux new-window",
|
||||
check=False,
|
||||
)
|
||||
_must(
|
||||
new_win_result.returncode == 0,
|
||||
f"cmux new-window failed: rc={new_win_result.returncode} stderr={new_win_result.stderr!r}",
|
||||
)
|
||||
|
||||
# Test 4: cmux rpc system.capabilities (v2 passthrough)
|
||||
rpc_result = _ssh_run(
|
||||
host, host_ssh_port, key_path,
|
||||
f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux rpc system.capabilities",
|
||||
check=False,
|
||||
)
|
||||
_must(
|
||||
rpc_result.returncode == 0,
|
||||
f"cmux rpc system.capabilities failed: rc={rpc_result.returncode} stderr={rpc_result.stderr!r}",
|
||||
)
|
||||
try:
|
||||
caps_data = json.loads(rpc_result.stdout.strip())
|
||||
_must(isinstance(caps_data, dict), f"rpc capabilities should return JSON: {rpc_result.stdout!r}")
|
||||
except json.JSONDecodeError:
|
||||
raise cmuxError(f"rpc system.capabilities returned invalid JSON: {rpc_result.stdout!r}")
|
||||
|
||||
# Cleanup
|
||||
try:
|
||||
client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
workspace_id = ""
|
||||
if workspace_id_2:
|
||||
try:
|
||||
client.close_workspace(workspace_id_2)
|
||||
except Exception:
|
||||
pass
|
||||
workspace_id_2 = ""
|
||||
|
||||
print("PASS: cmux CLI commands relay correctly over SSH reverse socket forwarding")
|
||||
return 0
|
||||
|
||||
finally:
|
||||
if workspace_id:
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as cleanup_client:
|
||||
cleanup_client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
if workspace_id_2:
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as cleanup_client:
|
||||
cleanup_client.close_workspace(workspace_id_2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_run(["docker", "rm", "-f", container_name], check=False)
|
||||
_run(["docker", "rmi", "-f", image_tag], check=False)
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
188
tests_v2/test_ssh_remote_daemon_resize_stdio.py
Normal file
188
tests_v2/test_ssh_remote_daemon_resize_stdio.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Process-level integration: cmuxd-remote stdio session resize coordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import select
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmuxError
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _daemon_module_dir() -> Path:
|
||||
return Path(__file__).resolve().parents[1] / "daemon" / "remote"
|
||||
|
||||
|
||||
def _rpc(
|
||||
proc: subprocess.Popen[str],
|
||||
req_id: int,
|
||||
method: str,
|
||||
params: dict,
|
||||
*,
|
||||
timeout_s: float = 5.0,
|
||||
) -> dict:
|
||||
if proc.stdin is None or proc.stdout is None:
|
||||
raise cmuxError("daemon subprocess stdio pipes are not available")
|
||||
|
||||
payload = {"id": req_id, "method": method, "params": params}
|
||||
proc.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n")
|
||||
proc.stdin.flush()
|
||||
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
wait_s = max(0.0, min(0.2, deadline - time.time()))
|
||||
ready, _, _ = select.select([proc.stdout], [], [], wait_s)
|
||||
if not ready:
|
||||
continue
|
||||
line = proc.stdout.readline()
|
||||
if line == "":
|
||||
stderr = ""
|
||||
if proc.stderr is not None:
|
||||
try:
|
||||
stderr = proc.stderr.read().strip()
|
||||
except Exception:
|
||||
stderr = ""
|
||||
raise cmuxError(f"cmuxd-remote exited while waiting for {method} response: {stderr}")
|
||||
try:
|
||||
resp = json.loads(line)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise cmuxError(f"Invalid JSON response for {method}: {line!r} ({exc})")
|
||||
_must(resp.get("id") == req_id, f"Response id mismatch for {method}: {resp}")
|
||||
return resp
|
||||
|
||||
raise cmuxError(f"Timed out waiting for cmuxd-remote response: {method}")
|
||||
|
||||
|
||||
def _as_int(value: object, field: str) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise cmuxError(f"{field} should be numeric, got bool")
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return int(value)
|
||||
raise cmuxError(f"{field} has unexpected type {type(value).__name__}: {value!r}")
|
||||
|
||||
|
||||
def _assert_effective(resp: dict, want_cols: int, want_rows: int, label: str) -> None:
|
||||
_must(resp.get("ok") is True, f"{label} should return ok=true: {resp}")
|
||||
result = resp.get("result") or {}
|
||||
got_cols = _as_int(result.get("effective_cols"), "effective_cols")
|
||||
got_rows = _as_int(result.get("effective_rows"), "effective_rows")
|
||||
_must(
|
||||
got_cols == want_cols and got_rows == want_rows,
|
||||
f"{label} effective size mismatch: got {got_cols}x{got_rows}, want {want_cols}x{want_rows} ({resp})",
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if shutil.which("go") is None:
|
||||
print("SKIP: go is not available")
|
||||
return 0
|
||||
|
||||
daemon_dir = _daemon_module_dir()
|
||||
_must(daemon_dir.is_dir(), f"Missing daemon module directory: {daemon_dir}")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
["go", "run", "./cmd/cmuxd-remote", "serve", "--stdio"],
|
||||
cwd=str(daemon_dir),
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
try:
|
||||
hello = _rpc(proc, 1, "hello", {})
|
||||
_must(hello.get("ok") is True, f"hello should return ok=true: {hello}")
|
||||
capabilities = {str(item) for item in ((hello.get("result") or {}).get("capabilities") or [])}
|
||||
_must("session.basic" in capabilities, f"hello missing session.basic capability: {hello}")
|
||||
_must("session.resize.min" in capabilities, f"hello missing session.resize.min capability: {hello}")
|
||||
|
||||
open_resp = _rpc(proc, 2, "session.open", {"session_id": "sess-e2e"})
|
||||
_assert_effective(open_resp, 0, 0, "session.open")
|
||||
|
||||
attach_small = _rpc(
|
||||
proc,
|
||||
3,
|
||||
"session.attach",
|
||||
{"session_id": "sess-e2e", "attachment_id": "a-small", "cols": 90, "rows": 30},
|
||||
)
|
||||
_assert_effective(attach_small, 90, 30, "session.attach(a-small)")
|
||||
|
||||
attach_large = _rpc(
|
||||
proc,
|
||||
4,
|
||||
"session.attach",
|
||||
{"session_id": "sess-e2e", "attachment_id": "a-large", "cols": 140, "rows": 50},
|
||||
)
|
||||
_assert_effective(attach_large, 90, 30, "session.attach(a-large)")
|
||||
|
||||
resize_large = _rpc(
|
||||
proc,
|
||||
5,
|
||||
"session.resize",
|
||||
{"session_id": "sess-e2e", "attachment_id": "a-large", "cols": 200, "rows": 80},
|
||||
)
|
||||
_assert_effective(resize_large, 90, 30, "session.resize(a-large)")
|
||||
|
||||
detach_small = _rpc(
|
||||
proc,
|
||||
6,
|
||||
"session.detach",
|
||||
{"session_id": "sess-e2e", "attachment_id": "a-small"},
|
||||
)
|
||||
_assert_effective(detach_small, 200, 80, "session.detach(a-small)")
|
||||
|
||||
detach_large = _rpc(
|
||||
proc,
|
||||
7,
|
||||
"session.detach",
|
||||
{"session_id": "sess-e2e", "attachment_id": "a-large"},
|
||||
)
|
||||
_assert_effective(detach_large, 200, 80, "session.detach(a-large)")
|
||||
|
||||
reattach = _rpc(
|
||||
proc,
|
||||
8,
|
||||
"session.attach",
|
||||
{"session_id": "sess-e2e", "attachment_id": "a-reconnect", "cols": 110, "rows": 40},
|
||||
)
|
||||
_assert_effective(reattach, 110, 40, "session.attach(a-reconnect)")
|
||||
|
||||
status = _rpc(proc, 9, "session.status", {"session_id": "sess-e2e"})
|
||||
_assert_effective(status, 110, 40, "session.status")
|
||||
attachments = (status.get("result") or {}).get("attachments") or []
|
||||
_must(len(attachments) == 1, f"session.status should report one active attachment after reattach: {status}")
|
||||
|
||||
print("PASS: cmuxd-remote stdio session.resize coordinator enforces smallest-screen-wins semantics")
|
||||
return 0
|
||||
finally:
|
||||
try:
|
||||
if proc.stdin is not None:
|
||||
proc.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2.0)
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
258
tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py
Normal file
258
tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Docker integration: remote daemon bootstrap must not depend on login-shell startup files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1")
|
||||
DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
|
||||
if check and proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
|
||||
return proc
|
||||
|
||||
|
||||
def _docker_available() -> bool:
|
||||
if shutil.which("docker") is None:
|
||||
return False
|
||||
probe = _run(["docker", "info"], check=False)
|
||||
return probe.returncode == 0
|
||||
|
||||
|
||||
def _parse_host_port(docker_port_output: str) -> int:
|
||||
text = docker_port_output.strip()
|
||||
if not text:
|
||||
raise cmuxError("docker port output was empty")
|
||||
return int(text.split(":")[-1])
|
||||
|
||||
|
||||
def _shell_single_quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
return _run(
|
||||
[
|
||||
"ssh",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"ConnectTimeout=5",
|
||||
"-p",
|
||||
str(host_port),
|
||||
"-i",
|
||||
str(key_path),
|
||||
host,
|
||||
f"sh -lc {_shell_single_quote(script)}",
|
||||
],
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
probe = _ssh_run(host, host_port, key_path, "echo ready", check=False)
|
||||
if probe.returncode == 0 and "ready" in probe.stdout:
|
||||
return
|
||||
time.sleep(0.5)
|
||||
raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready")
|
||||
|
||||
|
||||
def _wait_for_remote_connected(client: cmux, workspace_id: str, timeout: float = 45.0) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
last_status: dict = {}
|
||||
while time.time() < deadline:
|
||||
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
||||
remote = last_status.get("remote") or {}
|
||||
daemon = remote.get("daemon") or {}
|
||||
proxy = remote.get("proxy") or {}
|
||||
if (
|
||||
str(remote.get("state") or "") == "connected"
|
||||
and str(daemon.get("state") or "") == "ready"
|
||||
and str(proxy.get("state") or "") == "ready"
|
||||
):
|
||||
return last_status
|
||||
time.sleep(0.5)
|
||||
raise cmuxError(f"Remote did not converge to connected/ready under slow login profile: {last_status}")
|
||||
|
||||
|
||||
def _heartbeat_count(status: dict) -> int:
|
||||
remote = status.get("remote") or {}
|
||||
heartbeat = remote.get("heartbeat") or {}
|
||||
raw = heartbeat.get("count")
|
||||
try:
|
||||
return int(raw or 0)
|
||||
except Exception: # noqa: BLE001
|
||||
return 0
|
||||
|
||||
|
||||
def _wait_for_heartbeat_advance(client: cmux, workspace_id: str, minimum_count: int, timeout: float = 20.0) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
last_status: dict = {}
|
||||
while time.time() < deadline:
|
||||
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
||||
if _heartbeat_count(last_status) >= minimum_count:
|
||||
return last_status
|
||||
time.sleep(0.5)
|
||||
raise cmuxError(
|
||||
f"Remote heartbeat did not advance to >= {minimum_count} within {timeout:.1f}s: {last_status}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not _docker_available():
|
||||
print("SKIP: docker is not available")
|
||||
return 0
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote"
|
||||
_must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}")
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-bootstrap-nonlogin-"))
|
||||
image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}"
|
||||
container_name = f"cmux-ssh-bootstrap-nonlogin-{secrets.token_hex(4)}"
|
||||
workspace_id = ""
|
||||
|
||||
try:
|
||||
key_path = temp_dir / "id_ed25519"
|
||||
_run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)])
|
||||
pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip()
|
||||
_must(bool(pubkey), "Generated SSH public key was empty")
|
||||
|
||||
_run(["docker", "build", "-t", image_tag, str(fixture_dir)])
|
||||
_run(
|
||||
[
|
||||
"docker",
|
||||
"run",
|
||||
"-d",
|
||||
"--rm",
|
||||
"--name",
|
||||
container_name,
|
||||
"-e",
|
||||
f"AUTHORIZED_KEY={pubkey}",
|
||||
"-p",
|
||||
f"{DOCKER_PUBLISH_ADDR}::22",
|
||||
image_tag,
|
||||
]
|
||||
)
|
||||
|
||||
port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout
|
||||
host_ssh_port = _parse_host_port(port_info)
|
||||
host = f"root@{DOCKER_SSH_HOST}"
|
||||
_wait_for_ssh(host, host_ssh_port, key_path)
|
||||
|
||||
# Regression fixture: a slow login profile that should not block non-interactive daemon bootstrap.
|
||||
_ssh_run(
|
||||
host,
|
||||
host_ssh_port,
|
||||
key_path,
|
||||
"""
|
||||
cat > "$HOME/.profile" <<'EOF'
|
||||
sleep 15
|
||||
echo profile-sourced >&2
|
||||
EOF
|
||||
chmod 0644 "$HOME/.profile"
|
||||
""",
|
||||
check=True,
|
||||
)
|
||||
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
created = client._call("workspace.create", {"initial_command": "echo ssh-bootstrap-nonlogin"})
|
||||
workspace_id = str((created or {}).get("workspace_id") or "")
|
||||
_must(bool(workspace_id), f"workspace.create did not return workspace_id: {created}")
|
||||
|
||||
configured = client._call(
|
||||
"workspace.remote.configure",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"destination": host,
|
||||
"port": host_ssh_port,
|
||||
"identity_file": str(key_path),
|
||||
"ssh_options": ["UserKnownHostsFile=/dev/null", "StrictHostKeyChecking=no"],
|
||||
"auto_connect": True,
|
||||
},
|
||||
)
|
||||
_must(bool(configured), "workspace.remote.configure returned empty response")
|
||||
|
||||
status = _wait_for_remote_connected(client, workspace_id, timeout=45.0)
|
||||
remote = status.get("remote") or {}
|
||||
detail = str(remote.get("detail") or "").lower()
|
||||
_must("timed out" not in detail, f"remote detail should not report bootstrap timeout: {status}")
|
||||
|
||||
baseline_heartbeat = _heartbeat_count(status)
|
||||
status = _wait_for_heartbeat_advance(
|
||||
client,
|
||||
workspace_id,
|
||||
minimum_count=max(1, baseline_heartbeat + 1),
|
||||
timeout=15.0,
|
||||
)
|
||||
|
||||
opened = client._call("browser.open_split", {"workspace_id": workspace_id}) or {}
|
||||
browser_surface_id = str(opened.get("surface_id") or "")
|
||||
_must(bool(browser_surface_id), f"browser.open_split returned no surface_id: {opened}")
|
||||
|
||||
after_open_heartbeat = _heartbeat_count(status)
|
||||
status_after_blank_tab = _wait_for_heartbeat_advance(
|
||||
client,
|
||||
workspace_id,
|
||||
minimum_count=after_open_heartbeat + 2,
|
||||
timeout=20.0,
|
||||
)
|
||||
remote_after_blank_tab = status_after_blank_tab.get("remote") or {}
|
||||
_must(
|
||||
str(remote_after_blank_tab.get("state") or "") == "connected",
|
||||
f"remote should remain connected after blank browser open: {status_after_blank_tab}",
|
||||
)
|
||||
heartbeat_payload = remote_after_blank_tab.get("heartbeat") or {}
|
||||
_must(
|
||||
heartbeat_payload.get("last_seen_at") is not None,
|
||||
f"remote heartbeat should expose last_seen_at after bootstrap: {status_after_blank_tab}",
|
||||
)
|
||||
|
||||
try:
|
||||
client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
workspace_id = ""
|
||||
|
||||
print("PASS: remote daemon bootstrap remains healthy even when ~/.profile is slow")
|
||||
return 0
|
||||
finally:
|
||||
if workspace_id:
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as cleanup_client:
|
||||
cleanup_client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
_run(["docker", "rm", "-f", container_name], check=False)
|
||||
_run(["docker", "rmi", "-f", image_tag], check=False)
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
742
tests_v2/test_ssh_remote_docker_forwarding.py
Normal file
742
tests_v2/test_ssh_remote_docker_forwarding.py
Normal file
|
|
@ -0,0 +1,742 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Docker integration: remote SSH proxy endpoint via `cmux ssh`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "43173"))
|
||||
REMOTE_WS_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_WS_PORT", "43174"))
|
||||
MAX_REMOTE_DAEMON_SIZE_BYTES = int(os.environ.get("CMUX_SSH_TEST_MAX_DAEMON_SIZE_BYTES", "15000000"))
|
||||
DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1")
|
||||
DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
|
||||
if check and proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
|
||||
return proc
|
||||
|
||||
|
||||
def _run_cli_json(cli: str, args: list[str]) -> dict:
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
env.pop("CMUX_TAB_ID", None)
|
||||
|
||||
proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env)
|
||||
try:
|
||||
return json.loads(proc.stdout or "{}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
|
||||
|
||||
|
||||
def _docker_available() -> bool:
|
||||
if shutil.which("docker") is None:
|
||||
return False
|
||||
probe = _run(["docker", "info"], check=False)
|
||||
return probe.returncode == 0
|
||||
|
||||
|
||||
def _parse_host_port(docker_port_output: str) -> int:
|
||||
# docker port output form: "127.0.0.1:49154\n" or ":::\d+".
|
||||
text = docker_port_output.strip()
|
||||
if not text:
|
||||
raise cmuxError("docker port output was empty")
|
||||
last = text.split(":")[-1]
|
||||
return int(last)
|
||||
|
||||
|
||||
def _curl_via_socks(proxy_port: int, target_url: str) -> str:
|
||||
if shutil.which("curl") is None:
|
||||
raise cmuxError("curl is required for SOCKS proxy verification")
|
||||
proc = _run(
|
||||
[
|
||||
"curl",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--max-time",
|
||||
"5",
|
||||
"--socks5-hostname",
|
||||
f"127.0.0.1:{proxy_port}",
|
||||
target_url,
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"curl via SOCKS proxy failed: {merged}")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _shell_single_quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _recv_exact(sock: socket.socket, n: int) -> bytes:
|
||||
out = bytearray()
|
||||
while len(out) < n:
|
||||
chunk = sock.recv(n - len(out))
|
||||
if not chunk:
|
||||
raise cmuxError("unexpected EOF while reading socket")
|
||||
out.extend(chunk)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _recv_until(sock: socket.socket, marker: bytes, limit: int = 16384) -> bytes:
|
||||
out = bytearray()
|
||||
while marker not in out:
|
||||
chunk = sock.recv(1024)
|
||||
if not chunk:
|
||||
raise cmuxError("unexpected EOF while reading response headers")
|
||||
out.extend(chunk)
|
||||
if len(out) > limit:
|
||||
raise cmuxError("response headers too large")
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _read_socks5_connect_reply(sock: socket.socket) -> None:
|
||||
head = _recv_exact(sock, 4)
|
||||
if len(head) != 4 or head[0] != 0x05:
|
||||
raise cmuxError(f"invalid SOCKS5 reply: {head!r}")
|
||||
if head[1] != 0x00:
|
||||
raise cmuxError(f"SOCKS5 connect failed with status=0x{head[1]:02x}")
|
||||
|
||||
atyp = head[3]
|
||||
if atyp == 0x01:
|
||||
_ = _recv_exact(sock, 4)
|
||||
elif atyp == 0x03:
|
||||
ln = _recv_exact(sock, 1)[0]
|
||||
_ = _recv_exact(sock, ln)
|
||||
elif atyp == 0x04:
|
||||
_ = _recv_exact(sock, 16)
|
||||
else:
|
||||
raise cmuxError(f"invalid SOCKS5 atyp in reply: 0x{atyp:02x}")
|
||||
_ = _recv_exact(sock, 2) # bound port
|
||||
|
||||
|
||||
def _read_http_response_from_connected_socket(sock: socket.socket) -> str:
|
||||
response = _recv_until(sock, b"\r\n\r\n")
|
||||
header_end = response.index(b"\r\n\r\n") + 4
|
||||
header_blob = response[:header_end]
|
||||
body = bytearray(response[header_end:])
|
||||
header_text = header_blob.decode("utf-8", errors="replace")
|
||||
|
||||
status_line = header_text.split("\r\n", 1)[0]
|
||||
if "200" not in status_line:
|
||||
raise cmuxError(f"HTTP over SOCKS tunnel failed: {status_line!r}")
|
||||
|
||||
content_length: int | None = None
|
||||
for line in header_text.split("\r\n")[1:]:
|
||||
if line.lower().startswith("content-length:"):
|
||||
try:
|
||||
content_length = int(line.split(":", 1)[1].strip())
|
||||
except Exception: # noqa: BLE001
|
||||
content_length = None
|
||||
break
|
||||
|
||||
if content_length is not None:
|
||||
while len(body) < content_length:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
body.extend(chunk)
|
||||
else:
|
||||
while True:
|
||||
try:
|
||||
chunk = sock.recv(4096)
|
||||
except socket.timeout:
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
body.extend(chunk)
|
||||
|
||||
return bytes(body).decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _http_get_on_connected_socket(sock: socket.socket, host: str, port: int, path: str = "/") -> str:
|
||||
request = (
|
||||
f"GET {path} HTTP/1.1\r\n"
|
||||
f"Host: {host}:{port}\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n"
|
||||
).encode("utf-8")
|
||||
sock.sendall(request)
|
||||
return _read_http_response_from_connected_socket(sock)
|
||||
|
||||
|
||||
def _socks5_connect(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket:
|
||||
sock = socket.create_connection((proxy_host, proxy_port), timeout=6)
|
||||
sock.settimeout(6)
|
||||
|
||||
# greeting: no-auth only
|
||||
sock.sendall(b"\x05\x01\x00")
|
||||
greeting = _recv_exact(sock, 2)
|
||||
if greeting != b"\x05\x00":
|
||||
sock.close()
|
||||
raise cmuxError(f"SOCKS5 greeting failed: {greeting!r}")
|
||||
|
||||
try:
|
||||
host_bytes = socket.inet_aton(target_host)
|
||||
atyp = b"\x01" # IPv4
|
||||
addr = host_bytes
|
||||
except OSError:
|
||||
host_encoded = target_host.encode("utf-8")
|
||||
if len(host_encoded) > 255:
|
||||
sock.close()
|
||||
raise cmuxError("target host too long for SOCKS5 domain form")
|
||||
atyp = b"\x03" # domain
|
||||
addr = bytes([len(host_encoded)]) + host_encoded
|
||||
|
||||
req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port)
|
||||
sock.sendall(req)
|
||||
|
||||
try:
|
||||
_read_socks5_connect_reply(sock)
|
||||
except Exception:
|
||||
sock.close()
|
||||
raise
|
||||
return sock
|
||||
|
||||
|
||||
def _socks5_http_get_pipelined(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str:
|
||||
sock = socket.create_connection((proxy_host, proxy_port), timeout=6)
|
||||
sock.settimeout(6)
|
||||
try:
|
||||
try:
|
||||
host_bytes = socket.inet_aton(target_host)
|
||||
atyp = b"\x01"
|
||||
addr = host_bytes
|
||||
except OSError:
|
||||
host_encoded = target_host.encode("utf-8")
|
||||
if len(host_encoded) > 255:
|
||||
raise cmuxError("target host too long for SOCKS5 domain form")
|
||||
atyp = b"\x03"
|
||||
addr = bytes([len(host_encoded)]) + host_encoded
|
||||
|
||||
greeting = b"\x05\x01\x00"
|
||||
connect_req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port)
|
||||
http_get = (
|
||||
"GET / HTTP/1.1\r\n"
|
||||
f"Host: {target_host}:{target_port}\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n"
|
||||
).encode("utf-8")
|
||||
|
||||
# Send greeting + CONNECT + first upstream payload in one write to exercise
|
||||
# SOCKS request parsing when pending bytes already exist in the handshake buffer.
|
||||
sock.sendall(greeting + connect_req + http_get)
|
||||
|
||||
greeting_reply = _recv_exact(sock, 2)
|
||||
if greeting_reply != b"\x05\x00":
|
||||
raise cmuxError(f"SOCKS5 greeting failed: {greeting_reply!r}")
|
||||
_read_socks5_connect_reply(sock)
|
||||
return _read_http_response_from_connected_socket(sock)
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _http_connect_tunnel(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket:
|
||||
sock = socket.create_connection((proxy_host, proxy_port), timeout=6)
|
||||
sock.settimeout(6)
|
||||
request = (
|
||||
f"CONNECT {target_host}:{target_port} HTTP/1.1\r\n"
|
||||
f"Host: {target_host}:{target_port}\r\n"
|
||||
"Proxy-Connection: Keep-Alive\r\n"
|
||||
"\r\n"
|
||||
).encode("utf-8")
|
||||
sock.sendall(request)
|
||||
header_blob = _recv_until(sock, b"\r\n\r\n")
|
||||
header_text = header_blob.decode("utf-8", errors="replace")
|
||||
status_line = header_text.split("\r\n", 1)[0]
|
||||
if "200" not in status_line:
|
||||
sock.close()
|
||||
raise cmuxError(f"HTTP CONNECT tunnel failed: {status_line!r}")
|
||||
return sock
|
||||
|
||||
|
||||
def _encode_client_text_frame(payload: str) -> bytes:
|
||||
data = payload.encode("utf-8")
|
||||
first = 0x81 # FIN + text
|
||||
mask = secrets.token_bytes(4)
|
||||
length = len(data)
|
||||
if length < 126:
|
||||
header = bytes([first, 0x80 | length])
|
||||
elif length <= 0xFFFF:
|
||||
header = bytes([first, 0x80 | 126]) + struct.pack("!H", length)
|
||||
else:
|
||||
header = bytes([first, 0x80 | 127]) + struct.pack("!Q", length)
|
||||
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
|
||||
return header + mask + masked
|
||||
|
||||
|
||||
def _read_server_text_frame(sock: socket.socket) -> str:
|
||||
first, second = _recv_exact(sock, 2)
|
||||
opcode = first & 0x0F
|
||||
masked = (second & 0x80) != 0
|
||||
length = second & 0x7F
|
||||
if length == 126:
|
||||
length = struct.unpack("!H", _recv_exact(sock, 2))[0]
|
||||
elif length == 127:
|
||||
length = struct.unpack("!Q", _recv_exact(sock, 8))[0]
|
||||
mask = _recv_exact(sock, 4) if masked else b""
|
||||
payload = _recv_exact(sock, length) if length else b""
|
||||
if masked and payload:
|
||||
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
||||
|
||||
if opcode != 0x1:
|
||||
raise cmuxError(f"Expected websocket text frame opcode=0x1, got opcode=0x{opcode:x}")
|
||||
try:
|
||||
return payload.decode("utf-8")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise cmuxError(f"WebSocket response payload is not valid UTF-8: {exc}")
|
||||
|
||||
|
||||
def _websocket_echo_on_connected_socket(sock: socket.socket, ws_host: str, ws_port: int, message: str, path_label: str) -> str:
|
||||
ws_key = b64encode(secrets.token_bytes(16)).decode("ascii")
|
||||
request = (
|
||||
"GET /echo HTTP/1.1\r\n"
|
||||
f"Host: {ws_host}:{ws_port}\r\n"
|
||||
"Upgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {ws_key}\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n"
|
||||
"\r\n"
|
||||
).encode("utf-8")
|
||||
sock.sendall(request)
|
||||
header_blob = _recv_until(sock, b"\r\n\r\n")
|
||||
header_text = header_blob.decode("utf-8", errors="replace")
|
||||
status_line = header_text.split("\r\n", 1)[0]
|
||||
if "101" not in status_line:
|
||||
raise cmuxError(f"WebSocket handshake failed over {path_label}: {status_line!r}")
|
||||
|
||||
expected_accept = b64encode(
|
||||
hashlib.sha1((ws_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("utf-8")).digest()
|
||||
).decode("ascii")
|
||||
lowered_headers = {
|
||||
line.split(":", 1)[0].strip().lower(): line.split(":", 1)[1].strip()
|
||||
for line in header_text.split("\r\n")[1:]
|
||||
if ":" in line
|
||||
}
|
||||
if lowered_headers.get("sec-websocket-accept", "") != expected_accept:
|
||||
raise cmuxError(f"WebSocket handshake over {path_label} returned invalid Sec-WebSocket-Accept")
|
||||
|
||||
sock.sendall(_encode_client_text_frame(message))
|
||||
return _read_server_text_frame(sock)
|
||||
|
||||
|
||||
def _websocket_echo_via_socks(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str:
|
||||
sock = _socks5_connect("127.0.0.1", proxy_port, ws_host, ws_port)
|
||||
try:
|
||||
return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "SOCKS proxy")
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _websocket_echo_via_connect(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str:
|
||||
sock = _http_connect_tunnel("127.0.0.1", proxy_port, ws_host, ws_port)
|
||||
try:
|
||||
return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "HTTP CONNECT proxy")
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
return _run(
|
||||
[
|
||||
"ssh",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"ConnectTimeout=5",
|
||||
"-p",
|
||||
str(host_port),
|
||||
"-i",
|
||||
str(key_path),
|
||||
host,
|
||||
f"sh -lc {_shell_single_quote(script)}",
|
||||
],
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
probe = _ssh_run(host, host_port, key_path, "echo ready", check=False)
|
||||
if probe.returncode == 0 and "ready" in probe.stdout:
|
||||
return
|
||||
time.sleep(0.5)
|
||||
raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready")
|
||||
|
||||
|
||||
def _remote_binary_size_bytes(host: str, host_port: int, key_path: Path, remote_path: str) -> int:
|
||||
script = f"""
|
||||
set -eu
|
||||
p={_shell_single_quote(remote_path)}
|
||||
case "$p" in
|
||||
/*) full="$p" ;;
|
||||
*) full="$HOME/$p" ;;
|
||||
esac
|
||||
test -x "$full"
|
||||
wc -c < "$full"
|
||||
"""
|
||||
proc = _ssh_run(host, host_port, key_path, script, check=True)
|
||||
text = proc.stdout.strip().splitlines()[-1].strip()
|
||||
return int(text)
|
||||
|
||||
|
||||
def _extract_daemon_version_platform(remote_path: str) -> tuple[str, str]:
|
||||
parts = [segment for segment in remote_path.strip().split("/") if segment]
|
||||
try:
|
||||
marker_index = parts.index("cmuxd-remote")
|
||||
except ValueError as exc:
|
||||
raise cmuxError(f"remote daemon path missing cmuxd-remote marker: {remote_path!r}") from exc
|
||||
|
||||
required_len = marker_index + 4
|
||||
_must(
|
||||
len(parts) >= required_len,
|
||||
f"remote daemon path should include version/platform/binary: {remote_path!r}",
|
||||
)
|
||||
version = parts[marker_index + 1]
|
||||
platform = parts[marker_index + 2]
|
||||
binary_name = parts[marker_index + 3]
|
||||
_must(binary_name == "cmuxd-remote", f"unexpected daemon binary name in remote path: {remote_path!r}")
|
||||
_must(bool(version), f"daemon version should not be empty in remote path: {remote_path!r}")
|
||||
_must(bool(platform), f"daemon platform should not be empty in remote path: {remote_path!r}")
|
||||
return version, platform
|
||||
|
||||
|
||||
def _local_cached_daemon_binary(version: str, platform: str) -> Path:
|
||||
return Path(tempfile.gettempdir()) / "cmux-remote-daemon-build" / version / platform / "cmuxd-remote"
|
||||
|
||||
|
||||
def _local_file_sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _local_binary_contains_version_marker(path: Path, version: str) -> bool:
|
||||
marker = version.encode("utf-8")
|
||||
tail = b""
|
||||
with path.open("rb") as handle:
|
||||
while True:
|
||||
chunk = handle.read(1024 * 1024)
|
||||
if not chunk:
|
||||
return False
|
||||
haystack = tail + chunk
|
||||
if marker in haystack:
|
||||
return True
|
||||
tail = haystack[-max(len(marker) - 1, 0) :]
|
||||
|
||||
|
||||
def _remote_binary_sha256(host: str, host_port: int, key_path: Path, remote_path: str) -> str:
|
||||
script = f"""
|
||||
set -eu
|
||||
p={_shell_single_quote(remote_path)}
|
||||
case "$p" in
|
||||
/*) full="$p" ;;
|
||||
*) full="$HOME/$p" ;;
|
||||
esac
|
||||
test -x "$full"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$full" | awk '{{print $1}}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$full" | awk '{{print $1}}'
|
||||
else
|
||||
openssl dgst -sha256 "$full" | awk '{{print $NF}}'
|
||||
fi
|
||||
"""
|
||||
proc = _ssh_run(host, host_port, key_path, script, check=True)
|
||||
digest = proc.stdout.strip().splitlines()[-1].strip().lower()
|
||||
_must(len(digest) == 64 and all(ch in "0123456789abcdef" for ch in digest), f"invalid remote SHA256 digest: {digest!r}")
|
||||
return digest
|
||||
|
||||
|
||||
def _wait_connected_proxy_port(client: cmux, workspace_id: str, timeout: float = 45.0) -> tuple[dict, int]:
|
||||
deadline = time.time() + timeout
|
||||
last_status = {}
|
||||
proxy_port: int | None = None
|
||||
while time.time() < deadline:
|
||||
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
||||
remote = last_status.get("remote") or {}
|
||||
state = str(remote.get("state") or "")
|
||||
proxy = remote.get("proxy") or {}
|
||||
port_value = proxy.get("port")
|
||||
if isinstance(port_value, int):
|
||||
proxy_port = port_value
|
||||
elif isinstance(port_value, str) and port_value.isdigit():
|
||||
proxy_port = int(port_value)
|
||||
if state == "connected" and proxy_port is not None:
|
||||
return last_status, proxy_port
|
||||
time.sleep(0.5)
|
||||
raise cmuxError(f"Remote proxy did not converge to connected state: {last_status}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not _docker_available():
|
||||
print("SKIP: docker is not available")
|
||||
return 0
|
||||
|
||||
cli = _find_cli_binary()
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote"
|
||||
_must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}")
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-docker-"))
|
||||
image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}"
|
||||
container_name = f"cmux-ssh-test-{secrets.token_hex(4)}"
|
||||
workspace_id = ""
|
||||
workspace_id_shared = ""
|
||||
|
||||
try:
|
||||
key_path = temp_dir / "id_ed25519"
|
||||
_run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)])
|
||||
pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip()
|
||||
_must(bool(pubkey), "Generated SSH public key was empty")
|
||||
|
||||
_run(["docker", "build", "-t", image_tag, str(fixture_dir)])
|
||||
_run([
|
||||
"docker", "run", "-d", "--rm",
|
||||
"--name", container_name,
|
||||
"-e", f"AUTHORIZED_KEY={pubkey}",
|
||||
"-e", f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}",
|
||||
"-p", f"{DOCKER_PUBLISH_ADDR}::22",
|
||||
image_tag,
|
||||
])
|
||||
|
||||
port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout
|
||||
host_ssh_port = _parse_host_port(port_info)
|
||||
host = f"root@{DOCKER_SSH_HOST}"
|
||||
_wait_for_ssh(host, host_ssh_port, key_path)
|
||||
|
||||
fresh_check = _ssh_run(
|
||||
host,
|
||||
host_ssh_port,
|
||||
key_path,
|
||||
"test ! -e \"$HOME/.cmux/bin/cmuxd-remote\" && echo fresh",
|
||||
check=True,
|
||||
)
|
||||
_must("fresh" in fresh_check.stdout, "Fresh container should not have preinstalled cmuxd-remote")
|
||||
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
payload = _run_cli_json(
|
||||
cli,
|
||||
[
|
||||
"ssh",
|
||||
host,
|
||||
"--name", "docker-ssh-forward",
|
||||
"--port", str(host_ssh_port),
|
||||
"--identity", str(key_path),
|
||||
"--ssh-option", "UserKnownHostsFile=/dev/null",
|
||||
"--ssh-option", "StrictHostKeyChecking=no",
|
||||
],
|
||||
)
|
||||
workspace_id = str(payload.get("workspace_id") or "")
|
||||
workspace_ref = str(payload.get("workspace_ref") or "")
|
||||
if not workspace_id and workspace_ref.startswith("workspace:"):
|
||||
listed = client._call("workspace.list", {}) or {}
|
||||
for row in listed.get("workspaces") or []:
|
||||
if str(row.get("ref") or "") == workspace_ref:
|
||||
workspace_id = str(row.get("id") or "")
|
||||
break
|
||||
_must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}")
|
||||
|
||||
last_status, proxy_port = _wait_connected_proxy_port(client, workspace_id)
|
||||
|
||||
daemon = ((last_status.get("remote") or {}).get("daemon") or {})
|
||||
_must(str(daemon.get("state") or "") == "ready", f"daemon should be ready in connected state: {last_status}")
|
||||
capabilities = daemon.get("capabilities") or []
|
||||
_must("proxy.stream" in capabilities, f"daemon hello capabilities missing proxy.stream: {daemon}")
|
||||
_must("proxy.socks5" in capabilities, f"daemon hello capabilities missing proxy.socks5: {daemon}")
|
||||
_must("session.basic" in capabilities, f"daemon hello capabilities missing session.basic: {daemon}")
|
||||
_must("session.resize.min" in capabilities, f"daemon hello capabilities missing session.resize.min: {daemon}")
|
||||
remote_path = str(daemon.get("remote_path") or "").strip()
|
||||
_must(bool(remote_path), f"daemon ready state should include remote_path: {daemon}")
|
||||
|
||||
binary_size_bytes = _remote_binary_size_bytes(host, host_ssh_port, key_path, remote_path)
|
||||
_must(binary_size_bytes > 0, f"uploaded daemon binary should be non-empty: {binary_size_bytes}")
|
||||
_must(
|
||||
binary_size_bytes <= MAX_REMOTE_DAEMON_SIZE_BYTES,
|
||||
f"uploaded daemon binary too large: {binary_size_bytes} bytes > {MAX_REMOTE_DAEMON_SIZE_BYTES}",
|
||||
)
|
||||
daemon_version, daemon_platform = _extract_daemon_version_platform(remote_path)
|
||||
local_cached_binary = _local_cached_daemon_binary(daemon_version, daemon_platform)
|
||||
_must(
|
||||
local_cached_binary.is_file(),
|
||||
f"expected local daemon cache artifact at {local_cached_binary} after bootstrap upload",
|
||||
)
|
||||
_must(
|
||||
os.access(local_cached_binary, os.X_OK),
|
||||
f"local daemon cache artifact must be executable: {local_cached_binary}",
|
||||
)
|
||||
_must(
|
||||
_local_binary_contains_version_marker(local_cached_binary, daemon_version),
|
||||
f"local cached daemon binary should embed daemon version marker {daemon_version!r}: {local_cached_binary}",
|
||||
)
|
||||
local_sha256 = _local_file_sha256(local_cached_binary)
|
||||
remote_sha256 = _remote_binary_sha256(host, host_ssh_port, key_path, remote_path)
|
||||
_must(
|
||||
local_sha256 == remote_sha256,
|
||||
"uploaded daemon binary hash should match local cached build artifact "
|
||||
f"(local={local_sha256}, remote={remote_sha256})",
|
||||
)
|
||||
|
||||
body = ""
|
||||
deadline_http = time.time() + 15.0
|
||||
while time.time() < deadline_http:
|
||||
try:
|
||||
body = _curl_via_socks(proxy_port, f"http://127.0.0.1:{REMOTE_HTTP_PORT}/")
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
if "cmux-ssh-forward-ok" in body:
|
||||
break
|
||||
time.sleep(0.3)
|
||||
|
||||
_must("cmux-ssh-forward-ok" in body, f"Forwarded HTTP endpoint returned unexpected body: {body[:120]!r}")
|
||||
pipelined_body = _socks5_http_get_pipelined("127.0.0.1", proxy_port, "127.0.0.1", REMOTE_HTTP_PORT)
|
||||
_must(
|
||||
"cmux-ssh-forward-ok" in pipelined_body,
|
||||
f"SOCKS pipelined greeting/connect+payload path returned unexpected body: {pipelined_body[:120]!r}",
|
||||
)
|
||||
|
||||
ws_message = "cmux-ws-over-socks-ok"
|
||||
echoed_message = _websocket_echo_via_socks(proxy_port, "127.0.0.1", REMOTE_WS_PORT, ws_message)
|
||||
_must(
|
||||
echoed_message == ws_message,
|
||||
f"WebSocket echo over SOCKS proxy mismatch: {echoed_message!r} != {ws_message!r}",
|
||||
)
|
||||
|
||||
ws_connect_message = "cmux-ws-over-connect-ok"
|
||||
echoed_connect = _websocket_echo_via_connect(proxy_port, "127.0.0.1", REMOTE_WS_PORT, ws_connect_message)
|
||||
_must(
|
||||
echoed_connect == ws_connect_message,
|
||||
f"WebSocket echo over CONNECT proxy mismatch: {echoed_connect!r} != {ws_connect_message!r}",
|
||||
)
|
||||
|
||||
payload_shared = _run_cli_json(
|
||||
cli,
|
||||
[
|
||||
"ssh",
|
||||
host,
|
||||
"--name", "docker-ssh-forward-shared",
|
||||
"--port", str(host_ssh_port),
|
||||
"--identity", str(key_path),
|
||||
"--ssh-option", "UserKnownHostsFile=/dev/null",
|
||||
"--ssh-option", "StrictHostKeyChecking=no",
|
||||
],
|
||||
)
|
||||
workspace_id_shared = str(payload_shared.get("workspace_id") or "")
|
||||
workspace_ref_shared = str(payload_shared.get("workspace_ref") or "")
|
||||
if not workspace_id_shared and workspace_ref_shared.startswith("workspace:"):
|
||||
listed_shared = client._call("workspace.list", {}) or {}
|
||||
for row in listed_shared.get("workspaces") or []:
|
||||
if str(row.get("ref") or "") == workspace_ref_shared:
|
||||
workspace_id_shared = str(row.get("id") or "")
|
||||
break
|
||||
_must(bool(workspace_id_shared), f"cmux ssh output missing workspace_id for shared transport test: {payload_shared}")
|
||||
|
||||
_, shared_proxy_port = _wait_connected_proxy_port(client, workspace_id_shared)
|
||||
_must(
|
||||
shared_proxy_port == proxy_port,
|
||||
f"identical SSH transports should share one local proxy endpoint: {proxy_port} vs {shared_proxy_port}",
|
||||
)
|
||||
|
||||
try:
|
||||
client.close_workspace(workspace_id_shared)
|
||||
workspace_id_shared = ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
client.close_workspace(workspace_id)
|
||||
workspace_id = ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(
|
||||
"PASS: docker SSH proxy endpoint is reachable, handles HTTP + WebSocket egress over SOCKS and CONNECT through remote host, and is shared across identical transports; "
|
||||
f"uploaded cmuxd-remote size={binary_size_bytes} bytes, version={daemon_version}, platform={daemon_platform}"
|
||||
)
|
||||
return 0
|
||||
|
||||
finally:
|
||||
if workspace_id:
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as cleanup_client:
|
||||
cleanup_client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if workspace_id_shared:
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as cleanup_client:
|
||||
cleanup_client.close_workspace(workspace_id_shared)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_run(["docker", "rm", "-f", container_name], check=False)
|
||||
_run(["docker", "rmi", "-f", image_tag], check=False)
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
612
tests_v2/test_ssh_remote_docker_reconnect.py
Normal file
612
tests_v2/test_ssh_remote_docker_reconnect.py
Normal file
|
|
@ -0,0 +1,612 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Docker integration: remote SSH reconnect after host restart."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from base64 import b64encode
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "43173"))
|
||||
REMOTE_WS_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_WS_PORT", "43174"))
|
||||
DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1")
|
||||
DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
|
||||
if check and proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
|
||||
return proc
|
||||
|
||||
|
||||
def _run_cli_json(cli: str, args: list[str]) -> dict:
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
env.pop("CMUX_TAB_ID", None)
|
||||
proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env)
|
||||
try:
|
||||
return json.loads(proc.stdout or "{}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
|
||||
|
||||
|
||||
def _docker_available() -> bool:
|
||||
if shutil.which("docker") is None:
|
||||
return False
|
||||
probe = _run(["docker", "info"], check=False)
|
||||
return probe.returncode == 0
|
||||
|
||||
|
||||
def _curl_via_socks(proxy_port: int, target_url: str) -> str:
|
||||
if shutil.which("curl") is None:
|
||||
raise cmuxError("curl is required for SOCKS proxy verification")
|
||||
proc = _run(
|
||||
[
|
||||
"curl",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--max-time",
|
||||
"5",
|
||||
"--socks5-hostname",
|
||||
f"127.0.0.1:{proxy_port}",
|
||||
target_url,
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"curl via SOCKS proxy failed: {merged}")
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _find_free_loopback_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _recv_exact(sock: socket.socket, n: int) -> bytes:
|
||||
out = bytearray()
|
||||
while len(out) < n:
|
||||
chunk = sock.recv(n - len(out))
|
||||
if not chunk:
|
||||
raise cmuxError("unexpected EOF while reading socket")
|
||||
out.extend(chunk)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _recv_until(sock: socket.socket, marker: bytes, limit: int = 16384) -> bytes:
|
||||
out = bytearray()
|
||||
while marker not in out:
|
||||
chunk = sock.recv(1024)
|
||||
if not chunk:
|
||||
raise cmuxError("unexpected EOF while reading response headers")
|
||||
out.extend(chunk)
|
||||
if len(out) > limit:
|
||||
raise cmuxError("response headers too large")
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _read_socks5_connect_reply(sock: socket.socket) -> None:
|
||||
head = _recv_exact(sock, 4)
|
||||
if len(head) != 4 or head[0] != 0x05:
|
||||
raise cmuxError(f"invalid SOCKS5 reply: {head!r}")
|
||||
if head[1] != 0x00:
|
||||
raise cmuxError(f"SOCKS5 connect failed with status=0x{head[1]:02x}")
|
||||
|
||||
reply_atyp = head[3]
|
||||
if reply_atyp == 0x01:
|
||||
_ = _recv_exact(sock, 4)
|
||||
elif reply_atyp == 0x03:
|
||||
ln = _recv_exact(sock, 1)[0]
|
||||
_ = _recv_exact(sock, ln)
|
||||
elif reply_atyp == 0x04:
|
||||
_ = _recv_exact(sock, 16)
|
||||
else:
|
||||
raise cmuxError(f"invalid SOCKS5 atyp in reply: 0x{reply_atyp:02x}")
|
||||
_ = _recv_exact(sock, 2)
|
||||
|
||||
|
||||
def _read_http_response_from_connected_socket(sock: socket.socket) -> str:
|
||||
response = _recv_until(sock, b"\r\n\r\n")
|
||||
header_end = response.index(b"\r\n\r\n") + 4
|
||||
header_blob = response[:header_end]
|
||||
body = bytearray(response[header_end:])
|
||||
header_text = header_blob.decode("utf-8", errors="replace")
|
||||
|
||||
status_line = header_text.split("\r\n", 1)[0]
|
||||
if "200" not in status_line:
|
||||
raise cmuxError(f"HTTP over SOCKS tunnel failed: {status_line!r}")
|
||||
|
||||
content_length: int | None = None
|
||||
for line in header_text.split("\r\n")[1:]:
|
||||
if line.lower().startswith("content-length:"):
|
||||
try:
|
||||
content_length = int(line.split(":", 1)[1].strip())
|
||||
except Exception: # noqa: BLE001
|
||||
content_length = None
|
||||
break
|
||||
|
||||
if content_length is not None:
|
||||
while len(body) < content_length:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
body.extend(chunk)
|
||||
else:
|
||||
while True:
|
||||
try:
|
||||
chunk = sock.recv(4096)
|
||||
except socket.timeout:
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
body.extend(chunk)
|
||||
|
||||
return bytes(body).decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _socks5_connect(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket:
|
||||
sock = socket.create_connection((proxy_host, proxy_port), timeout=6)
|
||||
sock.settimeout(6)
|
||||
|
||||
sock.sendall(b"\x05\x01\x00")
|
||||
greeting = _recv_exact(sock, 2)
|
||||
if greeting != b"\x05\x00":
|
||||
sock.close()
|
||||
raise cmuxError(f"SOCKS5 greeting failed: {greeting!r}")
|
||||
|
||||
try:
|
||||
host_bytes = socket.inet_aton(target_host)
|
||||
atyp = b"\x01"
|
||||
addr = host_bytes
|
||||
except OSError:
|
||||
host_encoded = target_host.encode("utf-8")
|
||||
if len(host_encoded) > 255:
|
||||
sock.close()
|
||||
raise cmuxError("target host too long for SOCKS5 domain form")
|
||||
atyp = b"\x03"
|
||||
addr = bytes([len(host_encoded)]) + host_encoded
|
||||
|
||||
req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port)
|
||||
sock.sendall(req)
|
||||
|
||||
try:
|
||||
_read_socks5_connect_reply(sock)
|
||||
except Exception:
|
||||
sock.close()
|
||||
raise
|
||||
return sock
|
||||
|
||||
|
||||
def _socks5_http_get_pipelined(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str:
|
||||
sock = socket.create_connection((proxy_host, proxy_port), timeout=6)
|
||||
sock.settimeout(6)
|
||||
try:
|
||||
try:
|
||||
host_bytes = socket.inet_aton(target_host)
|
||||
atyp = b"\x01"
|
||||
addr = host_bytes
|
||||
except OSError:
|
||||
host_encoded = target_host.encode("utf-8")
|
||||
if len(host_encoded) > 255:
|
||||
raise cmuxError("target host too long for SOCKS5 domain form")
|
||||
atyp = b"\x03"
|
||||
addr = bytes([len(host_encoded)]) + host_encoded
|
||||
|
||||
greeting = b"\x05\x01\x00"
|
||||
connect_req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port)
|
||||
http_get = (
|
||||
"GET / HTTP/1.1\r\n"
|
||||
f"Host: {target_host}:{target_port}\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n"
|
||||
).encode("utf-8")
|
||||
|
||||
sock.sendall(greeting + connect_req + http_get)
|
||||
|
||||
greeting_reply = _recv_exact(sock, 2)
|
||||
if greeting_reply != b"\x05\x00":
|
||||
raise cmuxError(f"SOCKS5 greeting failed: {greeting_reply!r}")
|
||||
_read_socks5_connect_reply(sock)
|
||||
return _read_http_response_from_connected_socket(sock)
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _http_connect_tunnel(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket:
|
||||
sock = socket.create_connection((proxy_host, proxy_port), timeout=6)
|
||||
sock.settimeout(6)
|
||||
request = (
|
||||
f"CONNECT {target_host}:{target_port} HTTP/1.1\r\n"
|
||||
f"Host: {target_host}:{target_port}\r\n"
|
||||
"Proxy-Connection: Keep-Alive\r\n"
|
||||
"\r\n"
|
||||
).encode("utf-8")
|
||||
sock.sendall(request)
|
||||
header_blob = _recv_until(sock, b"\r\n\r\n")
|
||||
header_text = header_blob.decode("utf-8", errors="replace")
|
||||
status_line = header_text.split("\r\n", 1)[0]
|
||||
if "200" not in status_line:
|
||||
sock.close()
|
||||
raise cmuxError(f"HTTP CONNECT tunnel failed: {status_line!r}")
|
||||
return sock
|
||||
|
||||
|
||||
def _encode_client_text_frame(payload: str) -> bytes:
|
||||
data = payload.encode("utf-8")
|
||||
first = 0x81
|
||||
mask = secrets.token_bytes(4)
|
||||
length = len(data)
|
||||
if length < 126:
|
||||
header = bytes([first, 0x80 | length])
|
||||
elif length <= 0xFFFF:
|
||||
header = bytes([first, 0x80 | 126]) + struct.pack("!H", length)
|
||||
else:
|
||||
header = bytes([first, 0x80 | 127]) + struct.pack("!Q", length)
|
||||
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
|
||||
return header + mask + masked
|
||||
|
||||
|
||||
def _read_server_text_frame(sock: socket.socket) -> str:
|
||||
first, second = _recv_exact(sock, 2)
|
||||
opcode = first & 0x0F
|
||||
masked = (second & 0x80) != 0
|
||||
length = second & 0x7F
|
||||
if length == 126:
|
||||
length = struct.unpack("!H", _recv_exact(sock, 2))[0]
|
||||
elif length == 127:
|
||||
length = struct.unpack("!Q", _recv_exact(sock, 8))[0]
|
||||
mask = _recv_exact(sock, 4) if masked else b""
|
||||
payload = _recv_exact(sock, length) if length else b""
|
||||
if masked and payload:
|
||||
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
||||
|
||||
if opcode != 0x1:
|
||||
raise cmuxError(f"Expected websocket text frame opcode=0x1, got opcode=0x{opcode:x}")
|
||||
try:
|
||||
return payload.decode("utf-8")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise cmuxError(f"WebSocket response payload is not valid UTF-8: {exc}")
|
||||
|
||||
|
||||
def _websocket_echo_on_connected_socket(sock: socket.socket, ws_host: str, ws_port: int, message: str, path_label: str) -> str:
|
||||
ws_key = b64encode(secrets.token_bytes(16)).decode("ascii")
|
||||
request = (
|
||||
"GET /echo HTTP/1.1\r\n"
|
||||
f"Host: {ws_host}:{ws_port}\r\n"
|
||||
"Upgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {ws_key}\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n"
|
||||
"\r\n"
|
||||
).encode("utf-8")
|
||||
sock.sendall(request)
|
||||
header_blob = _recv_until(sock, b"\r\n\r\n")
|
||||
header_text = header_blob.decode("utf-8", errors="replace")
|
||||
status_line = header_text.split("\r\n", 1)[0]
|
||||
if "101" not in status_line:
|
||||
raise cmuxError(f"WebSocket handshake failed over {path_label}: {status_line!r}")
|
||||
|
||||
expected_accept = b64encode(
|
||||
hashlib.sha1((ws_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("utf-8")).digest()
|
||||
).decode("ascii")
|
||||
lowered_headers = {
|
||||
line.split(":", 1)[0].strip().lower(): line.split(":", 1)[1].strip()
|
||||
for line in header_text.split("\r\n")[1:]
|
||||
if ":" in line
|
||||
}
|
||||
if lowered_headers.get("sec-websocket-accept", "") != expected_accept:
|
||||
raise cmuxError(f"WebSocket handshake over {path_label} returned invalid Sec-WebSocket-Accept")
|
||||
|
||||
sock.sendall(_encode_client_text_frame(message))
|
||||
return _read_server_text_frame(sock)
|
||||
|
||||
|
||||
def _websocket_echo_via_socks(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str:
|
||||
sock = _socks5_connect("127.0.0.1", proxy_port, ws_host, ws_port)
|
||||
try:
|
||||
return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "SOCKS proxy")
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _websocket_echo_via_connect(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str:
|
||||
sock = _http_connect_tunnel("127.0.0.1", proxy_port, ws_host, ws_port)
|
||||
try:
|
||||
return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "HTTP CONNECT proxy")
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _start_container(image_tag: str, container_name: str, pubkey: str, host_ssh_port: int) -> None:
|
||||
for _ in range(20):
|
||||
proc = _run(
|
||||
[
|
||||
"docker",
|
||||
"run",
|
||||
"-d",
|
||||
"--rm",
|
||||
"--name",
|
||||
container_name,
|
||||
"-e",
|
||||
f"AUTHORIZED_KEY={pubkey}",
|
||||
"-e",
|
||||
f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}",
|
||||
"-e",
|
||||
f"REMOTE_WS_PORT={REMOTE_WS_PORT}",
|
||||
"-p",
|
||||
f"{DOCKER_PUBLISH_ADDR}:{host_ssh_port}:22",
|
||||
image_tag,
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return
|
||||
time.sleep(0.5)
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"Failed to start ssh test container on fixed port {host_ssh_port}: {merged}")
|
||||
|
||||
|
||||
def _wait_remote_connected(client: cmux, workspace_id: str, timeout: float) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
last_status = {}
|
||||
while time.time() < deadline:
|
||||
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
||||
remote = last_status.get("remote") or {}
|
||||
proxy = remote.get("proxy") or {}
|
||||
port_value = proxy.get("port")
|
||||
proxy_port: int | None
|
||||
if isinstance(port_value, int):
|
||||
proxy_port = port_value
|
||||
elif isinstance(port_value, str) and port_value.isdigit():
|
||||
proxy_port = int(port_value)
|
||||
else:
|
||||
proxy_port = None
|
||||
if str(remote.get("state") or "") == "connected" and proxy_port is not None:
|
||||
return last_status
|
||||
time.sleep(0.5)
|
||||
raise cmuxError(f"Remote did not reach connected+proxy-ready state: {last_status}")
|
||||
|
||||
|
||||
def _wait_remote_degraded(client: cmux, workspace_id: str, timeout: float) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
last_status = {}
|
||||
while time.time() < deadline:
|
||||
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
||||
remote = last_status.get("remote") or {}
|
||||
state = str(remote.get("state") or "")
|
||||
if state in {"error", "connecting", "disconnected"}:
|
||||
return last_status
|
||||
time.sleep(0.5)
|
||||
raise cmuxError(f"Remote did not enter reconnecting/degraded state: {last_status}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not _docker_available():
|
||||
print("SKIP: docker is not available")
|
||||
return 0
|
||||
|
||||
cli = _find_cli_binary()
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote"
|
||||
_must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}")
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-reconnect-"))
|
||||
image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}"
|
||||
container_name = f"cmux-ssh-reconnect-{secrets.token_hex(4)}"
|
||||
host_ssh_port = _find_free_loopback_port()
|
||||
workspace_id = ""
|
||||
container_running = False
|
||||
|
||||
try:
|
||||
key_path = temp_dir / "id_ed25519"
|
||||
_run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)])
|
||||
pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip()
|
||||
_must(bool(pubkey), "Generated SSH public key was empty")
|
||||
|
||||
_run(["docker", "build", "-t", image_tag, str(fixture_dir)])
|
||||
_start_container(image_tag, container_name, pubkey, host_ssh_port)
|
||||
container_running = True
|
||||
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
payload = _run_cli_json(
|
||||
cli,
|
||||
[
|
||||
"ssh",
|
||||
f"root@{DOCKER_SSH_HOST}",
|
||||
"--name",
|
||||
"docker-ssh-reconnect",
|
||||
"--port",
|
||||
str(host_ssh_port),
|
||||
"--identity",
|
||||
str(key_path),
|
||||
"--ssh-option",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
"--ssh-option",
|
||||
"StrictHostKeyChecking=no",
|
||||
],
|
||||
)
|
||||
workspace_id = str(payload.get("workspace_id") or "")
|
||||
workspace_ref = str(payload.get("workspace_ref") or "")
|
||||
if not workspace_id and workspace_ref.startswith("workspace:"):
|
||||
listed = client._call("workspace.list", {}) or {}
|
||||
for row in listed.get("workspaces") or []:
|
||||
if str(row.get("ref") or "") == workspace_ref:
|
||||
workspace_id = str(row.get("id") or "")
|
||||
break
|
||||
_must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}")
|
||||
|
||||
first_status = _wait_remote_connected(client, workspace_id, timeout=45.0)
|
||||
first_daemon = ((first_status.get("remote") or {}).get("daemon") or {})
|
||||
_must(str(first_daemon.get("state") or "") == "ready", f"daemon should be ready after first connect: {first_status}")
|
||||
first_capabilities = {str(item) for item in (first_daemon.get("capabilities") or [])}
|
||||
_must("proxy.stream" in first_capabilities, f"daemon should advertise proxy.stream: {first_status}")
|
||||
_must("proxy.socks5" in first_capabilities, f"daemon should advertise proxy.socks5: {first_status}")
|
||||
_must("proxy.http_connect" in first_capabilities, f"daemon should advertise proxy.http_connect: {first_status}")
|
||||
first_proxy = ((first_status.get("remote") or {}).get("proxy") or {})
|
||||
first_proxy_port = first_proxy.get("port")
|
||||
if isinstance(first_proxy_port, str) and first_proxy_port.isdigit():
|
||||
first_proxy_port = int(first_proxy_port)
|
||||
_must(isinstance(first_proxy_port, int), f"connected status should include proxy port: {first_status}")
|
||||
|
||||
first_body = ""
|
||||
first_deadline_http = time.time() + 15.0
|
||||
while time.time() < first_deadline_http:
|
||||
try:
|
||||
first_body = _curl_via_socks(int(first_proxy_port), f"http://127.0.0.1:{REMOTE_HTTP_PORT}/")
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
if "cmux-ssh-forward-ok" in first_body:
|
||||
break
|
||||
time.sleep(0.3)
|
||||
_must("cmux-ssh-forward-ok" in first_body, f"Forwarded HTTP endpoint failed before reconnect: {first_body[:120]!r}")
|
||||
first_pipelined_body = _socks5_http_get_pipelined("127.0.0.1", int(first_proxy_port), "127.0.0.1", REMOTE_HTTP_PORT)
|
||||
_must(
|
||||
"cmux-ssh-forward-ok" in first_pipelined_body,
|
||||
f"SOCKS pipelined greeting/connect+payload failed before reconnect: {first_pipelined_body[:120]!r}",
|
||||
)
|
||||
|
||||
first_ws_socks_message = "cmux-reconnect-before-over-socks"
|
||||
echoed_before_socks = _websocket_echo_via_socks(int(first_proxy_port), "127.0.0.1", REMOTE_WS_PORT, first_ws_socks_message)
|
||||
_must(
|
||||
echoed_before_socks == first_ws_socks_message,
|
||||
f"WebSocket echo over SOCKS proxy failed before reconnect: {echoed_before_socks!r} != {first_ws_socks_message!r}",
|
||||
)
|
||||
|
||||
first_ws_connect_message = "cmux-reconnect-before-over-connect"
|
||||
echoed_before_connect = _websocket_echo_via_connect(int(first_proxy_port), "127.0.0.1", REMOTE_WS_PORT, first_ws_connect_message)
|
||||
_must(
|
||||
echoed_before_connect == first_ws_connect_message,
|
||||
f"WebSocket echo over CONNECT proxy failed before reconnect: {echoed_before_connect!r} != {first_ws_connect_message!r}",
|
||||
)
|
||||
|
||||
_run(["docker", "rm", "-f", container_name], check=False)
|
||||
container_running = False
|
||||
_wait_remote_degraded(client, workspace_id, timeout=20.0)
|
||||
|
||||
_start_container(image_tag, container_name, pubkey, host_ssh_port)
|
||||
container_running = True
|
||||
|
||||
second_status = _wait_remote_connected(client, workspace_id, timeout=60.0)
|
||||
second_daemon = ((second_status.get("remote") or {}).get("daemon") or {})
|
||||
_must(str(second_daemon.get("state") or "") == "ready", f"daemon should be ready after reconnect: {second_status}")
|
||||
second_capabilities = {str(item) for item in (second_daemon.get("capabilities") or [])}
|
||||
_must("proxy.stream" in second_capabilities, f"daemon should advertise proxy.stream after reconnect: {second_status}")
|
||||
_must("proxy.socks5" in second_capabilities, f"daemon should advertise proxy.socks5 after reconnect: {second_status}")
|
||||
_must("proxy.http_connect" in second_capabilities, f"daemon should advertise proxy.http_connect after reconnect: {second_status}")
|
||||
second_proxy = ((second_status.get("remote") or {}).get("proxy") or {})
|
||||
second_proxy_port = second_proxy.get("port")
|
||||
if isinstance(second_proxy_port, str) and second_proxy_port.isdigit():
|
||||
second_proxy_port = int(second_proxy_port)
|
||||
_must(isinstance(second_proxy_port, int), f"reconnected status should include proxy port: {second_status}")
|
||||
|
||||
second_body = ""
|
||||
deadline_http = time.time() + 15.0
|
||||
while time.time() < deadline_http:
|
||||
try:
|
||||
second_body = _curl_via_socks(int(second_proxy_port), f"http://127.0.0.1:{REMOTE_HTTP_PORT}/")
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
if "cmux-ssh-forward-ok" in second_body:
|
||||
break
|
||||
time.sleep(0.3)
|
||||
_must("cmux-ssh-forward-ok" in second_body, f"Forwarded HTTP endpoint failed after reconnect: {second_body[:120]!r}")
|
||||
second_pipelined_body = _socks5_http_get_pipelined("127.0.0.1", int(second_proxy_port), "127.0.0.1", REMOTE_HTTP_PORT)
|
||||
_must(
|
||||
"cmux-ssh-forward-ok" in second_pipelined_body,
|
||||
f"SOCKS pipelined greeting/connect+payload failed after reconnect: {second_pipelined_body[:120]!r}",
|
||||
)
|
||||
|
||||
second_ws_socks_message = "cmux-reconnect-after-over-socks"
|
||||
echoed_after_socks = _websocket_echo_via_socks(int(second_proxy_port), "127.0.0.1", REMOTE_WS_PORT, second_ws_socks_message)
|
||||
_must(
|
||||
echoed_after_socks == second_ws_socks_message,
|
||||
f"WebSocket echo over SOCKS proxy failed after reconnect: {echoed_after_socks!r} != {second_ws_socks_message!r}",
|
||||
)
|
||||
|
||||
second_ws_connect_message = "cmux-reconnect-after-over-connect"
|
||||
echoed_after_connect = _websocket_echo_via_connect(int(second_proxy_port), "127.0.0.1", REMOTE_WS_PORT, second_ws_connect_message)
|
||||
_must(
|
||||
echoed_after_connect == second_ws_connect_message,
|
||||
f"WebSocket echo over CONNECT proxy failed after reconnect: {echoed_after_connect!r} != {second_ws_connect_message!r}",
|
||||
)
|
||||
|
||||
try:
|
||||
client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
workspace_id = ""
|
||||
|
||||
print("PASS: docker SSH remote reconnects and re-establishes HTTP + WebSocket egress over SOCKS and CONNECT")
|
||||
return 0
|
||||
|
||||
finally:
|
||||
if workspace_id:
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as cleanup_client:
|
||||
cleanup_client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if container_running:
|
||||
_run(["docker", "rm", "-f", container_name], check=False)
|
||||
_run(["docker", "rmi", "-f", image_tag], check=False)
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
249
tests_v2/test_ssh_remote_interactive_cmux_command_regression.py
Normal file
249
tests_v2/test_ssh_remote_interactive_cmux_command_regression.py
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: interactive `cmux ssh` shells must resolve `cmux` to the relay wrapper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip()
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli_json(cli: str, args: list[str]) -> dict:
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
env.pop("CMUX_TAB_ID", None)
|
||||
|
||||
import subprocess
|
||||
|
||||
proc = subprocess.run(
|
||||
[cli, "--socket", SOCKET_PATH, "--json", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise cmuxError(f"CLI failed ({' '.join(args)}): {(proc.stdout + proc.stderr).strip()}")
|
||||
try:
|
||||
return json.loads(proc.stdout or "{}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
|
||||
|
||||
|
||||
def _workspace_id_from_payload(client: cmux, payload: dict) -> str:
|
||||
workspace_id = str(payload.get("workspace_id") or "")
|
||||
if workspace_id:
|
||||
return workspace_id
|
||||
workspace_ref = str(payload.get("workspace_ref") or "")
|
||||
if workspace_ref.startswith("workspace:"):
|
||||
rows = (client._call("workspace.list", {}) or {}).get("workspaces") or []
|
||||
for row in rows:
|
||||
if str(row.get("ref") or "") == workspace_ref:
|
||||
return str(row.get("id") or "")
|
||||
return ""
|
||||
|
||||
|
||||
def _wait_remote_ready(client: cmux, workspace_id: str, timeout: float = 25.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
last_status = {}
|
||||
while time.time() < deadline:
|
||||
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
||||
remote = last_status.get("remote") or {}
|
||||
daemon = remote.get("daemon") or {}
|
||||
if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready":
|
||||
return
|
||||
time.sleep(0.25)
|
||||
raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}")
|
||||
|
||||
|
||||
def _wait_surface_id(client: cmux, workspace_id: str, timeout: float = 10.0) -> str:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
surfaces = client.list_surfaces(workspace_id)
|
||||
if surfaces:
|
||||
return str(surfaces[0][1])
|
||||
time.sleep(0.1)
|
||||
raise cmuxError(f"No terminal surface appeared for workspace {workspace_id}")
|
||||
|
||||
|
||||
def _wait_text(client: cmux, surface_id: str, token: str, timeout: float = 12.0) -> str:
|
||||
deadline = time.time() + timeout
|
||||
last = ""
|
||||
while time.time() < deadline:
|
||||
last = client.read_terminal_text(surface_id)
|
||||
if token in last:
|
||||
return last
|
||||
time.sleep(0.15)
|
||||
raise cmuxError(f"Timed out waiting for {token!r} in surface {surface_id}: {last[-1200:]!r}")
|
||||
|
||||
|
||||
def _wait_shell_ready(client: cmux, surface_id: str, timeout: float = 20.0) -> None:
|
||||
token = f"__CMUX_SHELL_READY_{secrets.token_hex(6)}__"
|
||||
client.send_surface(surface_id, f"printf '{token}'; echo")
|
||||
client.send_key_surface(surface_id, "enter")
|
||||
_wait_text(client, surface_id, token, timeout=timeout)
|
||||
|
||||
|
||||
def _run_remote_shell_command(client: cmux, surface_id: str, command: str, timeout: float = 12.0) -> tuple[int, str, str]:
|
||||
token = f"__CMUX_REMOTE_CMD_{secrets.token_hex(6)}__"
|
||||
start_marker = f"{token}:START"
|
||||
status_marker = f"{token}:STATUS"
|
||||
end_marker = f"{token}:END"
|
||||
client.send_surface(
|
||||
surface_id,
|
||||
(
|
||||
f"printf '{start_marker}'; echo; "
|
||||
f"{command}; "
|
||||
"__cmux_status=$?; "
|
||||
f"printf '{status_marker}:%s' \"$__cmux_status\"; echo; "
|
||||
f"printf '{end_marker}'; echo"
|
||||
),
|
||||
)
|
||||
client.send_key_surface(surface_id, "enter")
|
||||
deadline = time.time() + timeout
|
||||
text = ""
|
||||
while time.time() < deadline:
|
||||
text = client.read_terminal_text(surface_id)
|
||||
if (
|
||||
text.count(start_marker) >= 2
|
||||
and text.count(status_marker) >= 2
|
||||
and text.count(end_marker) >= 2
|
||||
):
|
||||
break
|
||||
time.sleep(0.15)
|
||||
pattern = re.compile(
|
||||
re.escape(start_marker) + r"\n(.*?)" + re.escape(status_marker) + r":(\d+)\n" + re.escape(end_marker),
|
||||
re.S,
|
||||
)
|
||||
matches = pattern.findall(text)
|
||||
if not matches:
|
||||
raise cmuxError(f"Missing command result token for {command!r}: {text[-1200:]!r}")
|
||||
output, status_raw = matches[-1]
|
||||
return int(status_raw), output, text
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not SSH_HOST:
|
||||
print("SKIP: set CMUX_SSH_TEST_HOST to run interactive ssh cmux command regression")
|
||||
return 0
|
||||
|
||||
cli = _find_cli_binary()
|
||||
workspace_ids: list[str] = []
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
payload = _run_cli_json(cli, ["ssh", SSH_HOST])
|
||||
workspace_id = _workspace_id_from_payload(client, payload)
|
||||
_must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}")
|
||||
workspace_ids.append(workspace_id)
|
||||
|
||||
_wait_remote_ready(client, workspace_id)
|
||||
surface_id = _wait_surface_id(client, workspace_id)
|
||||
_wait_shell_ready(client, surface_id)
|
||||
|
||||
which_status, which_output, which_text = _run_remote_shell_command(client, surface_id, "command -v cmux")
|
||||
_must(which_status == 0, f"`command -v cmux` failed: output={which_output!r} tail={which_text[-1200:]!r}")
|
||||
_must(
|
||||
"/.cmux/bin/cmux" in which_output,
|
||||
f"interactive ssh shell should resolve cmux to relay wrapper, got {which_output!r}",
|
||||
)
|
||||
|
||||
ping_status, ping_output, ping_text = _run_remote_shell_command(client, surface_id, "cmux ping")
|
||||
_must(ping_status == 0, f"`cmux ping` failed in interactive shell: output={ping_output!r} tail={ping_text[-1200:]!r}")
|
||||
_must("pong" in ping_output.lower(), f"`cmux ping` should return pong, got {ping_output!r}")
|
||||
_must(
|
||||
"Socket not found at 127.0.0.1:" not in ping_text,
|
||||
f"interactive ssh shell still routed cmux to a unix-socket-only binary: {ping_text[-1200:]!r}",
|
||||
)
|
||||
_must(
|
||||
"waiting for relay on 127.0.0.1:" not in ping_text and "failed to connect to 127.0.0.1:" not in ping_text,
|
||||
f"`cmux ping` hit a dead ssh relay instead of the local app socket: {ping_text[-1200:]!r}",
|
||||
)
|
||||
|
||||
notify_status, notify_output, notify_text = _run_remote_shell_command(
|
||||
client,
|
||||
surface_id,
|
||||
"cmux notify --body interactive-ssh-regression",
|
||||
)
|
||||
_must(
|
||||
notify_status == 0,
|
||||
f"`cmux notify` failed in interactive shell: output={notify_output!r} tail={notify_text[-1200:]!r}",
|
||||
)
|
||||
_must(
|
||||
"Socket not found at 127.0.0.1:" not in notify_text,
|
||||
f"`cmux notify` still failed via wrong cmux binary: {notify_text[-1200:]!r}",
|
||||
)
|
||||
_must(
|
||||
"waiting for relay on 127.0.0.1:" not in notify_text and "failed to connect to 127.0.0.1:" not in notify_text,
|
||||
f"`cmux notify` still failed because the ssh relay listener was not running: {notify_text[-1200:]!r}",
|
||||
)
|
||||
|
||||
shell_status, shell_output, shell_text = _run_remote_shell_command(
|
||||
client,
|
||||
surface_id,
|
||||
r'''printf 'TERM=%s\n' "${TERM:-}"; printf 'TERM_PROGRAM=%s\n' "${TERM_PROGRAM:-}"; printf 'TERM_PROGRAM_VERSION=%s\n' "${TERM_PROGRAM_VERSION:-}"; printf 'GHOSTTY_SHELL_FEATURES=%s\n' "${GHOSTTY_SHELL_FEATURES:-}"; bindkey "^A"; bindkey "^K"; bindkey "^[^?"; bindkey "^[b"; bindkey "^[f"''',
|
||||
)
|
||||
_must(shell_status == 0, f"ssh shell env/bindkey probe failed: output={shell_output!r} tail={shell_text[-1200:]!r}")
|
||||
_must("TERM=xterm-ghostty" in shell_output, f"ssh shell lost TERM=xterm-ghostty: {shell_output!r}")
|
||||
_must("TERM_PROGRAM=ghostty" in shell_output, f"ssh shell lost TERM_PROGRAM=ghostty: {shell_output!r}")
|
||||
_must("GHOSTTY_SHELL_FEATURES=" in shell_output, f"ssh shell lost GHOSTTY_SHELL_FEATURES: {shell_output!r}")
|
||||
_must("ssh-env" in shell_output, f"ssh shell missing ssh-env feature: {shell_output!r}")
|
||||
_must("ssh-terminfo" in shell_output, f"ssh shell missing ssh-terminfo feature: {shell_output!r}")
|
||||
_must('"^A" beginning-of-line' in shell_output, f"Ctrl-A binding regressed in ssh shell: {shell_output!r}")
|
||||
_must('"^K" kill-line' in shell_output, f"Ctrl-K binding regressed in ssh shell: {shell_output!r}")
|
||||
_must('"^[^?" backward-kill-word' in shell_output, f"Opt-Backspace binding regressed in ssh shell: {shell_output!r}")
|
||||
_must('"^[b" backward-word' in shell_output, f"Opt-Left binding regressed in ssh shell: {shell_output!r}")
|
||||
_must('"^[f" forward-word' in shell_output, f"Opt-Right binding regressed in ssh shell: {shell_output!r}")
|
||||
finally:
|
||||
if workspace_ids:
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
for workspace_id in workspace_ids:
|
||||
try:
|
||||
client._call("workspace.close", {"workspace_id": workspace_id})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("PASS: interactive ssh shell resolves cmux to relay wrapper and remote cmux commands succeed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
259
tests_v2/test_ssh_remote_last_surface_clears_remote_state.py
Normal file
259
tests_v2/test_ssh_remote_last_surface_clears_remote_state.py
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: closing the last SSH surface should clear remote workspace state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip()
|
||||
SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip()
|
||||
SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip()
|
||||
SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip()
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
|
||||
if check and proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
|
||||
return proc
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli_json(cli: str, args: list[str]) -> dict:
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
env.pop("CMUX_TAB_ID", None)
|
||||
|
||||
proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env)
|
||||
try:
|
||||
return json.loads(proc.stdout or "{}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
|
||||
|
||||
|
||||
def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if pred():
|
||||
return
|
||||
time.sleep(step_s)
|
||||
raise cmuxError("Timed out waiting for condition")
|
||||
|
||||
|
||||
def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 45.0) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
last_status = {}
|
||||
while time.time() < deadline:
|
||||
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
||||
remote = last_status.get("remote") or {}
|
||||
daemon = remote.get("daemon") or {}
|
||||
if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready":
|
||||
return
|
||||
time.sleep(0.25)
|
||||
raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}")
|
||||
|
||||
|
||||
def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str:
|
||||
workspace_id = str(payload.get("workspace_id") or "")
|
||||
if workspace_id:
|
||||
return workspace_id
|
||||
|
||||
workspace_ref = str(payload.get("workspace_ref") or "")
|
||||
if workspace_ref.startswith("workspace:"):
|
||||
listed = client._call("workspace.list", {}) or {}
|
||||
for row in listed.get("workspaces") or []:
|
||||
if str(row.get("ref") or "") == workspace_ref:
|
||||
resolved = str(row.get("id") or "")
|
||||
if resolved:
|
||||
return resolved
|
||||
|
||||
current = {wid for _index, wid, _title, _focused in client.list_workspaces()}
|
||||
new_ids = sorted(current - before_workspace_ids)
|
||||
if len(new_ids) == 1:
|
||||
return new_ids[0]
|
||||
|
||||
raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}")
|
||||
|
||||
|
||||
def _workspace_row(client: cmux, workspace_id: str) -> dict:
|
||||
rows = (client._call("workspace.list", {}) or {}).get("workspaces") or []
|
||||
for row in rows:
|
||||
if str(row.get("id") or "") == workspace_id:
|
||||
return row
|
||||
raise cmuxError(f"workspace.list missing {workspace_id}: {rows}")
|
||||
|
||||
|
||||
def _remote_session_count(client: cmux, workspace_id: str) -> int:
|
||||
row = _workspace_row(client, workspace_id)
|
||||
remote = row.get("remote") or {}
|
||||
return int(remote.get("active_terminal_sessions") or 0)
|
||||
|
||||
|
||||
def _run_surface_probe(client: cmux, surface_id: str, command: str, token_prefix: str, timeout_s: float = 12.0) -> str:
|
||||
token = f"__CMUX_{token_prefix}_{int(time.time() * 1000)}__"
|
||||
client.send_surface(
|
||||
surface_id,
|
||||
(
|
||||
f"printf '{token}:START'; echo; "
|
||||
f"{command}; "
|
||||
f"printf '{token}:END'; echo"
|
||||
),
|
||||
)
|
||||
client.send_key_surface(surface_id, "enter")
|
||||
deadline = time.time() + timeout_s
|
||||
last = ""
|
||||
pattern = re.compile(re.escape(token) + r":START\n(.*?)" + re.escape(token) + r":END", re.S)
|
||||
while time.time() < deadline:
|
||||
last = client.read_terminal_text(surface_id)
|
||||
matches = pattern.findall(last)
|
||||
if matches:
|
||||
return matches[-1]
|
||||
time.sleep(0.15)
|
||||
raise cmuxError(f"Timed out waiting for probe {token!r}: {last[-1200:]!r}")
|
||||
|
||||
|
||||
def _open_ssh_workspace(client: cmux, cli: str, *, name: str) -> str:
|
||||
before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()}
|
||||
|
||||
ssh_args = ["ssh", SSH_HOST, "--name", name]
|
||||
if SSH_PORT:
|
||||
ssh_args.extend(["--port", SSH_PORT])
|
||||
if SSH_IDENTITY:
|
||||
ssh_args.extend(["--identity", SSH_IDENTITY])
|
||||
if SSH_OPTIONS_RAW:
|
||||
for option in SSH_OPTIONS_RAW.split(","):
|
||||
trimmed = option.strip()
|
||||
if trimmed:
|
||||
ssh_args.extend(["--ssh-option", trimmed])
|
||||
|
||||
payload = _run_cli_json(cli, ssh_args)
|
||||
workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids)
|
||||
_wait_remote_ready(client, workspace_id)
|
||||
client.select_workspace(workspace_id)
|
||||
_wait_for(lambda: client.current_workspace() == workspace_id, timeout_s=8.0)
|
||||
return workspace_id
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not SSH_HOST:
|
||||
print("SKIP: set CMUX_SSH_TEST_HOST to run ssh last-surface remote state regression")
|
||||
return 0
|
||||
|
||||
cli = _find_cli_binary()
|
||||
workspace_id = ""
|
||||
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
workspace_id = _open_ssh_workspace(
|
||||
client,
|
||||
cli,
|
||||
name=f"ssh-last-surface-{int(time.time())}",
|
||||
)
|
||||
|
||||
row = _workspace_row(client, workspace_id)
|
||||
remote = row.get("remote") or {}
|
||||
_must(bool(remote.get("enabled")) is True, f"workspace should start as remote-enabled: {row}")
|
||||
_must(int(remote.get("active_terminal_sessions") or 0) == 1, f"workspace should start with one active ssh terminal session: {row}")
|
||||
|
||||
surfaces = client.list_surfaces(workspace_id)
|
||||
_must(len(surfaces) == 1, f"expected one initial ssh surface, got {surfaces}")
|
||||
|
||||
split_surface_id = client.new_split("right")
|
||||
_wait_for(lambda: len(client.list_surfaces(workspace_id)) == 2, timeout_s=10.0, step_s=0.1)
|
||||
_wait_for(lambda: _remote_session_count(client, workspace_id) == 2, timeout_s=10.0, step_s=0.1)
|
||||
|
||||
client.send_surface(split_surface_id, "exit")
|
||||
client.send_key_surface(split_surface_id, "enter")
|
||||
_wait_for(lambda: _remote_session_count(client, workspace_id) == 1, timeout_s=15.0, step_s=0.15)
|
||||
|
||||
row_after_first_exit = _workspace_row(client, workspace_id)
|
||||
remote_after_first_exit = row_after_first_exit.get("remote") or {}
|
||||
_must(bool(remote_after_first_exit.get("enabled")) is True, f"workspace should stay remote while one ssh terminal remains: {row_after_first_exit}")
|
||||
|
||||
remaining_surface_id = next(
|
||||
surface_id
|
||||
for _index, surface_id, _focused in client.list_surfaces(workspace_id)
|
||||
if surface_id != split_surface_id
|
||||
)
|
||||
client.send_surface(remaining_surface_id, "exit")
|
||||
client.send_key_surface(remaining_surface_id, "enter")
|
||||
|
||||
def _remote_cleared() -> bool:
|
||||
row_now = _workspace_row(client, workspace_id)
|
||||
remote_now = row_now.get("remote") or {}
|
||||
if bool(remote_now.get("enabled")):
|
||||
return False
|
||||
surfaces_now = client.list_surfaces(workspace_id)
|
||||
return len(surfaces_now) == 2
|
||||
|
||||
_wait_for(_remote_cleared, timeout_s=15.0, step_s=0.15)
|
||||
|
||||
final_row = _workspace_row(client, workspace_id)
|
||||
final_remote = final_row.get("remote") or {}
|
||||
_must(bool(final_remote.get("enabled")) is False, f"workspace remote metadata should clear after last ssh surface closes: {final_row}")
|
||||
_must(str(final_remote.get("state") or "") == "disconnected", f"workspace should end disconnected after remote metadata clears: {final_row}")
|
||||
_must(int(final_remote.get("active_terminal_sessions") or 0) == 0, f"workspace should report zero active ssh terminal sessions after last ssh surface closes: {final_row}")
|
||||
|
||||
local_surface_ids = [surface_id for _index, surface_id, _focused in client.list_surfaces(workspace_id)]
|
||||
_must(len(local_surface_ids) == 2, f"expected both panes to remain as local terminals after ssh exits, got {local_surface_ids}")
|
||||
for idx, surface_id in enumerate(local_surface_ids):
|
||||
socket_output = _run_surface_probe(
|
||||
client,
|
||||
surface_id,
|
||||
r'''printf '%s' "${CMUX_SOCKET_PATH:-}"''',
|
||||
f"SSH_LAST_SURFACE_SOCKET_{idx}",
|
||||
).strip()
|
||||
_must(
|
||||
not socket_output.startswith("127.0.0.1:"),
|
||||
f"surface {surface_id} should be local after clearing remote state, got CMUX_SOCKET_PATH={socket_output!r}",
|
||||
)
|
||||
finally:
|
||||
if workspace_id:
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as cleanup_client:
|
||||
cleanup_client._call("workspace.close", {"workspace_id": workspace_id})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("PASS: exiting all ssh panes clears remote workspace state while fallback local panes remain local")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
246
tests_v2/test_ssh_remote_proxy_bind_conflict.py
Normal file
246
tests_v2/test_ssh_remote_proxy_bind_conflict.py
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Docker integration: local proxy bind conflict surfaces proxy_unavailable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1")
|
||||
DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
|
||||
if check and proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
|
||||
return proc
|
||||
|
||||
|
||||
def _docker_available() -> bool:
|
||||
if shutil.which("docker") is None:
|
||||
return False
|
||||
probe = _run(["docker", "info"], check=False)
|
||||
return probe.returncode == 0
|
||||
|
||||
|
||||
def _parse_host_port(docker_port_output: str) -> int:
|
||||
text = docker_port_output.strip()
|
||||
if not text:
|
||||
raise cmuxError("docker port output was empty")
|
||||
last = text.split(":")[-1]
|
||||
return int(last)
|
||||
|
||||
|
||||
def _shell_single_quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
return _run(
|
||||
[
|
||||
"ssh",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"ConnectTimeout=5",
|
||||
"-p",
|
||||
str(host_port),
|
||||
"-i",
|
||||
str(key_path),
|
||||
host,
|
||||
f"sh -lc {_shell_single_quote(script)}",
|
||||
],
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
probe = _ssh_run(host, host_port, key_path, "echo ready", check=False)
|
||||
if probe.returncode == 0 and "ready" in probe.stdout:
|
||||
return
|
||||
time.sleep(0.5)
|
||||
raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready")
|
||||
|
||||
|
||||
def _find_free_loopback_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _wait_for_proxy_conflict_status(client: cmux, workspace_id: str, expected_local_proxy_port: int, timeout: float = 30.0) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
last_status = {}
|
||||
while time.time() < deadline:
|
||||
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
||||
remote = last_status.get("remote") or {}
|
||||
proxy = remote.get("proxy") or {}
|
||||
daemon = remote.get("daemon") or {}
|
||||
if str(remote.get("state") or "") == "error" and str(proxy.get("state") or "") == "error":
|
||||
detail = str(remote.get("detail") or "")
|
||||
_must(
|
||||
proxy.get("error_code") == "proxy_unavailable",
|
||||
f"proxy error should be proxy_unavailable under bind conflict: {last_status}",
|
||||
)
|
||||
_must(
|
||||
int(remote.get("local_proxy_port") or 0) == expected_local_proxy_port,
|
||||
f"remote status should retain configured local_proxy_port under bind conflict: {last_status}",
|
||||
)
|
||||
_must(
|
||||
(
|
||||
"Failed to start local daemon proxy" in detail
|
||||
or "Local proxy listener failed" in detail
|
||||
),
|
||||
f"remote detail should surface local proxy bind failure: {last_status}",
|
||||
)
|
||||
_must(
|
||||
"Address already in use" in detail,
|
||||
f"remote detail should preserve bind-conflict root cause: {last_status}",
|
||||
)
|
||||
_must(
|
||||
str(daemon.get("state") or "") == "ready",
|
||||
f"daemon should remain ready for local-only bind conflicts: {last_status}",
|
||||
)
|
||||
return last_status
|
||||
time.sleep(0.5)
|
||||
|
||||
raise cmuxError(f"Remote did not reach structured proxy_unavailable status for bind conflict: {last_status}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not _docker_available():
|
||||
print("SKIP: docker is not available")
|
||||
return 0
|
||||
|
||||
_ = _find_cli_binary() # enforce same test prerequisites as other SSH remote suites
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote"
|
||||
_must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}")
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-proxy-conflict-"))
|
||||
image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}"
|
||||
container_name = f"cmux-ssh-proxy-conflict-{secrets.token_hex(4)}"
|
||||
workspace_id = ""
|
||||
conflict_listener: socket.socket | None = None
|
||||
|
||||
try:
|
||||
key_path = temp_dir / "id_ed25519"
|
||||
_run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)])
|
||||
pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip()
|
||||
_must(bool(pubkey), "Generated SSH public key was empty")
|
||||
|
||||
_run(["docker", "build", "-t", image_tag, str(fixture_dir)])
|
||||
_run([
|
||||
"docker", "run", "-d", "--rm",
|
||||
"--name", container_name,
|
||||
"-e", f"AUTHORIZED_KEY={pubkey}",
|
||||
"-p", f"{DOCKER_PUBLISH_ADDR}::22",
|
||||
image_tag,
|
||||
])
|
||||
|
||||
port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout
|
||||
host_ssh_port = _parse_host_port(port_info)
|
||||
host = f"root@{DOCKER_SSH_HOST}"
|
||||
_wait_for_ssh(host, host_ssh_port, key_path)
|
||||
|
||||
conflict_port = _find_free_loopback_port()
|
||||
conflict_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
conflict_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
conflict_listener.bind(("127.0.0.1", conflict_port))
|
||||
conflict_listener.listen(1)
|
||||
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
created = client._call("workspace.create", {"initial_command": "echo ssh-proxy-conflict"})
|
||||
workspace_id = str((created or {}).get("workspace_id") or "")
|
||||
_must(bool(workspace_id), f"workspace.create did not return workspace_id: {created}")
|
||||
|
||||
configured = client._call("workspace.remote.configure", {
|
||||
"workspace_id": workspace_id,
|
||||
"destination": host,
|
||||
"port": host_ssh_port,
|
||||
"identity_file": str(key_path),
|
||||
"ssh_options": ["UserKnownHostsFile=/dev/null", "StrictHostKeyChecking=no"],
|
||||
"auto_connect": True,
|
||||
"local_proxy_port": conflict_port,
|
||||
})
|
||||
_must(bool(configured), "workspace.remote.configure returned empty response")
|
||||
|
||||
_ = _wait_for_proxy_conflict_status(
|
||||
client,
|
||||
workspace_id,
|
||||
expected_local_proxy_port=conflict_port,
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
try:
|
||||
client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
workspace_id = ""
|
||||
|
||||
print("PASS: local proxy bind conflict surfaces structured proxy_unavailable without degrading daemon readiness")
|
||||
return 0
|
||||
|
||||
finally:
|
||||
if conflict_listener is not None:
|
||||
try:
|
||||
conflict_listener.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if workspace_id:
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as cleanup_client:
|
||||
cleanup_client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_run(["docker", "rm", "-f", container_name], check=False)
|
||||
_run(["docker", "rmi", "-f", image_tag], check=False)
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
357
tests_v2/test_ssh_remote_resize_scrollback_regression.py
Normal file
357
tests_v2/test_ssh_remote_resize_scrollback_regression.py
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: ssh workspace keeps large pre-resize scrollback across split resize churn."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux.sock")
|
||||
SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip()
|
||||
SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip()
|
||||
SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip()
|
||||
SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip()
|
||||
LS_ENTRY_COUNT = int(os.environ.get("CMUX_SSH_TEST_LS_COUNT", "320"))
|
||||
RESIZE_ITERATIONS = int(os.environ.get("CMUX_SSH_TEST_RESIZE_ITERATIONS", "48"))
|
||||
|
||||
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||
OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
|
||||
if check and proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
|
||||
return proc
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli_json(cli: str, args: list[str]) -> dict:
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
env.pop("CMUX_TAB_ID", None)
|
||||
|
||||
proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env)
|
||||
try:
|
||||
return json.loads(proc.stdout or "{}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
|
||||
|
||||
|
||||
def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if pred():
|
||||
return
|
||||
time.sleep(step_s)
|
||||
raise cmuxError("Timed out waiting for condition")
|
||||
|
||||
|
||||
def _wait_remote_connected(client: cmux, workspace_id: str, timeout_s: float = 45.0) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
last = {}
|
||||
while time.time() < deadline:
|
||||
last = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
||||
remote = last.get("remote") or {}
|
||||
daemon = remote.get("daemon") or {}
|
||||
if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready":
|
||||
return
|
||||
time.sleep(0.25)
|
||||
raise cmuxError(f"Remote did not reach connected+ready state: {last}")
|
||||
|
||||
|
||||
def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str:
|
||||
workspace_id = str(payload.get("workspace_id") or "")
|
||||
if workspace_id:
|
||||
return workspace_id
|
||||
|
||||
workspace_ref = str(payload.get("workspace_ref") or "")
|
||||
if workspace_ref.startswith("workspace:"):
|
||||
listed = client._call("workspace.list", {}) or {}
|
||||
for row in listed.get("workspaces") or []:
|
||||
if str(row.get("ref") or "") == workspace_ref:
|
||||
resolved = str(row.get("id") or "")
|
||||
if resolved:
|
||||
return resolved
|
||||
|
||||
current = {wid for _index, wid, _title, _focused in client.list_workspaces()}
|
||||
new_ids = sorted(current - before_workspace_ids)
|
||||
if len(new_ids) == 1:
|
||||
return new_ids[0]
|
||||
|
||||
raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}")
|
||||
|
||||
|
||||
def _clean_line(raw: str) -> str:
|
||||
line = OSC_ESCAPE_RE.sub("", raw)
|
||||
line = ANSI_ESCAPE_RE.sub("", line)
|
||||
line = line.replace("\r", "")
|
||||
return line.strip()
|
||||
|
||||
|
||||
def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str:
|
||||
payload = client._call(
|
||||
"surface.read_text",
|
||||
{"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True},
|
||||
) or {}
|
||||
return str(payload.get("text") or "")
|
||||
|
||||
|
||||
def _surface_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]:
|
||||
return [_clean_line(raw) for raw in _surface_scrollback_text(client, workspace_id, surface_id).splitlines()]
|
||||
|
||||
|
||||
def _wait_surface_contains(
|
||||
client: cmux,
|
||||
workspace_id: str,
|
||||
surface_id: str,
|
||||
token: str,
|
||||
*,
|
||||
exact_line: bool = False,
|
||||
timeout_s: float = 25.0,
|
||||
) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if exact_line:
|
||||
if token in _surface_scrollback_lines(client, workspace_id, surface_id):
|
||||
return
|
||||
elif token in _surface_scrollback_text(client, workspace_id, surface_id):
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise cmuxError(f"Timed out waiting for terminal token: {token}")
|
||||
|
||||
|
||||
def _pane_for_surface(client: cmux, surface_id: str) -> str:
|
||||
target_id = str(client._resolve_surface_id(surface_id))
|
||||
for _idx, pane_id, _count, _focused in client.list_panes():
|
||||
rows = client.list_pane_surfaces(pane_id)
|
||||
for _row_idx, sid, _title, _selected in rows:
|
||||
try:
|
||||
candidate_id = str(client._resolve_surface_id(sid))
|
||||
except cmuxError:
|
||||
continue
|
||||
if candidate_id == target_id:
|
||||
return pane_id
|
||||
raise cmuxError(f"Surface {surface_id} is not present in current workspace panes")
|
||||
|
||||
|
||||
def _valid_resize_directions(client: cmux, workspace_id: str, pane_id: str) -> list[str]:
|
||||
valid: list[str] = []
|
||||
for direction in ("left", "right", "up", "down"):
|
||||
try:
|
||||
client._call(
|
||||
"pane.resize",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"pane_id": pane_id,
|
||||
"direction": direction,
|
||||
"amount": 10,
|
||||
},
|
||||
)
|
||||
valid.append(direction)
|
||||
except cmuxError:
|
||||
pass
|
||||
return valid
|
||||
|
||||
|
||||
def _choose_resize_pair(client: cmux, workspace_id: str, pane_ids: list[str]) -> list[tuple[str, str]]:
|
||||
by_pane: dict[str, list[str]] = {}
|
||||
for pane_id in pane_ids:
|
||||
by_pane[pane_id] = _valid_resize_directions(client, workspace_id, pane_id)
|
||||
|
||||
for pane_a, directions_a in by_pane.items():
|
||||
if "right" not in directions_a:
|
||||
continue
|
||||
for pane_b, directions_b in by_pane.items():
|
||||
if pane_b == pane_a:
|
||||
continue
|
||||
if "left" in directions_b:
|
||||
return [(pane_a, "right"), (pane_b, "left")]
|
||||
|
||||
for pane_a, directions_a in by_pane.items():
|
||||
if "down" not in directions_a:
|
||||
continue
|
||||
for pane_b, directions_b in by_pane.items():
|
||||
if pane_b == pane_a:
|
||||
continue
|
||||
if "up" in directions_b:
|
||||
return [(pane_a, "down"), (pane_b, "up")]
|
||||
|
||||
raise cmuxError(f"Could not find oscillating resize pair across panes: {by_pane}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not SSH_HOST:
|
||||
print("SKIP: set CMUX_SSH_TEST_HOST to run remote resize scrollback regression")
|
||||
return 0
|
||||
if LS_ENTRY_COUNT < 64:
|
||||
print("SKIP: CMUX_SSH_TEST_LS_COUNT must be >= 64 for meaningful scrollback coverage")
|
||||
return 0
|
||||
|
||||
cli = _find_cli_binary()
|
||||
workspace_id = ""
|
||||
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()}
|
||||
|
||||
ssh_args = ["ssh", SSH_HOST, "--name", f"ssh-resize-regression-{secrets.token_hex(4)}"]
|
||||
if SSH_PORT:
|
||||
ssh_args.extend(["--port", SSH_PORT])
|
||||
if SSH_IDENTITY:
|
||||
ssh_args.extend(["--identity", SSH_IDENTITY])
|
||||
if SSH_OPTIONS_RAW:
|
||||
for option in SSH_OPTIONS_RAW.split(","):
|
||||
trimmed = option.strip()
|
||||
if trimmed:
|
||||
ssh_args.extend(["--ssh-option", trimmed])
|
||||
|
||||
payload = _run_cli_json(cli, ssh_args)
|
||||
workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids)
|
||||
_wait_remote_connected(client, workspace_id, timeout_s=50.0)
|
||||
|
||||
surfaces = client.list_surfaces(workspace_id)
|
||||
_must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}")
|
||||
surface_id = surfaces[0][1]
|
||||
|
||||
stamp = secrets.token_hex(4)
|
||||
ls_entries = [f"CMUX_REMOTE_RESIZE_LS_{stamp}_{index:04d}.txt" for index in range(1, LS_ENTRY_COUNT + 1)]
|
||||
ls_start = f"CMUX_REMOTE_RESIZE_LS_START_{stamp}"
|
||||
ls_end = f"CMUX_REMOTE_RESIZE_LS_END_{stamp}"
|
||||
|
||||
ls_prefix = f"CMUX_REMOTE_RESIZE_LS_{stamp}_"
|
||||
ls_script = (
|
||||
"tmpdir=$(mktemp -d); "
|
||||
f"echo {ls_start}; "
|
||||
f"for i in $(seq 1 {LS_ENTRY_COUNT}); do "
|
||||
"n=$(printf '%04d' \"$i\"); "
|
||||
f"touch \"$tmpdir/{ls_prefix}$n.txt\"; "
|
||||
"done; "
|
||||
"LC_ALL=C CLICOLOR=0 ls -1 \"$tmpdir\"; "
|
||||
f"echo {ls_end}; "
|
||||
"rm -rf \"$tmpdir\""
|
||||
)
|
||||
client.send_surface(surface_id, f"{ls_script}\n")
|
||||
_wait_surface_contains(
|
||||
client,
|
||||
workspace_id,
|
||||
surface_id,
|
||||
ls_end,
|
||||
exact_line=True,
|
||||
timeout_s=45.0,
|
||||
)
|
||||
|
||||
pre_resize_lines = _surface_scrollback_lines(client, workspace_id, surface_id)
|
||||
_must(
|
||||
all(entry in pre_resize_lines for entry in ls_entries),
|
||||
"pre-resize scrollback missing ls fixture lines in ssh workspace",
|
||||
)
|
||||
pre_resize_anchors = [ls_entries[0], ls_entries[len(ls_entries) // 2], ls_entries[-1]]
|
||||
|
||||
client.select_workspace(workspace_id)
|
||||
client.activate_app()
|
||||
pane_count_before_split = len(client.list_panes())
|
||||
client.simulate_shortcut("cmd+d")
|
||||
_wait_for(lambda: len(client.list_panes()) >= pane_count_before_split + 1, timeout_s=10.0)
|
||||
|
||||
# Ensure the original surface remains selected before resize churn.
|
||||
client.focus_surface(surface_id)
|
||||
pane_ids = [pid for _idx, pid, _count, _focused in client.list_panes()]
|
||||
_must(len(pane_ids) >= 2, f"expected split workspace with >=2 panes: {pane_ids}")
|
||||
_ = _pane_for_surface(client, surface_id)
|
||||
resize_pair = _choose_resize_pair(client, workspace_id, pane_ids)
|
||||
|
||||
for iteration in range(1, RESIZE_ITERATIONS + 1):
|
||||
pane_id, direction = resize_pair[(iteration - 1) % len(resize_pair)]
|
||||
_ = client._call(
|
||||
"pane.resize",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"pane_id": pane_id,
|
||||
"direction": direction,
|
||||
"amount": 80,
|
||||
},
|
||||
)
|
||||
if iteration % 8 == 0:
|
||||
sampled_lines = _surface_scrollback_lines(client, workspace_id, surface_id)
|
||||
_must(
|
||||
all(anchor in sampled_lines for anchor in pre_resize_anchors),
|
||||
f"resize iteration {iteration} lost pre-resize anchor lines in ssh workspace",
|
||||
)
|
||||
|
||||
post_token = f"CMUX_REMOTE_RESIZE_POST_{secrets.token_hex(6)}"
|
||||
client.send_surface(surface_id, f"echo {post_token}\n")
|
||||
_wait_surface_contains(
|
||||
client,
|
||||
workspace_id,
|
||||
surface_id,
|
||||
post_token,
|
||||
exact_line=True,
|
||||
timeout_s=25.0,
|
||||
)
|
||||
|
||||
post_resize_lines = _surface_scrollback_lines(client, workspace_id, surface_id)
|
||||
_must(
|
||||
all(entry in post_resize_lines for entry in ls_entries),
|
||||
"post-resize scrollback lost ls fixture lines in ssh workspace",
|
||||
)
|
||||
_must(
|
||||
post_token in post_resize_lines,
|
||||
f"post-resize scrollback missing post token: {post_token}",
|
||||
)
|
||||
|
||||
client.close_workspace(workspace_id)
|
||||
workspace_id = ""
|
||||
|
||||
print(
|
||||
"PASS: cmux ssh split+resize churn preserved large pre-resize scrollback "
|
||||
f"(entries={LS_ENTRY_COUNT}, iterations={RESIZE_ITERATIONS})"
|
||||
)
|
||||
return 0
|
||||
|
||||
finally:
|
||||
if workspace_id:
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as cleanup_client:
|
||||
cleanup_client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
175
tests_v2/test_ssh_remote_second_session_mux_regression.py
Normal file
175
tests_v2/test_ssh_remote_second_session_mux_regression.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: opening a second `cmux ssh` workspace to the same host must not mux-refuse."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip()
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli_json(cli: str, args: list[str]) -> dict:
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
env.pop("CMUX_TAB_ID", None)
|
||||
|
||||
import subprocess
|
||||
|
||||
proc = subprocess.run(
|
||||
[cli, "--socket", SOCKET_PATH, "--json", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise cmuxError(f"CLI failed ({' '.join(args)}): {(proc.stdout + proc.stderr).strip()}")
|
||||
try:
|
||||
return json.loads(proc.stdout or "{}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
|
||||
|
||||
|
||||
def _wait_remote_ready(client: cmux, workspace_id: str, timeout: float = 20.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
last_status = {}
|
||||
while time.time() < deadline:
|
||||
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
||||
remote = last_status.get("remote") or {}
|
||||
daemon = remote.get("daemon") or {}
|
||||
if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready":
|
||||
return
|
||||
time.sleep(0.25)
|
||||
raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}")
|
||||
|
||||
|
||||
def _wait_surface_id(client: cmux, workspace_id: str, timeout: float = 10.0) -> str:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
surfaces = client.list_surfaces(workspace_id)
|
||||
if surfaces:
|
||||
return str(surfaces[0][1])
|
||||
time.sleep(0.1)
|
||||
raise cmuxError(f"No terminal surface appeared for workspace {workspace_id}")
|
||||
|
||||
|
||||
def _workspace_id_from_payload(client: cmux, payload: dict) -> str:
|
||||
workspace_id = str(payload.get("workspace_id") or "")
|
||||
if workspace_id:
|
||||
return workspace_id
|
||||
workspace_ref = str(payload.get("workspace_ref") or "")
|
||||
if workspace_ref.startswith("workspace:"):
|
||||
rows = (client._call("workspace.list", {}) or {}).get("workspaces") or []
|
||||
for row in rows:
|
||||
if str(row.get("ref") or "") == workspace_ref:
|
||||
return str(row.get("id") or "")
|
||||
return ""
|
||||
|
||||
|
||||
def _wait_text_contains(client: cmux, surface_id: str, needle: str, timeout: float = 8.0) -> str:
|
||||
deadline = time.time() + timeout
|
||||
last = ""
|
||||
while time.time() < deadline:
|
||||
last = client.read_terminal_text(surface_id)
|
||||
if needle in last:
|
||||
return last
|
||||
time.sleep(0.1)
|
||||
raise cmuxError(f"Timed out waiting for {needle!r} in surface {surface_id}: {last[-800:]!r}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not SSH_HOST:
|
||||
print("SKIP: set CMUX_SSH_TEST_HOST to run second-session ssh mux regression")
|
||||
return 0
|
||||
|
||||
cli = _find_cli_binary()
|
||||
workspace_ids: list[str] = []
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
first = _run_cli_json(cli, ["ssh", SSH_HOST])
|
||||
first_workspace_id = _workspace_id_from_payload(client, first)
|
||||
_must(bool(first_workspace_id), f"first cmux ssh output missing workspace_id: {first}")
|
||||
workspace_ids.append(first_workspace_id)
|
||||
_wait_remote_ready(client, first_workspace_id)
|
||||
first_surface_id = _wait_surface_id(client, first_workspace_id)
|
||||
_wait_text_contains(client, first_surface_id, "cmux in ~", timeout=12.0)
|
||||
|
||||
second = _run_cli_json(cli, ["ssh", SSH_HOST])
|
||||
second_workspace_id = _workspace_id_from_payload(client, second)
|
||||
_must(bool(second_workspace_id), f"second cmux ssh output missing workspace_id: {second}")
|
||||
workspace_ids.append(second_workspace_id)
|
||||
_wait_remote_ready(client, second_workspace_id)
|
||||
|
||||
second_surface_id = _wait_surface_id(client, second_workspace_id)
|
||||
text = _wait_text_contains(client, second_surface_id, "cmux in ~", timeout=12.0)
|
||||
|
||||
refusal_markers = [
|
||||
"mux_client_request_session: session request failed: Session open refused by peer",
|
||||
"ControlSocket ",
|
||||
"disabling multiplexing",
|
||||
]
|
||||
hits = [marker for marker in refusal_markers if marker in text]
|
||||
_must(
|
||||
not hits,
|
||||
"second cmux ssh session printed mux refusal text instead of starting cleanly: "
|
||||
f"markers={hits!r} tail={text[-1200:]!r}",
|
||||
)
|
||||
|
||||
client.send_surface(second_surface_id, "printf '__SECOND_SESSION_OK__\\n'")
|
||||
text = _wait_text_contains(client, second_surface_id, "__SECOND_SESSION_OK__", timeout=6.0)
|
||||
_must(
|
||||
"command not found" not in text,
|
||||
f"second cmux ssh session accepted corrupted input after startup: {text[-1200:]!r}",
|
||||
)
|
||||
finally:
|
||||
if workspace_ids:
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
for workspace_id in workspace_ids:
|
||||
try:
|
||||
client._call("workspace.close", {"workspace_id": workspace_id})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("PASS: second cmux ssh session opens cleanly without mux refusal")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
577
tests_v2/test_ssh_remote_shell_integration.py
Executable file
577
tests_v2/test_ssh_remote_shell_integration.py
Executable file
|
|
@ -0,0 +1,577 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Docker integration: prove cmux ssh applies Ghostty ssh-env/ssh-terminfo niceties."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1")
|
||||
DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1")
|
||||
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||
OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
|
||||
if check and proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
|
||||
return proc
|
||||
|
||||
|
||||
def _run_cli_json(cli: str, args: list[str]) -> dict:
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
env.pop("CMUX_TAB_ID", None)
|
||||
|
||||
proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env)
|
||||
try:
|
||||
return json.loads(proc.stdout or "{}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
|
||||
|
||||
|
||||
def _docker_available() -> bool:
|
||||
if shutil.which("docker") is None:
|
||||
return False
|
||||
probe = _run(["docker", "info"], check=False)
|
||||
return probe.returncode == 0
|
||||
|
||||
|
||||
def _parse_host_port(docker_port_output: str) -> int:
|
||||
text = docker_port_output.strip()
|
||||
if not text:
|
||||
raise cmuxError("docker port output was empty")
|
||||
return int(text.split(":")[-1])
|
||||
|
||||
|
||||
def _shell_single_quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
return _run(
|
||||
[
|
||||
"ssh",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"ConnectTimeout=5",
|
||||
"-p",
|
||||
str(host_port),
|
||||
"-i",
|
||||
str(key_path),
|
||||
host,
|
||||
f"sh -lc {_shell_single_quote(script)}",
|
||||
],
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
probe = _ssh_run(host, host_port, key_path, "echo ready", check=False)
|
||||
if probe.returncode == 0 and "ready" in probe.stdout:
|
||||
return
|
||||
time.sleep(0.5)
|
||||
raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready")
|
||||
|
||||
|
||||
def _wait_remote_connected(client: cmux, workspace_id: str, timeout: float) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
last_status = {}
|
||||
while time.time() < deadline:
|
||||
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
||||
remote = last_status.get("remote") or {}
|
||||
daemon = remote.get("daemon") or {}
|
||||
if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready":
|
||||
return last_status
|
||||
time.sleep(0.4)
|
||||
raise cmuxError(f"Remote did not reach connected+ready state: {last_status}")
|
||||
|
||||
|
||||
def _is_terminal_surface_not_found(exc: Exception) -> bool:
|
||||
return "terminal surface not found" in str(exc).lower()
|
||||
|
||||
|
||||
def _read_probe_value(client: cmux, surface_id: str, command: str, timeout: float = 20.0) -> str:
|
||||
token = f"__CMUX_PROBE_{secrets.token_hex(6)}__"
|
||||
client.send_surface(surface_id, f"{command}; printf '{token}%s\\n' $?\\n")
|
||||
|
||||
pattern = re.compile(re.escape(token) + r"([^\r\n]*)")
|
||||
deadline = time.time() + timeout
|
||||
saw_missing_surface = False
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
text = client.read_terminal_text(surface_id)
|
||||
except cmuxError as exc:
|
||||
if _is_terminal_surface_not_found(exc):
|
||||
saw_missing_surface = True
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
raise
|
||||
matches = pattern.findall(text)
|
||||
for raw in reversed(matches):
|
||||
value = raw.strip()
|
||||
if value and value != "%s" and "$(" not in value and "printf" not in value:
|
||||
return value
|
||||
time.sleep(0.2)
|
||||
|
||||
if saw_missing_surface:
|
||||
raise cmuxError("terminal surface not found")
|
||||
raise cmuxError(f"Timed out waiting for probe token for command: {command}")
|
||||
|
||||
|
||||
def _read_probe_payload(client: cmux, surface_id: str, payload_command: str, timeout: float = 20.0) -> str:
|
||||
token = f"__CMUX_PAYLOAD_{secrets.token_hex(6)}__"
|
||||
client.send_surface(surface_id, f"printf '{token}%s\\n' \"$({payload_command})\"\\n")
|
||||
|
||||
pattern = re.compile(re.escape(token) + r"([^\r\n]*)")
|
||||
deadline = time.time() + timeout
|
||||
saw_missing_surface = False
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
text = client.read_terminal_text(surface_id)
|
||||
except cmuxError as exc:
|
||||
if _is_terminal_surface_not_found(exc):
|
||||
saw_missing_surface = True
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
raise
|
||||
matches = pattern.findall(text)
|
||||
for raw in reversed(matches):
|
||||
value = raw.strip()
|
||||
if value and value != "%s" and "$(" not in value and "printf" not in value:
|
||||
return value
|
||||
time.sleep(0.2)
|
||||
|
||||
if saw_missing_surface:
|
||||
raise cmuxError("terminal surface not found")
|
||||
raise cmuxError(f"Timed out waiting for payload token for command: {payload_command}")
|
||||
|
||||
|
||||
def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if pred():
|
||||
return
|
||||
time.sleep(step_s)
|
||||
raise cmuxError("Timed out waiting for condition")
|
||||
|
||||
|
||||
def _wait_for_pane_count(client: cmux, minimum_count: int, timeout: float = 8.0) -> list[str]:
|
||||
deadline = time.time() + timeout
|
||||
last: list[str] = []
|
||||
while time.time() < deadline:
|
||||
last = [pid for _idx, pid, _count, _focused in client.list_panes()]
|
||||
if len(last) >= minimum_count:
|
||||
return last
|
||||
time.sleep(0.1)
|
||||
raise cmuxError(f"Timed out waiting for pane count >= {minimum_count}; saw {len(last)} panes: {last}")
|
||||
|
||||
|
||||
def _surface_text_scrollback(client: cmux, workspace_id: str, surface_id: str) -> str:
|
||||
payload = client._call(
|
||||
"surface.read_text",
|
||||
{"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True},
|
||||
) or {}
|
||||
return str(payload.get("text") or "")
|
||||
|
||||
|
||||
def _clean_line(raw: str) -> str:
|
||||
line = OSC_ESCAPE_RE.sub("", raw)
|
||||
line = ANSI_ESCAPE_RE.sub("", line)
|
||||
line = line.replace("\r", "")
|
||||
return line.strip()
|
||||
|
||||
|
||||
def _surface_text_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]:
|
||||
return [_clean_line(raw) for raw in _surface_text_scrollback(client, workspace_id, surface_id).splitlines()]
|
||||
|
||||
|
||||
def _scrollback_has_all_lines(
|
||||
client: cmux,
|
||||
workspace_id: str,
|
||||
surface_id: str,
|
||||
lines: list[str],
|
||||
) -> bool:
|
||||
available = set(_surface_text_scrollback_lines(client, workspace_id, surface_id))
|
||||
return all(line in available for line in lines)
|
||||
|
||||
|
||||
def _wait_surface_contains(
|
||||
client: cmux,
|
||||
workspace_id: str,
|
||||
surface_id: str,
|
||||
token: str,
|
||||
*,
|
||||
timeout: float = 20.0,
|
||||
) -> None:
|
||||
deadline = time.time() + timeout
|
||||
saw_missing_surface = False
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
if token in _surface_text_scrollback(client, workspace_id, surface_id):
|
||||
return
|
||||
except cmuxError as exc:
|
||||
if _is_terminal_surface_not_found(exc):
|
||||
saw_missing_surface = True
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
raise
|
||||
time.sleep(0.2)
|
||||
|
||||
if saw_missing_surface:
|
||||
raise cmuxError("terminal surface not found")
|
||||
raise cmuxError(f"Timed out waiting for terminal token: {token}")
|
||||
|
||||
|
||||
def _layout_panes(client: cmux) -> list[dict]:
|
||||
layout_payload = client.layout_debug() or {}
|
||||
layout = layout_payload.get("layout") or {}
|
||||
return list(layout.get("panes") or [])
|
||||
|
||||
|
||||
def _pane_extent(client: cmux, pane_id: str, axis: str) -> float:
|
||||
panes = _layout_panes(client)
|
||||
for pane in panes:
|
||||
pid = str(pane.get("paneId") or pane.get("pane_id") or "")
|
||||
if pid != pane_id:
|
||||
continue
|
||||
frame = pane.get("frame") or {}
|
||||
return float(frame.get(axis) or 0.0)
|
||||
raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}")
|
||||
|
||||
|
||||
def _pane_for_surface(client: cmux, surface_id: str) -> str:
|
||||
target_id = str(client._resolve_surface_id(surface_id))
|
||||
for _idx, pane_id, _count, _focused in client.list_panes():
|
||||
rows = client.list_pane_surfaces(pane_id)
|
||||
for _row_idx, sid, _title, _selected in rows:
|
||||
try:
|
||||
candidate_id = str(client._resolve_surface_id(sid))
|
||||
except cmuxError:
|
||||
continue
|
||||
if candidate_id == target_id:
|
||||
return pane_id
|
||||
raise cmuxError(f"Surface {surface_id} is not present in current workspace panes")
|
||||
|
||||
|
||||
def _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]:
|
||||
panes = [p for p in _layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids]
|
||||
if len(panes) < 2:
|
||||
raise cmuxError(f"Need >=2 panes for resize test, got {panes}")
|
||||
|
||||
def x_of(p: dict) -> float:
|
||||
return float((p.get("frame") or {}).get("x") or 0.0)
|
||||
|
||||
def y_of(p: dict) -> float:
|
||||
return float((p.get("frame") or {}).get("y") or 0.0)
|
||||
|
||||
x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes)
|
||||
y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes)
|
||||
|
||||
if x_span >= y_span:
|
||||
left_pane = min(panes, key=x_of)
|
||||
left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "")
|
||||
return ("right" if target_pane == left_id else "left"), "width"
|
||||
|
||||
top_pane = min(panes, key=y_of)
|
||||
top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "")
|
||||
return ("down" if target_pane == top_id else "up"), "height"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not _docker_available():
|
||||
print("SKIP: docker is not available")
|
||||
return 0
|
||||
if shutil.which("infocmp") is None:
|
||||
print("SKIP: local infocmp is not available (required for ssh-terminfo)")
|
||||
return 0
|
||||
|
||||
cli = _find_cli_binary()
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote"
|
||||
_must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}")
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-shell-integration-"))
|
||||
image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}"
|
||||
container_name = f"cmux-ssh-shell-{secrets.token_hex(4)}"
|
||||
workspace_id = ""
|
||||
|
||||
try:
|
||||
key_path = temp_dir / "id_ed25519"
|
||||
_run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)])
|
||||
pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip()
|
||||
_must(bool(pubkey), "Generated SSH public key was empty")
|
||||
|
||||
_run(["docker", "build", "-t", image_tag, str(fixture_dir)])
|
||||
_run([
|
||||
"docker",
|
||||
"run",
|
||||
"-d",
|
||||
"--rm",
|
||||
"--name",
|
||||
container_name,
|
||||
"-e",
|
||||
f"AUTHORIZED_KEY={pubkey}",
|
||||
"-p",
|
||||
f"{DOCKER_PUBLISH_ADDR}::22",
|
||||
image_tag,
|
||||
])
|
||||
|
||||
port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout
|
||||
host_ssh_port = _parse_host_port(port_info)
|
||||
host = f"root@{DOCKER_SSH_HOST}"
|
||||
if shutil.which("ghostty") is not None:
|
||||
_run(["ghostty", "+ssh-cache", f"--remove={host}"], check=False)
|
||||
_wait_for_ssh(host, host_ssh_port, key_path)
|
||||
|
||||
pre = _ssh_run(host, host_ssh_port, key_path, "if infocmp xterm-ghostty >/dev/null 2>&1; then echo present; else echo missing; fi")
|
||||
_must("missing" in pre.stdout, f"Fresh container should not have xterm-ghostty terminfo preinstalled: {pre.stdout!r}")
|
||||
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
payload = _run_cli_json(
|
||||
cli,
|
||||
[
|
||||
"ssh",
|
||||
host,
|
||||
"--name",
|
||||
"docker-ssh-shell-integration",
|
||||
"--port",
|
||||
str(host_ssh_port),
|
||||
"--identity",
|
||||
str(key_path),
|
||||
"--ssh-option",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
"--ssh-option",
|
||||
"StrictHostKeyChecking=no",
|
||||
],
|
||||
)
|
||||
workspace_id = str(payload.get("workspace_id") or "")
|
||||
workspace_ref = str(payload.get("workspace_ref") or "")
|
||||
if not workspace_id and workspace_ref.startswith("workspace:"):
|
||||
listed = client._call("workspace.list", {}) or {}
|
||||
for row in listed.get("workspaces") or []:
|
||||
if str(row.get("ref") or "") == workspace_ref:
|
||||
workspace_id = str(row.get("id") or "")
|
||||
break
|
||||
_must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}")
|
||||
|
||||
_wait_remote_connected(client, workspace_id, timeout=45.0)
|
||||
|
||||
surfaces = client.list_surfaces(workspace_id)
|
||||
_must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}")
|
||||
surface_id = surfaces[0][1]
|
||||
terminal_text = client.read_terminal_text(surface_id)
|
||||
_must(
|
||||
"Reconstructed via infocmp" not in terminal_text,
|
||||
"ssh-terminfo bootstrap should not leak raw infocmp output into the interactive shell",
|
||||
)
|
||||
_must(
|
||||
"Warning: Failed to install terminfo." not in terminal_text,
|
||||
"ssh shell bootstrap should not show a false terminfo failure warning",
|
||||
)
|
||||
|
||||
try:
|
||||
term_value = _read_probe_payload(client, surface_id, "printf '%s' \"$TERM\"")
|
||||
terminfo_state = _read_probe_value(client, surface_id, "infocmp xterm-ghostty >/dev/null 2>&1")
|
||||
except cmuxError as exc:
|
||||
if _is_terminal_surface_not_found(exc):
|
||||
print("SKIP: terminal surface unavailable for shell integration probes")
|
||||
return 0
|
||||
raise
|
||||
_must(terminfo_state in {"0", "1"}, f"unexpected terminfo probe exit status: {terminfo_state!r}")
|
||||
if terminfo_state == "0":
|
||||
_must(
|
||||
term_value == "xterm-ghostty",
|
||||
f"when terminfo install succeeds, TERM should remain xterm-ghostty (got {term_value!r})",
|
||||
)
|
||||
else:
|
||||
_must(
|
||||
term_value == "xterm-256color",
|
||||
f"when terminfo is unavailable, ssh-env fallback should use TERM=xterm-256color (got {term_value!r})",
|
||||
)
|
||||
|
||||
colorterm_value = _read_probe_payload(client, surface_id, "printf '%s' \"${COLORTERM:-}\"")
|
||||
_must(
|
||||
colorterm_value == "truecolor",
|
||||
f"ssh-env should propagate COLORTERM=truecolor, got: {colorterm_value!r}",
|
||||
)
|
||||
|
||||
term_program = _read_probe_payload(client, surface_id, "printf '%s' \"${TERM_PROGRAM:-}\"")
|
||||
_must(
|
||||
term_program == "ghostty",
|
||||
f"ssh-env should propagate TERM_PROGRAM=ghostty when AcceptEnv allows it, got: {term_program!r}",
|
||||
)
|
||||
|
||||
term_program_version = _read_probe_payload(client, surface_id, "printf '%s' \"${TERM_PROGRAM_VERSION:-}\"")
|
||||
_must(bool(term_program_version), "ssh-env should propagate non-empty TERM_PROGRAM_VERSION")
|
||||
|
||||
ls_stamp = secrets.token_hex(4)
|
||||
ls_entries = [f"CMUX_RESIZE_LS_{ls_stamp}_{index:02d}" for index in range(1, 17)]
|
||||
ls_start = f"CMUX_RESIZE_LS_START_{ls_stamp}"
|
||||
ls_end = f"CMUX_RESIZE_LS_END_{ls_stamp}"
|
||||
names = " ".join(ls_entries)
|
||||
ls_script = (
|
||||
"tmpdir=$(mktemp -d); "
|
||||
f"echo {ls_start}; "
|
||||
f"for name in {names}; do touch \"$tmpdir/$name\"; done; "
|
||||
"ls -1 \"$tmpdir\"; "
|
||||
f"echo {ls_end}; "
|
||||
"rm -rf \"$tmpdir\""
|
||||
)
|
||||
client.send_surface(surface_id, f"{ls_script}\n")
|
||||
_wait_surface_contains(client, workspace_id, surface_id, ls_end)
|
||||
pre_resize_scrollback_lines = _surface_text_scrollback_lines(client, workspace_id, surface_id)
|
||||
_must(
|
||||
all(line in pre_resize_scrollback_lines for line in ls_entries),
|
||||
"pre-resize scrollback missing ls output fixture lines",
|
||||
)
|
||||
pre_resize_anchors = [ls_entries[0], ls_entries[len(ls_entries) // 2], ls_entries[-1]]
|
||||
_must(
|
||||
len(pre_resize_anchors) == 3,
|
||||
f"pre-resize scrollback missing anchor lines: {pre_resize_anchors}",
|
||||
)
|
||||
pre_resize_visible = client.read_terminal_text(surface_id)
|
||||
pre_visible_lines = [line for line in ls_entries if line in pre_resize_visible]
|
||||
_must(
|
||||
len(pre_visible_lines) >= 2,
|
||||
"pre-resize viewport did not contain enough reference lines for continuity checks",
|
||||
)
|
||||
|
||||
client.select_workspace(workspace_id)
|
||||
client.activate_app()
|
||||
pane_count_before_split = len(client.list_panes())
|
||||
client.simulate_shortcut("cmd+d")
|
||||
pane_ids = _wait_for_pane_count(client, pane_count_before_split + 1, timeout=8.0)
|
||||
|
||||
pane_id = _pane_for_surface(client, surface_id)
|
||||
resize_direction, resize_axis = _pick_resize_direction_for_pane(client, pane_ids, pane_id)
|
||||
opposite_direction = {
|
||||
"left": "right",
|
||||
"right": "left",
|
||||
"up": "down",
|
||||
"down": "up",
|
||||
}[resize_direction]
|
||||
expected_sign_by_direction = {
|
||||
resize_direction: +1,
|
||||
opposite_direction: -1,
|
||||
}
|
||||
|
||||
resize_sequence = [resize_direction, opposite_direction] * 8
|
||||
current_extent = _pane_extent(client, pane_id, resize_axis)
|
||||
for index, direction in enumerate(resize_sequence, start=1):
|
||||
resize_result = client._call(
|
||||
"pane.resize",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"pane_id": pane_id,
|
||||
"direction": direction,
|
||||
"amount": 80,
|
||||
},
|
||||
) or {}
|
||||
_must(
|
||||
str(resize_result.get("pane_id") or "") == pane_id,
|
||||
f"pane.resize response missing expected pane_id: {resize_result}",
|
||||
)
|
||||
if expected_sign_by_direction[direction] > 0:
|
||||
_wait_for(lambda: _pane_extent(client, pane_id, resize_axis) > current_extent + 1.0, timeout_s=5.0)
|
||||
else:
|
||||
_wait_for(lambda: _pane_extent(client, pane_id, resize_axis) < current_extent - 1.0, timeout_s=5.0)
|
||||
current_extent = _pane_extent(client, pane_id, resize_axis)
|
||||
_must(
|
||||
_scrollback_has_all_lines(client, workspace_id, surface_id, pre_resize_anchors),
|
||||
f"resize iteration {index} lost pre-resize scrollback anchors",
|
||||
)
|
||||
|
||||
post_resize_visible = client.read_terminal_text(surface_id)
|
||||
visible_overlap = [line for line in pre_visible_lines if line in post_resize_visible]
|
||||
_must(
|
||||
bool(visible_overlap),
|
||||
f"resize lost all pre-resize visible lines from viewport: {pre_visible_lines}",
|
||||
)
|
||||
|
||||
resize_post_token = f"CMUX_RESIZE_POST_{secrets.token_hex(6)}"
|
||||
client.send_surface(surface_id, f"echo {resize_post_token}\n")
|
||||
_wait_surface_contains(client, workspace_id, surface_id, resize_post_token)
|
||||
|
||||
scrollback_lines = _surface_text_scrollback_lines(client, workspace_id, surface_id)
|
||||
_must(
|
||||
all(anchor in scrollback_lines for anchor in pre_resize_anchors),
|
||||
"terminal scrollback lost pre-resize lines after pane resize",
|
||||
)
|
||||
_must(
|
||||
resize_post_token in scrollback_lines,
|
||||
f"terminal scrollback missing post-resize token after pane resize: {resize_post_token}",
|
||||
)
|
||||
|
||||
try:
|
||||
client.close_workspace(workspace_id)
|
||||
workspace_id = ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(
|
||||
"PASS: cmux ssh enables Ghostty shell integration niceties and preserves pre-resize terminal content "
|
||||
f"(TERM={term_value}, COLORTERM={colorterm_value}, TERM_PROGRAM={term_program})"
|
||||
)
|
||||
return 0
|
||||
|
||||
finally:
|
||||
if workspace_id:
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as cleanup_client:
|
||||
cleanup_client.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_run(["docker", "rm", "-f", container_name], check=False)
|
||||
_run(["docker", "rmi", "-f", image_tag], check=False)
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
281
tests_v2/test_ssh_remote_shortcuts_stay_remote.py
Normal file
281
tests_v2/test_ssh_remote_shortcuts_stay_remote.py
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: new tabs and splits from an ssh terminal must stay on the remote shell."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip()
|
||||
SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip()
|
||||
SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip()
|
||||
SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip()
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
|
||||
if check and proc.returncode != 0:
|
||||
merged = f"{proc.stdout}\n{proc.stderr}".strip()
|
||||
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
|
||||
return proc
|
||||
|
||||
|
||||
def _find_cli_binary() -> str:
|
||||
env_cli = os.environ.get("CMUXTERM_CLI")
|
||||
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
|
||||
return env_cli
|
||||
|
||||
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
|
||||
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
|
||||
return fixed
|
||||
|
||||
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
|
||||
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
|
||||
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
|
||||
if not candidates:
|
||||
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
|
||||
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _run_cli_json(cli: str, args: list[str]) -> dict:
|
||||
env = dict(os.environ)
|
||||
env.pop("CMUX_WORKSPACE_ID", None)
|
||||
env.pop("CMUX_SURFACE_ID", None)
|
||||
env.pop("CMUX_TAB_ID", None)
|
||||
|
||||
proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env)
|
||||
try:
|
||||
return json.loads(proc.stdout or "{}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
|
||||
|
||||
|
||||
def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
while time.time() < deadline:
|
||||
if pred():
|
||||
return
|
||||
time.sleep(step_s)
|
||||
raise cmuxError("Timed out waiting for condition")
|
||||
|
||||
|
||||
def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 45.0) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
last_status = {}
|
||||
while time.time() < deadline:
|
||||
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
|
||||
remote = last_status.get("remote") or {}
|
||||
daemon = remote.get("daemon") or {}
|
||||
if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready":
|
||||
return
|
||||
time.sleep(0.25)
|
||||
raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}")
|
||||
|
||||
|
||||
def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str:
|
||||
workspace_id = str(payload.get("workspace_id") or "")
|
||||
if workspace_id:
|
||||
return workspace_id
|
||||
|
||||
workspace_ref = str(payload.get("workspace_ref") or "")
|
||||
if workspace_ref.startswith("workspace:"):
|
||||
listed = client._call("workspace.list", {}) or {}
|
||||
for row in listed.get("workspaces") or []:
|
||||
if str(row.get("ref") or "") == workspace_ref:
|
||||
resolved = str(row.get("id") or "")
|
||||
if resolved:
|
||||
return resolved
|
||||
|
||||
current = {wid for _index, wid, _title, _focused in client.list_workspaces()}
|
||||
new_ids = sorted(current - before_workspace_ids)
|
||||
if len(new_ids) == 1:
|
||||
return new_ids[0]
|
||||
|
||||
raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}")
|
||||
|
||||
|
||||
def _focused_surface_id(client: cmux) -> str:
|
||||
ident = client.identify()
|
||||
focused = ident.get("focused") or {}
|
||||
surface_id = str(focused.get("surface_id") or "")
|
||||
if not surface_id:
|
||||
raise cmuxError(f"Missing focused surface in identify payload: {ident}")
|
||||
return surface_id
|
||||
|
||||
|
||||
def _run_remote_shell_probe(client: cmux, surface_id: str, probe_label: str) -> str:
|
||||
token = f"__CMUX_REMOTE_SOCKET_{probe_label}_{secrets.token_hex(4)}__"
|
||||
client.send_surface(
|
||||
surface_id,
|
||||
(
|
||||
f"__cmux_socket_path=\"${{CMUX_SOCKET_PATH:-}}\"; "
|
||||
f"printf '{token}:%s:__CMUX_REMOTE_SOCKET_END__\\n' \"$__cmux_socket_path\"\n"
|
||||
),
|
||||
)
|
||||
deadline = time.time() + 15.0
|
||||
last = ""
|
||||
pattern = re.compile(re.escape(token) + r":(.*?):__CMUX_REMOTE_SOCKET_END__")
|
||||
while time.time() < deadline:
|
||||
last = client.read_terminal_text(surface_id)
|
||||
matches = pattern.findall(last)
|
||||
if matches:
|
||||
for candidate in reversed(matches):
|
||||
cleaned = candidate.strip()
|
||||
if cleaned and cleaned != "%s":
|
||||
return cleaned
|
||||
time.sleep(0.15)
|
||||
raise cmuxError(f"Timed out waiting for socket token {token!r}: {last[-1200:]!r}")
|
||||
|
||||
|
||||
def _assert_remote_socket_path(client: cmux, surface_id: str, shortcut_name: str) -> None:
|
||||
socket_path = _run_remote_shell_probe(client, surface_id, shortcut_name)
|
||||
_must(
|
||||
socket_path.startswith("127.0.0.1:"),
|
||||
f"{shortcut_name} should keep the new terminal on the ssh relay, got CMUX_SOCKET_PATH={socket_path!r}",
|
||||
)
|
||||
|
||||
|
||||
def _open_ssh_workspace(client: cmux, cli: str, *, name: str) -> str:
|
||||
before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()}
|
||||
|
||||
ssh_args = ["ssh", SSH_HOST, "--name", name]
|
||||
if SSH_PORT:
|
||||
ssh_args.extend(["--port", SSH_PORT])
|
||||
if SSH_IDENTITY:
|
||||
ssh_args.extend(["--identity", SSH_IDENTITY])
|
||||
if SSH_OPTIONS_RAW:
|
||||
for option in SSH_OPTIONS_RAW.split(","):
|
||||
trimmed = option.strip()
|
||||
if trimmed:
|
||||
ssh_args.extend(["--ssh-option", trimmed])
|
||||
|
||||
payload = _run_cli_json(cli, ssh_args)
|
||||
workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids)
|
||||
_wait_remote_ready(client, workspace_id)
|
||||
client.select_workspace(workspace_id)
|
||||
_wait_for(lambda: client.current_workspace() == workspace_id, timeout_s=8.0)
|
||||
return workspace_id
|
||||
|
||||
|
||||
def _assert_shortcut_creates_remote_terminal(
|
||||
client: cmux,
|
||||
workspace_id: str,
|
||||
shortcut: str,
|
||||
shortcut_name: str,
|
||||
*,
|
||||
expect_new_pane: bool,
|
||||
) -> None:
|
||||
before_surfaces = {sid for _index, sid, _focused in client.list_surfaces(workspace_id)}
|
||||
before_pane_count = len(client.list_panes())
|
||||
|
||||
client.activate_app()
|
||||
client.simulate_app_active()
|
||||
client.simulate_shortcut(shortcut)
|
||||
|
||||
_wait_for(
|
||||
lambda: len({sid for _index, sid, _focused in client.list_surfaces(workspace_id)} - before_surfaces) == 1,
|
||||
timeout_s=12.0,
|
||||
)
|
||||
|
||||
if expect_new_pane:
|
||||
_wait_for(lambda: len(client.list_panes()) >= before_pane_count + 1, timeout_s=12.0)
|
||||
|
||||
after_surfaces = {sid for _index, sid, _focused in client.list_surfaces(workspace_id)}
|
||||
new_surface_ids = sorted(after_surfaces - before_surfaces)
|
||||
_must(len(new_surface_ids) == 1, f"{shortcut_name} should create exactly one new surface: {new_surface_ids}")
|
||||
|
||||
focused_surface_id = _focused_surface_id(client)
|
||||
_must(
|
||||
focused_surface_id == new_surface_ids[0],
|
||||
f"{shortcut_name} should focus the new terminal surface: focused={focused_surface_id!r} new={new_surface_ids[0]!r}",
|
||||
)
|
||||
_assert_remote_socket_path(client, focused_surface_id, shortcut_name)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not SSH_HOST:
|
||||
print("SKIP: set CMUX_SSH_TEST_HOST to run ssh shortcut inheritance regression")
|
||||
return 0
|
||||
|
||||
cli = _find_cli_binary()
|
||||
workspace_ids: list[str] = []
|
||||
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
workspace_id = _open_ssh_workspace(
|
||||
client,
|
||||
cli,
|
||||
name=f"ssh-shortcut-cmdt-{secrets.token_hex(4)}",
|
||||
)
|
||||
workspace_ids.append(workspace_id)
|
||||
_assert_shortcut_creates_remote_terminal(
|
||||
client,
|
||||
workspace_id,
|
||||
"cmd+t",
|
||||
"cmd+t",
|
||||
expect_new_pane=False,
|
||||
)
|
||||
|
||||
workspace_id = _open_ssh_workspace(
|
||||
client,
|
||||
cli,
|
||||
name=f"ssh-shortcut-cmdd-{secrets.token_hex(4)}",
|
||||
)
|
||||
workspace_ids.append(workspace_id)
|
||||
_assert_shortcut_creates_remote_terminal(
|
||||
client,
|
||||
workspace_id,
|
||||
"cmd+d",
|
||||
"cmd+d",
|
||||
expect_new_pane=True,
|
||||
)
|
||||
|
||||
workspace_id = _open_ssh_workspace(
|
||||
client,
|
||||
cli,
|
||||
name=f"ssh-shortcut-cmdshiftd-{secrets.token_hex(4)}",
|
||||
)
|
||||
workspace_ids.append(workspace_id)
|
||||
_assert_shortcut_creates_remote_terminal(
|
||||
client,
|
||||
workspace_id,
|
||||
"cmd+shift+d",
|
||||
"cmd+shift+d",
|
||||
expect_new_pane=True,
|
||||
)
|
||||
finally:
|
||||
if workspace_ids:
|
||||
try:
|
||||
with cmux(SOCKET_PATH) as client:
|
||||
for workspace_id in workspace_ids:
|
||||
try:
|
||||
client._call("workspace.close", {"workspace_id": workspace_id})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("PASS: cmd+t/cmd+d/cmd+shift+d keep ssh terminals on the remote relay")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
86
tests_v2/test_workspace_create_initial_env.py
Normal file
86
tests_v2/test_workspace_create_initial_env.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: workspace.create must apply initial_env to the initial terminal."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
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_text(c: cmux, workspace_id: str, needle: str, timeout_s: float = 8.0) -> str:
|
||||
deadline = time.time() + timeout_s
|
||||
last_text = ""
|
||||
while time.time() < deadline:
|
||||
payload = c._call(
|
||||
"surface.read_text",
|
||||
{"workspace_id": workspace_id},
|
||||
) or {}
|
||||
if "text" in payload:
|
||||
last_text = str(payload.get("text") or "")
|
||||
else:
|
||||
b64 = str(payload.get("base64") or "")
|
||||
raw = base64.b64decode(b64) if b64 else b""
|
||||
last_text = raw.decode("utf-8", errors="replace")
|
||||
if needle in last_text:
|
||||
return last_text
|
||||
time.sleep(0.1)
|
||||
raise cmuxError(f"Timed out waiting for {needle!r} in panel text: {last_text!r}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as c:
|
||||
baseline_workspace = c.current_workspace()
|
||||
created_workspace = ""
|
||||
try:
|
||||
token = f"tok_{int(time.time() * 1000)}"
|
||||
payload = c._call(
|
||||
"workspace.create",
|
||||
{
|
||||
"initial_env": {"CMUX_INITIAL_ENV_TOKEN": token},
|
||||
},
|
||||
) or {}
|
||||
created_workspace = str(payload.get("workspace_id") or "")
|
||||
_must(bool(created_workspace), f"workspace.create returned no workspace_id: {payload}")
|
||||
_must(c.current_workspace() == baseline_workspace, "workspace.create should not steal workspace focus")
|
||||
|
||||
# Terminal surfaces in background workspaces may not be attached/render-ready yet.
|
||||
# Select it before reading text so the initial command output is available.
|
||||
c.select_workspace(created_workspace)
|
||||
listed = c._call("surface.list", {"workspace_id": created_workspace}) or {}
|
||||
rows = list(listed.get("surfaces") or [])
|
||||
_must(bool(rows), "Expected at least one surface in the created workspace")
|
||||
terminal_row = next((row for row in rows if str(row.get("type") or "") == "terminal"), None)
|
||||
_must(terminal_row is not None, f"Expected a terminal surface in workspace.create result: {rows}")
|
||||
|
||||
c.send("printf 'CMUX_ENV_CHECK=%s\\n' \"$CMUX_INITIAL_ENV_TOKEN\"\\n")
|
||||
text = _wait_for_text(c, created_workspace, f"CMUX_ENV_CHECK={token}")
|
||||
_must(
|
||||
f"CMUX_ENV_CHECK={token}" in text,
|
||||
f"initial_env token missing from terminal output: {text!r}",
|
||||
)
|
||||
c.select_workspace(baseline_workspace)
|
||||
finally:
|
||||
if created_workspace:
|
||||
try:
|
||||
c.close_workspace(created_workspace)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("PASS: workspace.create applies initial_env to initial terminal")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue