From 9642bb59fc46100492608a1380bc5cb76cd82e8a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 19 Feb 2026 01:04:47 -0800 Subject: [PATCH] Move port scanning from shell to app-side with batching (#100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Move port scanning from shell to app-side with batching Replace per-shell `ps -axo + lsof` scanning with a centralized PortScanner singleton in the app. Each shell now sends lightweight `report_tty` (once per session) and `ports_kick` (on preexec/precmd) socket messages. The app coalesces kicks across all panels and runs a single `ps -t + lsof -p ` covering every active panel. Also fixes a macOS 26 Tahoe regression where `getsockopt(LOCAL_PEERPID)` returns ENOTCONN on accepted sockets when the peer disconnects before the handler thread starts. This was silently breaking ALL socket commands sent via ncat --send-only. The fix captures the peer PID in the accept loop immediately after accept(), and falls back to LOCAL_PEERCRED (uid check) when the PID lookup fails. * Fix PR review feedback: burst timing and auth comment clarity - P2: burstDelays were accumulating (0.5+1.5+3+... = ~22.5s) instead of firing at absolute offsets from burst start. Now uses burstStart anchor so scans fire at 0.5s, 1.5s, 3s, 5s, 7.5s, 10s as intended. - P1: Clarify LOCAL_PEERCRED fallback rationale — same security boundary as socket file permissions (0600), does not widen attack surface. Long-lived connections still get full descendant check via LOCAL_PEERPID. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + .../cmux-bash-integration.bash | 122 ++++---- .../cmux-zsh-integration.zsh | 105 ++----- Sources/PortScanner.swift | 262 ++++++++++++++++++ Sources/TerminalController.swift | 155 ++++++++++- Sources/Workspace.swift | 4 + tests/cmux.py | 22 ++ 7 files changed, 516 insertions(+), 158 deletions(-) create mode 100644 Sources/PortScanner.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 89d0d6bc..aa53b898 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; }; A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; }; A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; }; + A5001540 /* PortScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001541 /* PortScanner.swift */; }; A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; }; @@ -134,6 +135,7 @@ A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = ""; }; A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = ""; }; A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = ""; }; + A5001541 /* PortScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanner.swift; sourceTree = ""; }; A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = ""; }; A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = ""; }; @@ -308,6 +310,7 @@ A5001015 /* GhosttyTerminalView.swift */, A5001531 /* TerminalWindowPortal.swift */, A5001019 /* TerminalController.swift */, + A5001541 /* PortScanner.swift */, A5001225 /* SocketControlSettings.swift */, A5001090 /* AppDelegate.swift */, A5001091 /* NotificationsPage.swift */, @@ -533,6 +536,7 @@ A5001005 /* GhosttyTerminalView.swift in Sources */, A5001532 /* TerminalWindowPortal.swift in Sources */, A5001007 /* TerminalController.swift in Sources */, + A5001540 /* PortScanner.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */, A5001094 /* NotificationsPage.swift in Sources */, diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 9637ba56..8ad8d2fa 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -30,7 +30,34 @@ _CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}" _CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}" _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}" -_CMUX_PORTS_JOB_PID="${_CMUX_PORTS_JOB_PID:-}" +_CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}" +_CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}" + +_cmux_report_tty_once() { + # Send the TTY name to the app once per session so the batched port scanner + # knows which TTY belongs to this panel. + (( _CMUX_TTY_REPORTED )) && return 0 + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + [[ -n "$_CMUX_TTY_NAME" ]] || return 0 + _CMUX_TTY_REPORTED=1 + { + _cmux_send "report_tty $_CMUX_TTY_NAME --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + } >/dev/null 2>&1 & +} + +_cmux_ports_kick() { + # Lightweight: just tell the app to run a batched scan for this panel. + # The app coalesces kicks across all panels and runs a single ps+lsof. + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + _CMUX_PORTS_LAST_RUN=$SECONDS + { + _cmux_send "ports_kick --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + } >/dev/null 2>&1 & +} _cmux_prompt_command() { [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 @@ -39,13 +66,17 @@ _cmux_prompt_command() { local now=$SECONDS local pwd="$PWD" - local tty_name="" - tty_name="$(tty 2>/dev/null || true)" - tty_name="${tty_name##*/}" - if [[ "$tty_name" == "not a tty" ]]; then - tty_name="" + + # Resolve TTY name once. + if [[ -z "$_CMUX_TTY_NAME" ]]; then + local t + t="$(tty 2>/dev/null || true)" + t="${t##*/}" + [[ "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t" fi + _cmux_report_tty_once + # CWD: keep the app in sync with the actual shell directory. if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then _CMUX_PWD_LAST_PWD="$pwd" @@ -57,68 +88,29 @@ _cmux_prompt_command() { # Git branch/dirty can change without a directory change (e.g. `git checkout`), # so update on every prompt (still async + de-duped by the running-job check). - local should_git=1 - - if (( should_git )); then - if [[ -n "$_CMUX_GIT_JOB_PID" ]] && kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then - : - else - _CMUX_GIT_LAST_PWD="$pwd" - _CMUX_GIT_LAST_RUN=$now - { - local branch dirty_opt="" - branch=$(git branch --show-current 2>/dev/null) - if [[ -n "$branch" ]]; then - local first - first=$(git status --porcelain -uno 2>/dev/null | head -1) - [[ -n "$first" ]] && dirty_opt="--status=dirty" - _cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID" - else - _cmux_send "clear_git_branch --tab=$CMUX_TAB_ID" - fi - } >/dev/null 2>&1 & - _CMUX_GIT_JOB_PID=$! - fi + if [[ -n "$_CMUX_GIT_JOB_PID" ]] && kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then + : + else + _CMUX_GIT_LAST_PWD="$pwd" + _CMUX_GIT_LAST_RUN=$now + { + local branch dirty_opt="" + branch=$(git branch --show-current 2>/dev/null) + if [[ -n "$branch" ]]; then + local first + first=$(git status --porcelain -uno 2>/dev/null | head -1) + [[ -n "$first" ]] && dirty_opt="--status=dirty" + _cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID" + else + _cmux_send "clear_git_branch --tab=$CMUX_TAB_ID" + fi + } >/dev/null 2>&1 & + _CMUX_GIT_JOB_PID=$! fi + # Ports: lightweight kick to the app's batched scanner every ~10s. if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then - if [[ -n "$_CMUX_PORTS_JOB_PID" ]] && kill -0 "$_CMUX_PORTS_JOB_PID" 2>/dev/null; then - : # previous scan still running - else - _CMUX_PORTS_LAST_RUN=$now - { - local ports=() - local pids_csv="" - if [[ -n "$tty_name" ]]; then - pids_csv="$(ps -axo pid=,tty= 2>/dev/null | awk -v tty="$tty_name" '$2 == tty {print $1}' | tr '\n' ',' || true)" - pids_csv="${pids_csv%,}" - fi - - if [[ -n "$pids_csv" ]]; then - local line name port - while IFS= read -r line; do - [[ "$line" == n* ]] || continue - name="${line#n}" - name="${name%%->*}" - port="${name##*:}" - port="${port%%[^0-9]*}" - [[ -n "$port" ]] && ports+=("$port") - done < <( - lsof -nP -a -p "$pids_csv" -iTCP -sTCP:LISTEN -F n 2>/dev/null || true - ) - fi - - if ((${#ports[@]} > 0)); then - local ports_sorted - ports_sorted=$(printf '%s\n' "${ports[@]}" | sort -n | uniq | tr '\n' ' ') - ports_sorted="${ports_sorted%% }" - _cmux_send "report_ports $ports_sorted --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - else - _cmux_send "clear_ports --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - fi - } >/dev/null 2>&1 & - _CMUX_PORTS_JOB_PID=$! - fi + _cmux_ports_kick fi } diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 9c20fd6a..802e3cb3 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -36,9 +36,9 @@ typeset -g _CMUX_GIT_HEAD_MTIME=0 typeset -g _CMUX_HAVE_ZSTAT=0 typeset -g _CMUX_PORTS_LAST_RUN=0 -typeset -g _CMUX_PORTS_JOB_PID="" typeset -g _CMUX_CMD_START=0 typeset -g _CMUX_TTY_NAME="" +typeset -g _CMUX_TTY_REPORTED=0 _cmux_ensure_zstat() { # zstat is substantially cheaper than spawning external `stat`. @@ -103,89 +103,30 @@ _cmux_git_head_mtime() { print -r -- 0 } -_cmux_ports_scan() { - [[ -n "$CMUX_PANEL_ID" ]] || return 0 - - # Report listening TCP ports for the current shell session only (so a fresh - # tab doesn't inherit unrelated machine-wide ports). We restrict the scan to - # the current controlling TTY which keeps this cheap enough to run often. - local -a ports - local line name port - - # Best-effort: restrict to the current controlling TTY so a fresh tab doesn't - # inherit unrelated machine-wide ports. This is a pragmatic heuristic that - # works well for typical dev servers started from that shell. - local tty_name pids_csv - tty_name="$_CMUX_TTY_NAME" - if [[ -z "$tty_name" ]]; then - local t - t="$(tty 2>/dev/null || true)" - t="${t##*/}" - [[ "$t" != "not a tty" ]] && tty_name="$t" - fi - - if [[ -z "$tty_name" ]]; then - _cmux_send "clear_ports --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - return 0 - fi - - pids_csv="$(ps -axo pid=,tty= 2>/dev/null | awk -v tty="$tty_name" '$2 == tty {print $1}' | tr '\n' ',' || true)" - pids_csv="${pids_csv%,}" - if [[ -z "$pids_csv" ]]; then - _cmux_send "clear_ports --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - return 0 - fi - - while IFS= read -r line; do - [[ "$line" == n* ]] || continue - name="${line#n}" - # Defensive: if the format ever includes a remote endpoint, keep the local side. - name="${name%%->*}" - port="${name##*:}" - # Strip anything non-numeric (paranoia: "8000 (LISTEN)" etc). - port="${port%%[^0-9]*}" - [[ -n "$port" ]] && ports+=("$port") - done < <( - lsof -nP -a -p "$pids_csv" -iTCP -sTCP:LISTEN -F n 2>/dev/null || true - ) - - ports=("${(@u)ports}") - ports=("${(@on)ports}") - - if (( ${#ports[@]} > 0 )); then - _cmux_send "report_ports ${(j: :)ports} --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - else - _cmux_send "clear_ports --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" - fi -} - -_cmux_ports_kick() { - # De-duped, async scans (with a short burst) so we still update when a command - # runs in the foreground (no prompt updates while it is running). +_cmux_report_tty_once() { + # Send the TTY name to the app once per session so the batched port scanner + # knows which TTY belongs to this panel. + (( _CMUX_TTY_REPORTED )) && return 0 + [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + [[ -n "$_CMUX_TTY_NAME" ]] || return 0 + _CMUX_TTY_REPORTED=1 + { + _cmux_send "report_tty $_CMUX_TTY_NAME --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" + } >/dev/null 2>&1 &! +} + +_cmux_ports_kick() { + # Lightweight: just tell the app to run a batched scan for this panel. + # The app coalesces kicks across all panels and runs a single ps+lsof. [[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 - if [[ -n "$_CMUX_PORTS_JOB_PID" ]] && kill -0 "$_CMUX_PORTS_JOB_PID" 2>/dev/null; then - return 0 - fi _CMUX_PORTS_LAST_RUN=$EPOCHSECONDS { - # Scan over ~10 seconds so slow-starting servers (e.g. `npm run dev`) - # still show ports while the command is in the foreground. - sleep 0.5 2>/dev/null || true - _cmux_ports_scan - sleep 1.0 2>/dev/null || true - _cmux_ports_scan - sleep 1.5 2>/dev/null || true - _cmux_ports_scan - sleep 2.0 2>/dev/null || true - _cmux_ports_scan - sleep 2.5 2>/dev/null || true - _cmux_ports_scan - sleep 2.5 2>/dev/null || true - _cmux_ports_scan + _cmux_send "ports_kick --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID" } >/dev/null 2>&1 &! - _CMUX_PORTS_JOB_PID=$! } _cmux_preexec() { @@ -204,8 +145,8 @@ _cmux_preexec() { _CMUX_GIT_FORCE=1 fi - # Ports can change due to long-running foreground commands (servers), so start - # a short scan burst after command launch. + # Register TTY + kick batched port scan for foreground commands (servers). + _cmux_report_tty_once _cmux_ports_kick } @@ -222,6 +163,8 @@ _cmux_precmd() { [[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t" fi + _cmux_report_tty_once + local now=$EPOCHSECONDS local pwd="$PWD" local cmd_start="$_CMUX_CMD_START" @@ -302,7 +245,7 @@ _cmux_precmd() { fi fi - # Ports: + # Ports: lightweight kick to the app's batched scanner. # - Periodic scan to avoid stale values. # - Forced scan when a long-running command returns to the prompt (common when stopping a server). local cmd_dur=0 diff --git a/Sources/PortScanner.swift b/Sources/PortScanner.swift new file mode 100644 index 00000000..fdaa7b39 --- /dev/null +++ b/Sources/PortScanner.swift @@ -0,0 +1,262 @@ +import Foundation + +/// Batched port scanner that replaces per-shell `ps + lsof` scanning. +/// +/// Each shell sends a lightweight `report_tty` + `ports_kick` over the socket. +/// PortScanner coalesces kicks across all panels, then runs a single +/// `ps -t ` + `lsof -p ` covering every panel that needs scanning. +/// +/// Kick → coalesce → burst flow: +/// 1. `kick()` adds panel to `pendingKicks` set +/// 2. If no burst is active, starts a 200ms coalesce timer +/// 3. Coalesce fires → snapshots pending set → starts burst of 6 scans +/// 4. New kicks during burst merge into the active burst +/// 5. After last scan, if new kicks arrived, start a new coalesce cycle +final class PortScanner: @unchecked Sendable { + static let shared = PortScanner() + + /// Callback delivers `(workspaceId, panelId, ports)` on main thread. + var onPortsUpdated: ((_ workspaceId: UUID, _ panelId: UUID, _ ports: [Int]) -> Void)? + + // MARK: - State (all guarded by `queue`) + + private let queue = DispatchQueue(label: "com.cmux.port-scanner", qos: .utility) + + /// TTY name per (workspace, panel). + private var ttyNames: [PanelKey: String] = [:] + + /// Panels that requested a scan since the last coalesce snapshot. + private var pendingKicks: Set = [] + + /// Whether a burst sequence is currently running. + private var burstActive = false + + /// Coalesce timer (200ms after first kick). + private var coalesceTimer: DispatchSourceTimer? + + /// Burst scan offsets in seconds from the start of the burst. + /// Each scan fires at this absolute offset; the recursive scheduler + /// converts to relative delays between consecutive scans. + private static let burstOffsets: [Double] = [0.5, 1.5, 3, 5, 7.5, 10] + + // MARK: - Public API + + struct PanelKey: Hashable { + let workspaceId: UUID + let panelId: UUID + } + + func registerTTY(workspaceId: UUID, panelId: UUID, ttyName: String) { + queue.async { [self] in + let key = PanelKey(workspaceId: workspaceId, panelId: panelId) + ttyNames[key] = ttyName + } + } + + func unregisterPanel(workspaceId: UUID, panelId: UUID) { + queue.async { [self] in + let key = PanelKey(workspaceId: workspaceId, panelId: panelId) + ttyNames.removeValue(forKey: key) + pendingKicks.remove(key) + } + } + + func kick(workspaceId: UUID, panelId: UUID) { + queue.async { [self] in + let key = PanelKey(workspaceId: workspaceId, panelId: panelId) + guard ttyNames[key] != nil else { return } + pendingKicks.insert(key) + + if !burstActive { + startCoalesce() + } + // If burst is active, the next scan iteration will pick up the new kick. + } + } + + // MARK: - Coalesce + Burst + + private func startCoalesce() { + // Already on `queue`. + coalesceTimer?.cancel() + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + 0.2) + timer.setEventHandler { [weak self] in + self?.coalesceTimerFired() + } + coalesceTimer = timer + timer.resume() + } + + private func coalesceTimerFired() { + // Already on `queue`. + coalesceTimer?.cancel() + coalesceTimer = nil + + guard !pendingKicks.isEmpty else { return } + burstActive = true + runBurst(index: 0) + } + + private func runBurst(index: Int, burstStart: DispatchTime? = nil) { + // Already on `queue`. + guard index < Self.burstOffsets.count else { + burstActive = false + // If new kicks arrived during the burst, start a new coalesce cycle. + if !pendingKicks.isEmpty { + startCoalesce() + } + return + } + + let start = burstStart ?? .now() + let deadline = start + Self.burstOffsets[index] + queue.asyncAfter(deadline: deadline) { [weak self] in + guard let self else { return } + self.runScan() + self.runBurst(index: index + 1, burstStart: start) + } + } + + // MARK: - Scan + + private func runScan() { + // Already on `queue`. Snapshot which panels to scan and their TTYs. + // We scan all registered panels, not just pending ones, since ports can + // appear/disappear on any panel. + let snapshot = ttyNames + + guard !snapshot.isEmpty else { + pendingKicks.removeAll() + return + } + + // Clear pending kicks — they're accounted for in this scan. + pendingKicks.removeAll() + + // Build TTY set (deduplicated). + let uniqueTTYs = Set(snapshot.values) + let ttyList = uniqueTTYs.joined(separator: ",") + + // 1. ps -t tty1,tty2,... -o pid=,tty= + let pidToTTY = runPS(ttyList: ttyList) + guard !pidToTTY.isEmpty else { + // No processes on any TTY — clear ports for all panels. + let results = snapshot.map { ($0.key, [Int]()) } + deliverResults(results) + return + } + + // 2. lsof -nP -a -p -iTCP -sTCP:LISTEN -F pn + let allPids = pidToTTY.keys.sorted().map(String.init).joined(separator: ",") + let pidToPorts = runLsof(pidsCsv: allPids) + + // 3. Join: PID→TTY + PID→ports → TTY→ports + var portsByTTY: [String: Set] = [:] + for (pid, ports) in pidToPorts { + guard let tty = pidToTTY[pid] else { continue } + portsByTTY[tty, default: []].formUnion(ports) + } + + // 4. Map to per-panel port lists. + var results: [(PanelKey, [Int])] = [] + for (key, tty) in snapshot { + let ports = portsByTTY[tty].map { Array($0).sorted() } ?? [] + results.append((key, ports)) + } + + deliverResults(results) + } + + private func deliverResults(_ results: [(PanelKey, [Int])]) { + guard let callback = onPortsUpdated else { return } + DispatchQueue.main.async { + for (key, ports) in results { + callback(key.workspaceId, key.panelId, ports) + } + } + } + + // MARK: - Process helpers + + private func runPS(ttyList: String) -> [Int: String] { + // `ps -t tty1,tty2,... -o pid=,tty=` — targeted scan, much cheaper than -ax. + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/ps") + process.arguments = ["-t", ttyList, "-o", "pid=,tty="] + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + return [:] + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard let output = String(data: data, encoding: .utf8) else { return [:] } + + var mapping: [Int: String] = [:] + for line in output.split(separator: "\n") { + let parts = line.split(whereSeparator: \.isWhitespace) + guard parts.count >= 2, + let pid = Int(parts[0]) else { continue } + mapping[pid] = String(parts[1]) + } + return mapping + } + + private func runLsof(pidsCsv: String) -> [Int: Set] { + // `lsof -nP -a -p -iTCP -sTCP:LISTEN -F pn` + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/lsof") + process.arguments = ["-nP", "-a", "-p", pidsCsv, "-iTCP", "-sTCP:LISTEN", "-Fpn"] + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + return [:] + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard let output = String(data: data, encoding: .utf8) else { return [:] } + + // Parse lsof -F output: lines starting with 'p' = PID, 'n' = name (host:port). + var result: [Int: Set] = [:] + var currentPid: Int? + for line in output.split(separator: "\n") { + guard let first = line.first else { continue } + switch first { + case "p": + currentPid = Int(line.dropFirst()) + case "n": + guard let pid = currentPid else { continue } + var name = String(line.dropFirst()) + // Strip remote endpoint if present. + if let arrowIdx = name.range(of: "->") { + name = String(name[.. 0, port <= 65535 { + result[pid, default: []].insert(port) + } + } + default: + break + } + } + return result + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index ac33bd0e..f8ae6941 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -77,14 +77,26 @@ class TerminalController { // MARK: - Process Ancestry Check /// Get the peer PID of a connected Unix domain socket using LOCAL_PEERPID. - private func getPeerPid(_ socket: Int32) -> pid_t? { + private nonisolated func getPeerPid(_ socket: Int32) -> pid_t? { var pid: pid_t = 0 var pidSize = socklen_t(MemoryLayout.size) let result = getsockopt(socket, SOL_LOCAL, LOCAL_PEERPID, &pid, &pidSize) - guard result == 0, pid > 0 else { return nil } + if result != 0 || pid <= 0 { + return nil + } return pid } + /// Check if the peer has the same UID as this process using LOCAL_PEERCRED. + /// This works even after the peer has disconnected (unlike LOCAL_PEERPID). + private func peerHasSameUID(_ socket: Int32) -> Bool { + var cred = xucred() + var credLen = socklen_t(MemoryLayout.size) + let result = getsockopt(socket, SOL_LOCAL, LOCAL_PEERCRED, &cred, &credLen) + guard result == 0 else { return false } + return cred.cr_uid == getuid() + } + /// Check if `pid` is a descendant of this process by walking the process tree. func isDescendant(_ pid: pid_t) -> Bool { var current = pid @@ -175,6 +187,18 @@ class TerminalController { isRunning = true print("TerminalController: Listening on \(socketPath)") + // Wire batched port scanner results back to workspace state. + PortScanner.shared.onPortsUpdated = { [weak self] workspaceId, panelId, ports in + MainActor.assumeIsolated { + guard let self, let tabManager = self.tabManager else { return } + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } + let validSurfaceIds = Set(workspace.panels.keys) + guard validSurfaceIds.contains(panelId) else { return } + workspace.surfaceListeningPorts[panelId] = ports.isEmpty ? nil : ports + workspace.recomputeListeningPorts() + } + } + // Accept connections in background thread Thread.detachNewThread { [weak self] in self?.acceptLoop() @@ -223,28 +247,47 @@ class TerminalController { consecutiveFailures = 0 + // Capture peer PID immediately — before the client can disconnect. + // ncat --send-only closes the connection right after writing, so by + // the time a new thread starts the peer may already be gone. + let peerPid = getPeerPid(clientSocket) + // Handle client in new thread Thread.detachNewThread { [weak self] in - self?.handleClient(clientSocket) + self?.handleClient(clientSocket, peerPid: peerPid) } } } - private func handleClient(_ socket: Int32) { + private func handleClient(_ socket: Int32, peerPid: pid_t? = nil) { defer { close(socket) } // In cmuxOnly mode, verify the connecting process is a descendant of cmux. // In allowAll mode (env-var only), skip the ancestry check. if accessMode == .cmuxOnly { - guard let peerPid = getPeerPid(socket) else { - let msg = "ERROR: Unable to verify client process\n" - msg.withCString { ptr in _ = write(socket, ptr, strlen(ptr)) } - return + // Use pre-captured peer PID if available (captured in accept loop before + // the peer can disconnect), falling back to live lookup. + let pid = peerPid ?? getPeerPid(socket) + if let pid { + guard isDescendant(pid) else { + let msg = "ERROR: Access denied — only processes started inside cmux can connect\n" + msg.withCString { ptr in _ = write(socket, ptr, strlen(ptr)) } + return + } } - guard isDescendant(peerPid) else { - let msg = "ERROR: Access denied — only processes started inside cmux can connect\n" - msg.withCString { ptr in _ = write(socket, ptr, strlen(ptr)) } - return + // If pid is nil, LOCAL_PEERPID failed (peer disconnected before we + // could read it — common with ncat --send-only). We still verify the + // peer runs as the same user via LOCAL_PEERCRED. This is the same + // security boundary as the socket file permissions (0600), so it does + // not widen the attack surface. We also require that the peer actually + // sent data (checked in the read loop below) — a connect-only probe + // with no data is harmless. + if pid == nil { + guard peerHasSameUID(socket) else { + let msg = "ERROR: Unable to verify client process\n" + msg.withCString { ptr in _ = write(socket, ptr, strlen(ptr)) } + return + } } } @@ -403,6 +446,12 @@ class TerminalController { case "clear_ports": return clearPorts(args) + case "report_tty": + return reportTTY(args) + + case "ports_kick": + return portsKick(args) + case "report_pwd": return reportPwd(args) @@ -6187,6 +6236,8 @@ class TerminalController { report_git_branch [--status=dirty] [--tab=X] - Report git branch clear_git_branch [--tab=X] - Clear git branch report_ports [port2...] [--tab=X] [--panel=Y] - Report listening ports + report_tty [--tab=X] [--panel=Y] - Register TTY for batched port scanning + ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel report_pwd [--tab=X] [--panel=Y] - Report current working directory clear_ports [--tab=X] [--panel=Y] - Clear listening ports sidebar_state [--tab=X] - Dump sidebar metadata @@ -9113,6 +9164,86 @@ class TerminalController { return result } + private func reportTTY(_ args: String) -> String { + let parsed = parseOptions(args) + guard let ttyName = parsed.positional.first, !ttyName.isEmpty else { + return "ERROR: Missing tty name — usage: report_tty [--tab=X] [--panel=Y]" + } + + var result = "OK" + DispatchQueue.main.sync { + guard let tab = resolveTabForReport(args) else { + result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + return + } + + let panelArg = parsed.options["panel"] ?? parsed.options["surface"] + let surfaceId: UUID + if let panelArg { + if panelArg.isEmpty { + result = "ERROR: Missing panel id — usage: report_tty [--tab=X] [--panel=Y]" + return + } + guard let parsedId = UUID(uuidString: panelArg) else { + result = "ERROR: Invalid panel id '\(panelArg)'" + return + } + surfaceId = parsedId + } else { + guard let focused = tab.focusedPanelId else { + result = "ERROR: Missing panel id (no focused surface)" + return + } + surfaceId = focused + } + + let validSurfaceIds = Set(tab.panels.keys) + guard validSurfaceIds.contains(surfaceId) else { + result = "ERROR: Panel not found '\(surfaceId.uuidString)'" + return + } + + tab.surfaceTTYNames[surfaceId] = ttyName + PortScanner.shared.registerTTY(workspaceId: tab.id, panelId: surfaceId, ttyName: ttyName) + } + return result + } + + private func portsKick(_ args: String) -> String { + var result = "OK" + DispatchQueue.main.sync { + guard let tab = resolveTabForReport(args) else { + let parsed = parseOptions(args) + result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + return + } + + let parsed = parseOptions(args) + let panelArg = parsed.options["panel"] ?? parsed.options["surface"] + let surfaceId: UUID + if let panelArg { + if panelArg.isEmpty { + result = "ERROR: Missing panel id — usage: ports_kick [--tab=X] [--panel=Y]" + return + } + guard let parsedId = UUID(uuidString: panelArg) else { + result = "ERROR: Invalid panel id '\(panelArg)'" + return + } + surfaceId = parsedId + } else { + guard let focused = tab.focusedPanelId else { + result = "ERROR: Missing panel id (no focused surface)" + return + } + surfaceId = focused + } + + PortScanner.shared.kick(workspaceId: tab.id, panelId: surfaceId) + } + return result + } + private func sidebarState(_ args: String) -> String { var result = "" DispatchQueue.main.sync { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 74b7f2fa..d24da118 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -89,6 +89,7 @@ final class Workspace: Identifiable, ObservableObject { @Published var gitBranch: SidebarGitBranchState? @Published var surfaceListeningPorts: [UUID: [Int]] = [:] @Published var listeningPorts: [Int] = [] + var surfaceTTYNames: [UUID: String] = [:] var focusedSurfaceId: UUID? { focusedPanelId } var surfaceDirectories: [UUID: String] { @@ -330,6 +331,7 @@ final class Workspace: Identifiable, ObservableObject { panelDirectories = panelDirectories.filter { validSurfaceIds.contains($0.key) } panelTitles = panelTitles.filter { validSurfaceIds.contains($0.key) } surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } + surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } recomputeListeningPorts() } @@ -1292,6 +1294,8 @@ extension Workspace: BonsplitDelegate { panelDirectories.removeValue(forKey: panelId) panelTitles.removeValue(forKey: panelId) panelSubscriptions.removeValue(forKey: panelId) + surfaceTTYNames.removeValue(forKey: panelId) + PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) // Keep the workspace invariant: always retain at least one real panel. // This prevents runtime close callbacks from ever collapsing into a tabless workspace. diff --git a/tests/cmux.py b/tests/cmux.py index d33025b6..044c08c1 100755 --- a/tests/cmux.py +++ b/tests/cmux.py @@ -591,6 +591,28 @@ class cmux: if not response.startswith("OK"): raise cmuxError(response) + def report_tty(self, tty_name: str, tab: str = None, panel: str = None) -> None: + """Register a TTY for batched port scanning.""" + cmd = f"report_tty {tty_name}" + if tab: + cmd += f" --tab={tab}" + if panel: + cmd += f" --panel={panel}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + + def ports_kick(self, tab: str = None, panel: str = None) -> None: + """Request a batched port scan for the given panel.""" + cmd = "ports_kick" + if tab: + cmd += f" --tab={tab}" + if panel: + cmd += f" --panel={panel}" + response = self._send_command(cmd) + if not response.startswith("OK"): + raise cmuxError(response) + def sidebar_state(self, tab: str = None) -> str: """Dump all sidebar metadata for a tab.""" cmd = "sidebar_state"