From ea87076fe4450cd1fd7e72401dbaae07bce12c3a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 22 Feb 2026 01:37:42 -0800 Subject: [PATCH] Fix CLI exit code on v1 auth errors --- CLI/cmux.swift | 42 +++++---- tests/test_socket_access.py | 169 ++++++++++++++++++++++++++++++++++-- 2 files changed, 188 insertions(+), 23 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index d72798aa..c7d6b8d6 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -539,7 +539,7 @@ struct CMUXCLI { switch command { case "ping": - let response = try client.send(command: "ping") + let response = try sendV1Command("ping", client: client) print(response) case "capabilities": @@ -582,7 +582,7 @@ struct CMUXCLI { print(jsonString(formatIDs(response, mode: idFormat))) case "list-windows": - let response = try client.send(command: "list_windows") + let response = try sendV1Command("list_windows", client: client) if jsonOutput { let windows = parseWindows(response) let payload = windows.map { item -> [String: Any] in @@ -601,7 +601,7 @@ struct CMUXCLI { } case "current-window": - let response = try client.send(command: "current_window") + let response = try sendV1Command("current_window", client: client) if jsonOutput { print(jsonString(["window_id": response])) } else { @@ -609,21 +609,21 @@ struct CMUXCLI { } case "new-window": - let response = try client.send(command: "new_window") + let response = try sendV1Command("new_window", client: client) print(response) case "focus-window": guard let target = optionValue(commandArgs, name: "--window") else { throw CLIError(message: "focus-window requires --window") } - let response = try client.send(command: "focus_window \(target)") + let response = try sendV1Command("focus_window \(target)", client: client) print(response) case "close-window": guard let target = optionValue(commandArgs, name: "--window") else { throw CLIError(message: "close-window requires --window") } - let response = try client.send(command: "close_window \(target)") + let response = try sendV1Command("close_window \(target)", client: client) print(response) case "move-workspace-to-window": @@ -685,7 +685,7 @@ struct CMUXCLI { if let unknown = remaining.first(where: { $0.hasPrefix("--") }) { throw CLIError(message: "new-workspace: unknown flag '\(unknown)'. Known flags: --command ") } - let response = try client.send(command: "new_workspace") + let response = try sendV1Command("new_workspace", client: client) print(response) if let commandText = commandOpt { guard response.hasPrefix("OK ") else { @@ -830,11 +830,11 @@ struct CMUXCLI { guard let direction = rem1.first else { throw CLIError(message: "drag-surface-to-split requires a direction") } - let response = try client.send(command: "drag_surface_to_split \(surface) \(direction)") + let response = try sendV1Command("drag_surface_to_split \(surface) \(direction)", client: client) print(response) case "refresh-surfaces": - let response = try client.send(command: "refresh_surfaces") + let response = try sendV1Command("refresh_surfaces", client: client) print(response) case "surface-health": @@ -950,7 +950,7 @@ struct CMUXCLI { printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) case "current-workspace": - let response = try client.send(command: "current_workspace") + let response = try sendV1Command("current_workspace", client: client) if jsonOutput { print(jsonString(["workspace_id": response])) } else { @@ -1074,11 +1074,11 @@ struct CMUXCLI { let targetSurface = try resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: client) let payload = "\(title)|\(subtitle)|\(body)" - let response = try client.send(command: "notify_target \(targetWorkspace) \(targetSurface) \(payload)") + let response = try sendV1Command("notify_target \(targetWorkspace) \(targetSurface) \(payload)", client: client) print(response) case "list-notifications": - let response = try client.send(command: "list_notifications") + let response = try sendV1Command("list_notifications", client: client) if jsonOutput { let notifications = parseNotifications(response) let payload = notifications.map { item in @@ -1099,7 +1099,7 @@ struct CMUXCLI { } case "clear-notifications": - let response = try client.send(command: "clear_notifications") + let response = try sendV1Command("clear_notifications", client: client) print(response) case "claude-hook": @@ -1107,11 +1107,11 @@ struct CMUXCLI { case "set-app-focus": guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") } - let response = try client.send(command: "set_app_focus \(value)") + let response = try sendV1Command("set_app_focus \(value)", client: client) print(response) case "simulate-app-active": - let response = try client.send(command: "simulate_app_active") + let response = try sendV1Command("simulate_app_active", client: client) print(response) case "capture-pane", @@ -1191,6 +1191,14 @@ struct CMUXCLI { } } + private func sendV1Command(_ command: String, client: SocketClient) throws -> String { + let response = try client.send(command: command) + if response.hasPrefix("ERROR:") { + throw CLIError(message: response) + } + return response + } + private func resolvedIDFormat(jsonOutput: Bool, raw: String?) throws -> CLIIDFormat { _ = jsonOutput if let parsed = try CLIIDFormat.parse(raw) { @@ -4086,7 +4094,7 @@ struct CMUXCLI { let subtitle = sanitizeNotificationField(completion.subtitle) let body = sanitizeNotificationField(completion.body) let payload = "\(title)|\(subtitle)|\(body)" - let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)") + let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) print(response) } else { print("OK") @@ -4126,7 +4134,7 @@ struct CMUXCLI { ) } - let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)") + let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) _ = try? setClaudeStatus( client: client, workspaceId: workspaceId, diff --git a/tests/test_socket_access.py b/tests/test_socket_access.py index 48929bd2..ab24627b 100644 --- a/tests/test_socket_access.py +++ b/tests/test_socket_access.py @@ -22,6 +22,7 @@ import tempfile import time import json import glob +import plistlib sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from cmux import cmux, cmuxError @@ -70,22 +71,123 @@ def _raw_send(sock, command: str, timeout: float = 3.0) -> str: return data.decode().strip() +def _preferred_worktree_slug(): + env_slug = os.environ.get("CMUX_TAG") or os.environ.get("CMUX_BRANCH_SLUG") + if env_slug: + return env_slug.strip().lower() + + cwd = os.getcwd() + marker = "/worktrees/" + if marker in cwd: + tail = cwd.split(marker, 1)[1] + slug = tail.split("/", 1)[0].strip().lower() + if slug: + return slug + return "" + + +def _derived_app_candidates_for_current_worktree(): + project_path = os.path.realpath(os.path.join(os.getcwd(), "GhosttyTabs.xcodeproj")) + info_paths = glob.glob(os.path.expanduser( + "~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*/info.plist" + )) + matches = [] + for info_path in info_paths: + try: + with open(info_path, "rb") as f: + info = plistlib.load(f) + except Exception: + continue + workspace_path = info.get("WorkspacePath") + if not workspace_path: + continue + if os.path.realpath(workspace_path) != project_path: + continue + derived_root = os.path.dirname(info_path) + app_path = os.path.join(derived_root, "Build/Products/Debug/cmux DEV.app") + if os.path.exists(app_path): + matches.append(app_path) + return matches + + def _find_app(): explicit = os.environ.get("CMUX_APP_PATH") if explicit and os.path.exists(explicit): return explicit + preferred_slug = _preferred_worktree_slug() + if preferred_slug: + preferred_tmp = [] + preferred_tmp.extend(glob.glob(f"/tmp/cmux-{preferred_slug}/Build/Products/Debug/cmux DEV*.app")) + preferred_tmp.extend(glob.glob(f"/private/tmp/cmux-{preferred_slug}/Build/Products/Debug/cmux DEV*.app")) + preferred_tmp = [p for p in preferred_tmp if os.path.exists(p)] + if preferred_tmp: + preferred_tmp.sort(key=os.path.getmtime, reverse=True) + return preferred_tmp[0] + + direct_matches = _derived_app_candidates_for_current_worktree() + if direct_matches: + direct_matches.sort(key=os.path.getmtime, reverse=True) + return direct_matches[0] + + home = os.path.expanduser("~") + derived_candidates = glob.glob(os.path.join( + home, "Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux DEV.app" + )) + tmp_candidates = [] + tmp_candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux DEV*.app")) + tmp_candidates.extend(glob.glob("/private/tmp/cmux-*/Build/Products/Debug/cmux DEV*.app")) + + derived_candidates = [p for p in derived_candidates if os.path.exists(p)] + tmp_candidates = [p for p in tmp_candidates if os.path.exists(p)] + + if preferred_slug: + preferred_derived = [p for p in derived_candidates if preferred_slug in p.lower()] + preferred_tmp = [p for p in tmp_candidates if preferred_slug in p.lower()] + if preferred_derived: + derived_candidates = preferred_derived + if preferred_tmp: + tmp_candidates = preferred_tmp + + if derived_candidates: + derived_candidates.sort(key=os.path.getmtime, reverse=True) + return derived_candidates[0] + + if tmp_candidates: + tmp_candidates.sort(key=os.path.getmtime, reverse=True) + return tmp_candidates[0] + + return "" + + +def _find_cli(preferred_app_path: str = ""): + explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI") + if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK): + return explicit + + if preferred_app_path: + debug_dir = os.path.dirname(preferred_app_path) + sibling = os.path.join(debug_dir, "cmux") + if os.path.exists(sibling) and os.access(sibling, os.X_OK): + return sibling + candidates = [] home = os.path.expanduser("~") candidates.extend(glob.glob(os.path.join( - home, "Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux DEV.app" + home, "Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux" ))) - candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux DEV*.app")) - candidates.extend(glob.glob("/private/tmp/cmux-*/Build/Products/Debug/cmux DEV*.app")) - - candidates = [p for p in candidates if os.path.exists(p)] + candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")) + candidates.extend(glob.glob("/private/tmp/cmux-*/Build/Products/Debug/cmux")) + candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)] if not candidates: return "" + + preferred_slug = _preferred_worktree_slug() + if preferred_slug: + preferred = [p for p in candidates if preferred_slug in p.lower()] + if preferred: + candidates = preferred + candidates.sort(key=os.path.getmtime, reverse=True) return candidates[0] @@ -131,7 +233,7 @@ def _launch_cmux(app_path: str, socket_path: str, mode: str = None, extra_env: d launch_env.update(extra_env) for key, value in launch_env.items(): env_args.extend(["--env", f"{key}={value}"]) - subprocess.Popen(["open", "-a", app_path] + env_args) + subprocess.Popen(["open", "-na", app_path] + env_args) if not _wait_for_socket(socket_path): raise RuntimeError(f"Socket {socket_path} not created after launch") time.sleep(8) @@ -475,6 +577,60 @@ def test_password_mode_v2_auth_flow(socket_path: str, app_path: str) -> TestResu return result +def test_password_mode_cli_exit_code(socket_path: str, app_path: str) -> TestResult: + """Verify CLI exits non-zero on auth-required and succeeds with --password.""" + result = TestResult("Password mode CLI exit code") + password = f"cmux-pass-{os.getpid()}" + try: + cli_path = _find_cli(preferred_app_path=app_path) + if not cli_path: + result.failure("Could not find cmux CLI binary") + return result + + _kill_cmux(app_path) + _launch_cmux( + app_path, + socket_path, + mode="password", + extra_env={"CMUX_SOCKET_PASSWORD": password} + ) + + no_auth = subprocess.run( + [cli_path, "--socket", socket_path, "ping"], + capture_output=True, + text=True, + timeout=10 + ) + combined = f"{no_auth.stdout}\n{no_auth.stderr}" + if no_auth.returncode == 0: + result.failure("CLI ping without password exited 0 in password mode") + return result + if "Authentication required" not in combined: + result.failure(f"Unexpected unauthenticated CLI output: {combined!r}") + return result + + with_auth = subprocess.run( + [cli_path, "--socket", socket_path, "--password", password, "ping"], + capture_output=True, + text=True, + timeout=10 + ) + if with_auth.returncode != 0: + result.failure( + f"CLI ping with password failed: exit={with_auth.returncode} " + f"stdout={with_auth.stdout!r} stderr={with_auth.stderr!r}" + ) + return result + if "PONG" not in with_auth.stdout: + result.failure(f"Expected PONG with password, got: {with_auth.stdout!r}") + return result + + result.success("CLI exits non-zero for auth_required and succeeds with --password") + except Exception as e: + result.failure(f"{type(e).__name__}: {e}") + return result + + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -545,6 +701,7 @@ def run_tests(): run_test(test_password_mode_requires_auth, socket_path, app_path) run_test(test_password_mode_v1_auth_flow, socket_path, app_path) run_test(test_password_mode_v2_auth_flow, socket_path, app_path) + run_test(test_password_mode_cli_exit_code, socket_path, app_path) print() # ── Cleanup: leave cmux in cmuxOnly mode ──