Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
2107bcb
Rewrite amux backend in Rust
lawrencecchen Apr 6, 2026
4fba953
Fix Rust daemon event semantics and verification
lawrencecchen Apr 6, 2026
997876a
Fix Ghostty build drift on macOS and iOS
lawrencecchen Apr 6, 2026
2be1639
Fix amux backend compat and review gaps
lawrencecchen Apr 6, 2026
a72c88e
Fix cross-platform termios ioctls for session CLI
lawrencecchen Apr 6, 2026
fd12865
Fix remote daemon release asset CI
lawrencecchen Apr 6, 2026
0b73637
Fix compat and cross-target daemon builds
lawrencecchen Apr 6, 2026
246a668
Bundle Ghostty CLI helper in Xcode builds
lawrencecchen Apr 6, 2026
ef6c6ea
ci: skip Ghostty helper zig build on macOS workflows
lawrencecchen Apr 6, 2026
6b67bc3
fix: close remaining Rust daemon compat gaps
lawrencecchen Apr 6, 2026
90b5984
test: run remote compat suite against Rust daemon
lawrencecchen Apr 6, 2026
3edf1a7
build: link the Rust daemon against the right C++ runtime
lawrencecchen Apr 6, 2026
95c6190
fix: restore websocket startup and session guards
lawrencecchen Apr 6, 2026
c7ab5d3
fix: detach failed Go CLI attachments
lawrencecchen Apr 6, 2026
2f46fec
test: serialize websocket compat coverage
lawrencecchen Apr 6, 2026
aa6625c
test: report daemon exits in compat failures
lawrencecchen Apr 6, 2026
d283fcc
ci: run remote daemon tests without cgo
lawrencecchen Apr 6, 2026
05c65a3
debug: trace remote daemon stream failures
lawrencecchen Apr 6, 2026
a38314f
fix: keep ghostty vt stream bound to its terminal
lawrencecchen Apr 6, 2026
d5d6e45
test: run ghostty shim coverage in CI
lawrencecchen Apr 6, 2026
3afd9aa
ci: prepare ghostty shim for tests
lawrencecchen Apr 6, 2026
39d190a
app: default local daemon to rust
lawrencecchen Apr 6, 2026
6f5b2a2
Move local PTY path to direct Rust daemon bridge
lawrencecchen Apr 7, 2026
d23cbc5
Tighten PTY migration verification plan
lawrencecchen Apr 7, 2026
8603339
Add tmux parity and PTY bridge regressions
lawrencecchen Apr 7, 2026
36abf35
Fix CI artifact env paths
lawrencecchen Apr 7, 2026
241b097
Install zig for PTY bridge CI smoke
lawrencecchen Apr 7, 2026
8c95d73
test: stabilize Linux PTY parity regressions
lawrencecchen Apr 7, 2026
253014f
test: preserve TUI size across reattach coverage
lawrencecchen Apr 7, 2026
8f32c7c
fix Linux session attach resize parity
lawrencecchen Apr 7, 2026
c136b53
fix Linux PTY parity regressions
lawrencecchen Apr 7, 2026
dbe90cb
stabilize attach CLI exit coverage
lawrencecchen Apr 7, 2026
1f44572
fix Linux PTY test polling
lawrencecchen Apr 7, 2026
4431bd4
force WINCH after pane resize
lawrencecchen Apr 7, 2026
d083f7e
signal PTY foreground group on resize
lawrencecchen Apr 7, 2026
80815ac
wait for TUI repaint before reattach
lawrencecchen Apr 7, 2026
99a3593
use raw python fixture for tui resize tests
lawrencecchen Apr 7, 2026
0c7092a
Add cmux pty vs tmux parity regression in CI
lawrencecchen Apr 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive

- name: Setup Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
Expand Down
34 changes: 21 additions & 13 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ import Bonsplit
import IOSurface
import UniformTypeIdentifiers

// Ghostty still exports these embedded-surface helpers, but the current
// generated public header in this branch no longer declares them.
@_silgen_name("ghostty_surface_clear_selection")
private func cmuxGhosttySurfaceClearSelection(_ surface: ghostty_surface_t) -> Bool

@_silgen_name("ghostty_surface_select_cursor_cell")
private func cmuxGhosttySurfaceSelectCursorCell(_ surface: ghostty_surface_t) -> Bool

#if os(macOS)
func cmuxShouldUseTransparentBackgroundWindow() -> Bool {
let defaults = UserDefaults.standard
Expand Down Expand Up @@ -1868,10 +1876,10 @@ class GhosttyApp {

private func bellAudioPath() -> String? {
guard let config else { return nil }
var value = ghostty_config_path_s()
var value: UnsafePointer<Int8>?
let key = "bell-audio-path"
guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))),
let rawPath = value.path else {
let rawPath = value else {
return nil
}
let path = String(cString: rawPath)
Expand Down Expand Up @@ -4679,7 +4687,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
guard surface != nil else { return false }
setKeyboardCopyModeActive(!keyboardCopyModeActive)
if !keyboardCopyModeActive, let surface {
_ = ghostty_surface_clear_selection(surface)
_ = cmuxGhosttySurfaceClearSelection(surface)
}
return true
}
Expand All @@ -4690,13 +4698,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
keyboardCopyModeActive = active
if active, let surface {
keyboardCopyModeViewportRow = keyboardCopyModeSelectionAnchor(surface: surface)?.row
_ = ghostty_surface_clear_selection(surface)
_ = cmuxGhosttySurfaceClearSelection(surface)
if keyboardCopyModeViewportRow == nil {
keyboardCopyModeViewportRow = keyboardCopyModeImeViewportRow(surface: surface)
}
// Create a 1-cell selection at the terminal cursor to serve as a
// visible cursor indicator in copy mode.
_ = ghostty_surface_select_cursor_cell(surface)
_ = cmuxGhosttySurfaceSelectCursorCell(surface)
} else {
keyboardCopyModeViewportRow = nil
}
Expand Down Expand Up @@ -4733,7 +4741,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
private func keyboardCopyModeSelectionAnchor(surface: ghostty_surface_t) -> (row: Int, y: Double)? {
let size = ghostty_surface_size(surface)
guard size.rows > 0, size.columns > 0 else { return nil }
guard ghostty_surface_select_cursor_cell(surface) else { return nil }
guard cmuxGhosttySurfaceSelectCursorCell(surface) else { return nil }

var text = ghostty_text_s()
guard ghostty_surface_read_selection(surface, &text) else { return nil }
Expand All @@ -4754,7 +4762,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
guard let anchor = keyboardCopyModeSelectionAnchor(surface: surface) else { return }
keyboardCopyModeViewportRow = anchor.row
// Preserve the visible cursor indicator.
_ = ghostty_surface_select_cursor_cell(surface)
_ = cmuxGhosttySurfaceSelectCursorCell(surface)
}

private func copyCurrentViewportLinesToClipboard(
Expand All @@ -4769,7 +4777,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
guard let anchor = keyboardCopyModeSelectionAnchor(surface: surface) else {
return false
}
_ = ghostty_surface_clear_selection(surface)
_ = cmuxGhosttySurfaceClearSelection(surface)

var imeX: Double = 0
var imeY: Double = 0
Expand Down Expand Up @@ -4825,18 +4833,18 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {

switch action {
case .exit:
_ = ghostty_surface_clear_selection(surface)
_ = cmuxGhosttySurfaceClearSelection(surface)
setKeyboardCopyModeActive(false)
case .startSelection:
keyboardCopyModeVisualActive = true
case .clearSelection:
keyboardCopyModeVisualActive = false
_ = ghostty_surface_clear_selection(surface)
_ = cmuxGhosttySurfaceClearSelection(surface)
// Re-create 1-cell cursor at terminal cursor position.
_ = ghostty_surface_select_cursor_cell(surface)
_ = cmuxGhosttySurfaceSelectCursorCell(surface)
case .copyAndExit:
_ = performBindingAction("copy_to_clipboard")
_ = ghostty_surface_clear_selection(surface)
_ = cmuxGhosttySurfaceClearSelection(surface)
setKeyboardCopyModeActive(false)
case .copyLineAndExit:
let startRow = currentKeyboardCopyModeViewportRow(surface: surface)
Expand All @@ -4845,7 +4853,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
startRow: startRow,
lineCount: count
)
_ = ghostty_surface_clear_selection(surface)
_ = cmuxGhosttySurfaceClearSelection(surface)
setKeyboardCopyModeActive(false)
case let .scrollLines(delta):
_ = performBindingAction("scroll_page_lines:\(delta * count)")
Expand Down
22 changes: 0 additions & 22 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,28 +224,6 @@ struct WorkspaceRemoteDaemonManifest: Decodable, Equatable {
}
}

private struct BonsplitCompatibilityTabIDPayload: Codable {
let id: UUID
}

extension TabID {
var uuid: UUID {
if let id = Mirror(reflecting: self).children.first(where: { $0.label == "id" })?.value as? UUID {
return id
}

let decoder = JSONDecoder()
let encoder = JSONEncoder()

do {
let data = try encoder.encode(self)
return try decoder.decode(BonsplitCompatibilityTabIDPayload.self, from: data).id
} catch {
preconditionFailure("Failed to read Bonsplit TabID compatibility payload: \(error)")
}
}
}

extension Workspace {
private static var compatibilityToggleZoomContextAction: TabContextAction? {
TabContextAction(rawValue: "toggleZoom")
Expand Down
5 changes: 4 additions & 1 deletion cmuxTests/TabManagerUnitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -989,7 +989,10 @@ final class BonsplitZoomCompatibilityTests: XCTestCase {
receivedPaneId = incomingPaneId
}

let paneId = controller.allPaneIds.first
guard let paneId = controller.allPaneIds.first else {
XCTFail("Expected a default Bonsplit pane")
return
}
controller.onTabCloseRequest?(tabId, paneId)

XCTAssertEqual(receivedTabId?.uuid, tabId.uuid)
Expand Down
13 changes: 13 additions & 0 deletions daemon/remote/cmd/cmuxd-remote/ioctl_unix_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build darwin

package main

import "syscall"

func ioctlReadTermiosRequest() uintptr {
return syscall.TIOCGETA
}

func ioctlWriteTermiosRequest() uintptr {
return syscall.TIOCSETA
}
13 changes: 13 additions & 0 deletions daemon/remote/cmd/cmuxd-remote/ioctl_unix_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build linux

package main

import "syscall"

func ioctlReadTermiosRequest() uintptr {
return syscall.TCGETS
}

func ioctlWriteTermiosRequest() uintptr {
return syscall.TCSETS
}
118 changes: 115 additions & 3 deletions daemon/remote/cmd/cmuxd-remote/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
fs.SetOutput(stderr)
stdio := fs.Bool("stdio", false, "serve over stdin/stdout")
unixMode := fs.Bool("unix", false, "serve over a Unix socket")
socketPath := fs.String("socket", "", "Unix socket path")
tlsMode := fs.Bool("tls", false, "serve over TLS")
listenAddr := fs.String("listen", "", "TLS listen address")
serverID := fs.String("server-id", "", "server identifier for ticket verification")
Expand All @@ -63,8 +65,18 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if err := fs.Parse(args[1:]); err != nil {
return 2
}
if *stdio == *tlsMode {
_, _ = fmt.Fprintln(stderr, "serve requires exactly one of --stdio or --tls")
modeCount := 0
if *stdio {
modeCount++
}
if *unixMode {
modeCount++
}
if *tlsMode {
modeCount++
}
if modeCount != 1 {
_, _ = fmt.Fprintln(stderr, "serve requires exactly one of --stdio, --unix, or --tls")
return 2
}
if *stdio {
Expand All @@ -74,6 +86,13 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
}
return 0
}
if *unixMode {
if err := runUnixServer(*socketPath); err != nil {
_, _ = fmt.Fprintf(stderr, "serve failed: %v\n", err)
return 1
}
return 0
}
if err := runTLSServer(direct.Config{
ServerID: *serverID,
TicketSecret: []byte(*ticketSecret),
Expand All @@ -85,8 +104,12 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 1
}
return 0
case "session":
return runSessionCLI(args[1:])
case "cli":
return runCLI(args[1:])
case "list", "ls", "attach", "status", "history", "kill", "new":
return runSessionCLI(args)
default:
usage(stderr)
return 2
Expand All @@ -97,7 +120,9 @@ func usage(w io.Writer) {
_, _ = fmt.Fprintln(w, "Usage:")
_, _ = fmt.Fprintln(w, " cmuxd-remote version")
_, _ = fmt.Fprintln(w, " cmuxd-remote serve --stdio")
_, _ = fmt.Fprintln(w, " cmuxd-remote serve --unix --socket <path>")
_, _ = fmt.Fprintln(w, " cmuxd-remote serve --tls --listen <addr> --server-id <id> --ticket-secret <secret> --cert-file <path> --key-file <path>")
_, _ = fmt.Fprintln(w, " cmuxd-remote session <command> [args...]")
_, _ = fmt.Fprintln(w, " cmuxd-remote cli <command> [args...]")
}

Expand All @@ -107,6 +132,42 @@ func runStdioServer(stdin io.Reader, stdout io.Writer) error {
return rpc.NewServer(server.handleRequest).Serve(stdin, stdout)
}

func runUnixServer(socketPath string) error {
if socketPath == "" {
return errors.New("unix server requires --socket")
}
if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil {
return err
}
_ = os.Remove(socketPath)

listener, err := net.Listen("unix", socketPath)
if err != nil {
return err
}
defer func() {
_ = listener.Close()
_ = os.Remove(socketPath)
}()

server := newDaemonServer()
defer server.closeAll()

for {
conn, err := listener.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return nil
}
return err
}
go func(conn net.Conn) {
defer conn.Close()
_ = rpc.NewServer(server.handleRequest).Serve(conn, conn)
Comment on lines +164 to +166
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Authenticate Unix peers before dispatching RPCs

The new Unix server accepts each client and immediately hands the socket to rpc.NewServer(...).Serve without validating the peer UID, so any local user who can reach the socket can issue privileged methods (for example terminal.open/terminal.write). This is a regression from the prior Unix path, which explicitly called local_peer_auth.authorizeClient in daemon/remote/zig/src/serve_unix.zig, and it is made riskier here by creating the socket directory with 0755 defaults.

Useful? React with 👍 / 👎.

}(conn)
}
}

func runTLSServer(cfg direct.Config) error {
server := newDaemonServer()
defer server.closeAll()
Expand Down Expand Up @@ -181,6 +242,10 @@ func (s *daemonServer) handleRequest(req rpc.Request) rpc.Response {
return s.handleSessionDetach(req)
case "session.status":
return s.handleSessionStatus(req)
case "session.list":
return s.handleSessionList(req)
case "session.history":
return s.handleSessionHistory(req)
case "terminal.open":
return s.handleTerminalOpen(req)
case "terminal.read":
Expand Down Expand Up @@ -699,6 +764,52 @@ func (s *daemonServer) handleSessionStatus(req rpc.Request) rpc.Response {
}
}

func (s *daemonServer) handleSessionList(req rpc.Request) rpc.Response {
sessions := s.sessions.List()
result := make([]map[string]any, 0, len(sessions))
for _, status := range sessions {
result = append(result, map[string]any{
"session_id": status.SessionID,
"attachment_count": len(status.Attachments),
"effective_cols": status.EffectiveCols,
"effective_rows": status.EffectiveRows,
})
}
return rpc.Response{
ID: req.ID,
OK: true,
Result: map[string]any{"sessions": result},
}
}

func (s *daemonServer) handleSessionHistory(req rpc.Request) rpc.Response {
sessionID, ok := getStringParam(req.Params, "session_id")
if !ok || sessionID == "" {
return rpc.Response{
ID: req.ID,
OK: false,
Error: &rpc.Error{
Code: "invalid_params",
Message: "session.history requires session_id",
},
}
}

history, err := s.terminals.History(sessionID)
if err != nil {
return rpc.Response{
ID: req.ID,
OK: false,
Error: terminalError(err),
}
}
return rpc.Response{
ID: req.ID,
OK: true,
Result: map[string]any{"session_id": sessionID, "history": string(history)},
}
}

func (s *daemonServer) handleTerminalOpen(req rpc.Request) rpc.Response {
command, ok := getStringParam(req.Params, "command")
if !ok || command == "" {
Expand Down Expand Up @@ -735,7 +846,8 @@ func (s *daemonServer) handleTerminalOpen(req rpc.Request) rpc.Response {
}
}

sessionID, attachmentID := s.sessions.Open(cols, rows)
requestedSessionID, _ := getStringParam(req.Params, "session_id")
sessionID, attachmentID := s.sessions.Open(requestedSessionID, cols, rows)
status, err := s.sessions.Status(sessionID)
if err != nil {
return rpc.Response{ID: req.ID, OK: false, Error: sessionError(err)}
Expand Down
6 changes: 3 additions & 3 deletions daemon/remote/cmd/cmuxd-remote/main_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package main

import (
"encoding/base64"
"bufio"
"encoding/base64"
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -131,8 +131,8 @@ func TestServeStdioSupportsTerminalOpenReadAndWrite(t *testing.T) {
t.Fatalf("terminal.read echo result missing: %+v", readEcho)
}
echoChunk := decodeBase64Field(t, echoResult, "data")
if string(echoChunk) != "hello\r\n" {
t.Fatalf("echo chunk = %q, want %q", string(echoChunk), "hello\r\n")
if string(echoChunk) != "hello\n" {
t.Fatalf("echo chunk = %q, want %q", string(echoChunk), "hello\n")
}

_ = stdinW.Close()
Expand Down
Loading
Loading