Fix CLI exit code on v1 auth errors

This commit is contained in:
Lawrence Chen 2026-02-22 01:37:42 -08:00
parent a205028b2e
commit ea87076fe4
2 changed files with 188 additions and 23 deletions

View file

@ -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 <text>")
}
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,

View file

@ -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 ──