Reapply "Merge pull request #239 from manaflow-ai/issue-151-ssh-remote-port-proxying"

This reverts commit f7cbbad434.
This commit is contained in:
Lawrence Chen 2026-03-12 15:54:26 -07:00
parent 294217eb39
commit 19b59cae37
60 changed files with 17139 additions and 1249 deletions

View 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())

View 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())

View 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())

View file

@ -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],

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())