From 7105bbc236989426c55f49993dade3c131eaba9a Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Fri, 17 Apr 2026 23:17:45 -0400 Subject: [PATCH] linux(socket): add surface.report_tty registering panel TTY name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the v2 surface.report_tty RPC on Linux. Mirrors the workspace_id/surface_id resolution pattern from handleSurfaceSendText so callers may omit either or both ids. Trims surrounding whitespace and rejects empty tty_name. The handler stores the TTY name onto Panel.tty_name using the alloc-then-free pattern (avoids dangling on allocation failure) and echoes {workspace_id, surface_id, tty_name}. The Linux build does not yet have a PortScanner / remote workspace plumbing equivalent to macOS, so this is metadata-only — the value is recorded and visible to subsequent surface.list / pane.list responses that may want to surface it, but no port scan is triggered. Adds tests_v2/test_surface_report_tty.py covering: default surface fallback, explicit surface_id targeting, whitespace trimming, missing / empty / whitespace-only tty_name errors, and invalid workspace/surface id errors. Refs #220 (Sprint A item #8). --- cmux-linux/src/socket.zig | 56 +++++++++++ tests_v2/test_surface_report_tty.py | 147 ++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 tests_v2/test_surface_report_tty.py diff --git a/cmux-linux/src/socket.zig b/cmux-linux/src/socket.zig index 5364b7a30679..2b227c268fd6 100644 --- a/cmux-linux/src/socket.zig +++ b/cmux-linux/src/socket.zig @@ -223,6 +223,7 @@ const methods = .{ .{ "surface.clear_history", handleSurfaceClearHistory }, .{ "surface.action", handleSurfaceAction }, .{ "tab.action", handleSurfaceAction }, + .{ "surface.report_tty", handleSurfaceReportTty }, .{ "pane.list", handlePaneList }, .{ "pane.focus", handlePaneFocus }, .{ "pane.create", handlePaneCreate }, @@ -1631,6 +1632,61 @@ fn handleSurfaceAction(alloc: Allocator, params: json.Value) []const u8 { return buf.toOwnedSlice(alloc) catch "{\"error\":\"unsupported action\"}"; } +fn handleSurfaceReportTty(alloc: Allocator, params: json.Value) []const u8 { + const tm = getTabManager() orelse return "{\"error\":\"no tab manager\"}"; + + // Resolve workspace + target surface using the same pattern as + // handleSurfaceSendText so callers can omit either or both ids and + // fall back to the currently selected workspace + focused surface. + const workspace_found = if (getParamString(params, "workspace_id")) |id_str| + findWorkspaceById(tm, id_str) orelse return "{\"error\":\"invalid workspace_id\"}" + else + null; + + const ws = if (workspace_found) |found| + found.ws + else if (getParamString(params, "surface_id")) |id_str| + if (findSurfaceGlobal(tm, id_str)) |found| found.ws else return "{\"error\":\"invalid surface_id\"}" + else + tm.selectedWorkspace() orelse return "{\"error\":\"no workspace\"}"; + + const target_id = if (getParamString(params, "surface_id")) |id_str| + if (workspace_found != null) + findSurfaceInWorkspace(ws, id_str) orelse return "{\"error\":\"invalid surface_id\"}" + else if (findSurfaceGlobal(tm, id_str)) |found| + found.id + else + return "{\"error\":\"invalid surface_id\"}" + else + ws.focused_panel_id orelse return "{\"error\":\"no focused surface\"}"; + + const tty_name_raw = getParamString(params, "tty_name") orelse return "{\"error\":\"missing tty_name\"}"; + const tty_name = std.mem.trim(u8, tty_name_raw, " \t\r\n"); + if (tty_name.len == 0) return "{\"error\":\"missing tty_name\"}"; + + const panel_ptr = ws.panels.getPtr(target_id) orelse return "{\"error\":\"invalid surface_id\"}"; + + // Allocate the new value first, then free the old one so a failed + // allocation never leaves the panel pointing at freed memory. + const new_tty = ws.alloc.dupe(u8, tty_name) catch return "{\"error\":\"out of memory\"}"; + if (panel_ptr.*.tty_name) |old| ws.alloc.free(old); + panel_ptr.*.tty_name = new_tty; + + const ws_hex = formatId(ws.id); + const panel_hex = formatId(panel_ptr.*.id); + + var buf = std.ArrayList(u8).empty; + defer buf.deinit(alloc); + const writer = buf.writer(alloc); + writer.print( + "{{\"workspace_id\":\"{s}\",\"surface_id\":\"{s}\",\"tty_name\":", + .{ @as([]const u8, &ws_hex), @as([]const u8, &panel_hex) }, + ) catch return "{\"error\":\"encode failed\"}"; + writeJsonString(writer, tty_name) catch return "{\"error\":\"encode failed\"}"; + writer.writeByte('}') catch return "{\"error\":\"encode failed\"}"; + return buf.toOwnedSlice(alloc) catch "{}"; +} + // ── Batch 3: Additional Pane Operations ───────────────────────────────── fn handlePaneFocus(_: Allocator, params: json.Value) []const u8 { diff --git a/tests_v2/test_surface_report_tty.py b/tests_v2/test_surface_report_tty.py new file mode 100644 index 000000000000..76a744c238ad --- /dev/null +++ b/tests_v2/test_surface_report_tty.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Socket API: surface.report_tty registers a TTY name on a panel. + +Linux's report_tty is a metadata-only mutation: there is no PortScanner / +remote workspace plumbing on Linux yet, so the handler simply duplicates +the supplied tty_name onto Panel.tty_name and echoes it back. + +This test verifies: + 1. report_tty without surface_id falls back to the focused surface + 2. report_tty with explicit surface_id targets that surface + 3. tty_name with surrounding whitespace is trimmed + 4. missing / empty / whitespace-only tty_name -> error + 5. invalid workspace_id / surface_id -> error + 6. response shape: {workspace_id, surface_id, tty_name} +""" + +import os +import re +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") + +UUID_HEX_RE = re.compile(r"^[0-9a-f]{32}$") + + +def _assert_uuid(label: str, value: object) -> None: + if not isinstance(value, str) or not UUID_HEX_RE.match(value): + raise cmuxError(f"{label} not a 32-char hex UUID: {value!r}") + + +def _expect_error(label: str, fn) -> None: + try: + fn() + except cmuxError: + return + raise cmuxError(f"{label}: expected error, call returned successfully") + + +def main() -> int: + with cmux(SOCKET_PATH) as c: + ws_id = c.new_workspace() + c.select_workspace(ws_id) + time.sleep(0.1) + + surfaces = c.list_surfaces() + if not surfaces: + raise cmuxError("Expected at least one surface in fresh workspace") + focused = next((s for s in surfaces if s[2]), None) + if focused is None: + raise cmuxError("No focused surface in fresh workspace") + focused_id = focused[1] + _assert_uuid("focused surface_id", focused_id) + + # 1. No surface_id — defaults to focused surface + res1 = c._call( + "surface.report_tty", + {"workspace_id": ws_id, "tty_name": "/dev/pts/42"}, + ) + if not isinstance(res1, dict): + raise cmuxError(f"report_tty (default surface) returned {res1!r}") + if res1.get("workspace_id") != ws_id: + raise cmuxError( + f"workspace_id echo mismatch: {res1.get('workspace_id')!r} vs {ws_id!r}" + ) + if res1.get("surface_id") != focused_id: + raise cmuxError( + f"default surface_id should equal focused {focused_id!r}, got {res1.get('surface_id')!r}" + ) + if res1.get("tty_name") != "/dev/pts/42": + raise cmuxError(f"tty_name echo mismatch: {res1.get('tty_name')!r}") + + # 2. Explicit surface_id targets that surface (here it's the same one) + res2 = c._call( + "surface.report_tty", + { + "workspace_id": ws_id, + "surface_id": focused_id, + "tty_name": "/dev/pts/99", + }, + ) + if res2.get("surface_id") != focused_id: + raise cmuxError(f"explicit surface_id mismatch: {res2!r}") + if res2.get("tty_name") != "/dev/pts/99": + raise cmuxError(f"second tty_name not echoed: {res2!r}") + + # 3. Surrounding whitespace gets trimmed + res3 = c._call( + "surface.report_tty", + {"workspace_id": ws_id, "tty_name": " /dev/pts/7\n"}, + ) + if res3.get("tty_name") != "/dev/pts/7": + raise cmuxError(f"tty_name not trimmed: {res3!r}") + + # 4. Empty / whitespace-only / missing tty_name -> error + _expect_error( + "missing tty_name", + lambda: c._call("surface.report_tty", {"workspace_id": ws_id}), + ) + _expect_error( + "empty tty_name", + lambda: c._call( + "surface.report_tty", + {"workspace_id": ws_id, "tty_name": ""}, + ), + ) + _expect_error( + "whitespace-only tty_name", + lambda: c._call( + "surface.report_tty", + {"workspace_id": ws_id, "tty_name": " \t\n"}, + ), + ) + + # 5. Invalid ids -> error + _expect_error( + "invalid workspace_id", + lambda: c._call( + "surface.report_tty", + {"workspace_id": "0" * 32, "tty_name": "/dev/pts/0"}, + ), + ) + _expect_error( + "invalid surface_id", + lambda: c._call( + "surface.report_tty", + { + "workspace_id": ws_id, + "surface_id": "0" * 32, + "tty_name": "/dev/pts/0", + }, + ), + ) + + # Cleanup + c.close_workspace(ws_id) + + print("PASS: surface.report_tty (default + explicit + trim + errors)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())