From d1f765ba8c05dc2c779d8628ac1477d1d80aa0ec Mon Sep 17 00:00:00 2001 From: Jeffrey Burt Date: Mon, 10 Aug 2026 15:17:44 -0700 Subject: [PATCH 01/14] fix: preserve launch path in app bundle --- scripts/restart-environment-lib.sh | 66 +++++++++++ scripts/restart-environment-lib.test.sh | 151 ++++++++++++++++++++++++ scripts/restart.sh | 28 +++-- 3 files changed, 237 insertions(+), 8 deletions(-) create mode 100755 scripts/restart-environment-lib.sh create mode 100755 scripts/restart-environment-lib.test.sh diff --git a/scripts/restart-environment-lib.sh b/scripts/restart-environment-lib.sh new file mode 100755 index 00000000..d726918b --- /dev/null +++ b/scripts/restart-environment-lib.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Pure launch-environment helpers for restart.sh. This file is safe to source: +# it defines functions only and does not mutate a bundle or launch processes. + +require_restart_path() { + local launch_path="${1-}" + if [ -z "$launch_path" ]; then + echo "error: PATH must be set and non-empty" >&2 + return 1 + fi +} + +write_restart_environment_plist() { + local source_plist="$1" + local generated_plist="$2" + local launch_path="${3-}" + local working_plist + local expected_path + local extracted_path + + require_restart_path "$launch_path" || return 1 + + working_plist="$(mktemp "${generated_plist}.tmp.XXXXXX")" || return 1 + expected_path="$(mktemp "${generated_plist}.expected.XXXXXX")" || { + rm -f "$working_plist" + return 1 + } + extracted_path="$(mktemp "${generated_plist}.actual.XXXXXX")" || { + rm -f "$working_plist" "$expected_path" + return 1 + } + + if ! cp "$source_plist" "$working_plist" \ + || ! plutil -lint "$working_plist" >/dev/null; then + rm -f "$working_plist" "$expected_path" "$extracted_path" + return 1 + fi + + plutil -remove LSEnvironment "$working_plist" >/dev/null 2>&1 || true + + if ! plutil -insert LSEnvironment -xml '' "$working_plist" \ + || ! plutil -insert LSEnvironment.PATH -string "$launch_path" "$working_plist" \ + || ! plutil -lint "$working_plist" >/dev/null \ + || ! plutil -extract LSEnvironment.PATH raw -o "$extracted_path" "$working_plist"; then + rm -f "$working_plist" "$expected_path" "$extracted_path" + return 1 + fi + + printf '%s' "$launch_path" > "$expected_path" + if ! cmp -s "$expected_path" "$extracted_path"; then + echo "error: generated LSEnvironment.PATH does not match PATH" >&2 + rm -f "$working_plist" "$expected_path" "$extracted_path" + return 1 + fi + + if ! mv "$working_plist" "$generated_plist"; then + rm -f "$working_plist" "$expected_path" "$extracted_path" + return 1 + fi + rm -f "$expected_path" "$extracted_path" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + echo "error: source this helper from restart.sh or its test harness" >&2 + exit 64 +fi diff --git a/scripts/restart-environment-lib.test.sh b/scripts/restart-environment-lib.test.sh new file mode 100755 index 00000000..29387500 --- /dev/null +++ b/scripts/restart-environment-lib.test.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# Tests for scripts/restart-environment-lib.sh. +# Run: bash scripts/restart-environment-lib.test.sh +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$HERE/.." && pwd)" +HELPER="$HERE/restart-environment-lib.sh" +SOURCE_PLIST="$REPO_ROOT/Resources/TBDApp.Info.plist" + +if [ ! -f "$HELPER" ]; then + echo "FAIL - restart environment helper is missing: $HELPER" + exit 1 +fi + +# shellcheck source=/dev/null +source "$HELPER" + +FAIL=0 +TEST_TMP="$(mktemp -d "${TMPDIR:-/tmp}/restart-environment-test.XXXXXX")" +trap 'rm -rf "$TEST_TMP"' EXIT + +pass() { + echo "ok - $1" +} + +fail() { + echo "FAIL - $1" + FAIL=1 +} + +assert_ok() { + local description="$1" + shift + if "$@" >/dev/null 2>&1; then + pass "$description" + else + fail "$description: expected success" + fi +} + +assert_fail() { + local description="$1" + shift + if "$@" >/dev/null 2>&1; then + fail "$description: expected failure" + else + pass "$description" + fi +} + +assert_plist_path() { + local description="$1" + local plist="$2" + local expected_path="$3" + local expected_file="$TEST_TMP/expected-$RANDOM" + local actual_file="$TEST_TMP/actual-$RANDOM" + + printf '%s' "$expected_path" > "$expected_file" + if ! plutil -extract LSEnvironment.PATH raw -o "$actual_file" "$plist" >/dev/null 2>&1; then + fail "$description: could not extract LSEnvironment.PATH" + return + fi + if cmp -s "$expected_file" "$actual_file"; then + pass "$description" + else + fail "$description: PATH bytes differ" + fi +} + +test_exact_path_round_trip() { + local generated="$TEST_TMP/exact.plist" + local launch_path='/opt/tools with spaces/bin:/usr/bin:/opt/tools with spaces/bin:/bin' + + if write_restart_environment_plist "$SOURCE_PLIST" "$generated" "$launch_path" >/dev/null 2>&1; then + pass "helper writes a generated plist" + else + fail "helper writes a generated plist: expected success" + return + fi + assert_plist_path "PATH round-trips exactly with spaces and repeated entries" "$generated" "$launch_path" + assert_ok "generated plist passes plutil lint" plutil -lint "$generated" +} + +test_stale_environment_is_replaced() { + local generated="$TEST_TMP/stale-generated.plist" + local launch_path='/new/tools:/usr/bin' + + cp "$SOURCE_PLIST" "$generated" + plutil -insert LSEnvironment -xml 'PATH/stale/pathSTALEvalue' "$generated" + + if ! write_restart_environment_plist "$SOURCE_PLIST" "$generated" "$launch_path" >/dev/null 2>&1; then + fail "helper replaces a stale LSEnvironment: expected success" + return + fi + assert_plist_path "stale LSEnvironment.PATH is replaced" "$generated" "$launch_path" + assert_fail "other stale LSEnvironment values are removed" \ + plutil -extract LSEnvironment.STALE raw -o - "$generated" +} + +test_empty_path_is_rejected() { + local generated="$TEST_TMP/empty.plist" + + assert_fail "empty PATH is rejected" \ + write_restart_environment_plist "$SOURCE_PLIST" "$generated" "" + if [ -e "$generated" ]; then + fail "empty PATH does not leave a generated plist" + else + pass "empty PATH does not leave a generated plist" + fi +} + +test_malformed_plist_is_rejected() { + local malformed="$TEST_TMP/malformed.plist" + local generated="$TEST_TMP/malformed-generated.plist" + + printf '%s\n' 'not a plist' > "$malformed" + assert_fail "malformed source plist is rejected" \ + write_restart_environment_plist "$malformed" "$generated" '/usr/bin:/bin' +} + +test_source_plist_is_unchanged() { + local source_copy="$TEST_TMP/source-copy.plist" + local source_before="$TEST_TMP/source-before.plist" + local generated="$TEST_TMP/source-generated.plist" + + cp "$SOURCE_PLIST" "$source_copy" + cp "$source_copy" "$source_before" + if ! write_restart_environment_plist "$source_copy" "$generated" '/custom/bin:/usr/bin' >/dev/null 2>&1; then + fail "source plist remains unchanged: helper failed" + return + fi + if cmp -s "$source_before" "$source_copy"; then + pass "source plist remains unchanged" + else + fail "source plist remains unchanged: source bytes differ" + fi +} + +test_exact_path_round_trip +test_stale_environment_is_replaced +test_empty_path_is_rejected +test_malformed_plist_is_rejected +test_source_plist_is_unchanged + +if [ "$FAIL" -ne 0 ]; then + echo "SOME RESTART ENVIRONMENT TESTS FAILED" + exit 1 +fi + +echo "ALL RESTART ENVIRONMENT TESTS PASSED" diff --git a/scripts/restart.sh b/scripts/restart.sh index 7a3f4984..30e7ef96 100755 --- a/scripts/restart.sh +++ b/scripts/restart.sh @@ -13,7 +13,20 @@ set -e # scripts/restart.sh --wip # force install even if on a WIP branch # TBD_INSTALL_WIP=1 scripts/restart.sh # same as --wip -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT_DIR="${BASH_SOURCE[0]%/*}" +if [ "$SCRIPT_DIR" = "${BASH_SOURCE[0]}" ]; then + SCRIPT_DIR="." +fi +SCRIPT_DIR="$(cd "$SCRIPT_DIR" && pwd)" + +# The shell that installs this bundle defines the PATH contract for every TBD +# process launched from it. Reject a missing contract before any build or +# installation work begins. +# shellcheck source=/dev/null +source "$SCRIPT_DIR/restart-environment-lib.sh" +require_restart_path "${PATH-}" + +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" BUILD_DIR="$REPO_ROOT/.build/debug" app_only=false @@ -156,6 +169,10 @@ SOURCE_PLIST="$REPO_ROOT/Resources/TBDApp.Info.plist" mkdir -p "$BUNDLE_MACOS" +# Recreate the generated plist from the machine-independent source every run, +# then embed and verify the installation's exact PATH before signing. +write_restart_environment_plist "$SOURCE_PLIST" "$BUNDLE_PLIST" "$PATH" + # Resolve the absolute real path of the swift-build output. We need this # first so we can pass an absolute path to `ln` below (sidesteps any # cwd-relative resolution issues) and so we have a stable pgrep/pkill @@ -174,11 +191,6 @@ APP_EXEC_PATH="$(/usr/bin/readlink -f "$BUILD_DIR/TBDApp")" # previous restart.sh versions) idempotently. ln -f "$APP_EXEC_PATH" "$BUNDLE_MACOS/TBDApp" -# Copy the Info.plist if missing or older than the source. -if [ ! -f "$BUNDLE_PLIST" ] || [ "$SOURCE_PLIST" -nt "$BUNDLE_PLIST" ]; then - cp "$SOURCE_PLIST" "$BUNDLE_PLIST" -fi - # Copy the on-disk AppIcon.icns into the bundle. macOS reads this for # Notification Center banners, System Settings → Notifications, and Finder — # none of those paths look at NSApp.applicationIconImage (which still drives @@ -337,10 +349,10 @@ if [ "$daemon_only" = false ]; then echo "Starting app..." if [ "$install_to_applications" = true ]; then # Launch from /Applications (install-ready or --wip override) - open "$INSTALLED_BUNDLE" --stdout /tmp/tbdapp.log --stderr /tmp/tbdapp.log + open --env "PATH=$PATH" "$INSTALLED_BUNDLE" --stdout /tmp/tbdapp.log --stderr /tmp/tbdapp.log else # Launch from .build/debug (WIP worktree, no install to /Applications) - open "$BUNDLE_DIR" --stdout /tmp/tbdapp.log --stderr /tmp/tbdapp.log + open --env "PATH=$PATH" "$BUNDLE_DIR" --stdout /tmp/tbdapp.log --stderr /tmp/tbdapp.log fi # `open` returns immediately after asking LaunchServices to spawn the app. From 52abb54a6ee44c9dfd889d012831bed3047a35d5 Mon Sep 17 00:00:00 2001 From: Jeffrey Burt Date: Mon, 10 Aug 2026 15:31:28 -0700 Subject: [PATCH 02/14] fix: resolve tmux from launch path --- Sources/TBDApp/AppState.swift | 7 +- .../TBDApp/Helpers/ExecutableResolver.swift | 26 ++++ .../TBDApp/Terminal/TerminalPanelView.swift | 10 +- Sources/TBDApp/Terminal/TmuxBridge.swift | 38 +++++- .../ControlMode/TmuxControlConnection.swift | 5 +- .../Tmux/ControlMode/TmuxVersion.swift | 5 +- Sources/TBDDaemon/Tmux/TmuxManager.swift | 35 ++++- .../TBDAppTests/ExecutableResolverTests.swift | 125 ++++++++++++++++++ Tests/TBDAppTests/TmuxBridgeTests.swift | 55 ++++++-- .../HistoryLimitIntegrationTests.swift | 3 +- .../TmuxPathResolutionTests.swift | 74 +++++++++++ 11 files changed, 345 insertions(+), 38 deletions(-) create mode 100644 Sources/TBDApp/Helpers/ExecutableResolver.swift create mode 100644 Tests/TBDAppTests/ExecutableResolverTests.swift create mode 100644 Tests/TBDDaemonTests/TmuxPathResolutionTests.swift diff --git a/Sources/TBDApp/AppState.swift b/Sources/TBDApp/AppState.swift index 6f6b8368..1eab759f 100644 --- a/Sources/TBDApp/AppState.swift +++ b/Sources/TBDApp/AppState.swift @@ -1035,7 +1035,12 @@ final class AppState: ObservableObject { let themeStore = ThemeStore() let daemonClient = DaemonClient() - let tmuxBridge = TmuxBridge() + let tmuxBridge = TmuxBridge( + tmuxExecutablePath: ExecutableResolver.resolve( + "tmux", + path: ProcessInfo.processInfo.environment["PATH"] + ) + ) /// App-scoped owner of control-mode stream readers (Phase 2 FD vending). /// Lives here — not on any view — so SwiftUI view destruction cannot tear /// down an active reader. Keyed by `FDVendHeader.routingKey`. diff --git a/Sources/TBDApp/Helpers/ExecutableResolver.swift b/Sources/TBDApp/Helpers/ExecutableResolver.swift new file mode 100644 index 00000000..75a187e3 --- /dev/null +++ b/Sources/TBDApp/Helpers/ExecutableResolver.swift @@ -0,0 +1,26 @@ +import Foundation + +enum ExecutableResolver { + static func resolve(_ name: String, path: String?) -> String? { + guard !name.isEmpty, !name.contains("/"), let path, !path.isEmpty else { + return nil + } + + for entry in path.split(separator: ":", omittingEmptySubsequences: false) { + let directory = String(entry) + guard !directory.isEmpty, (directory as NSString).isAbsolutePath else { + continue + } + + let candidate = URL(fileURLWithPath: directory, isDirectory: true) + .appendingPathComponent(name) + .standardizedFileURL + .path + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + + return nil + } +} diff --git a/Sources/TBDApp/Terminal/TerminalPanelView.swift b/Sources/TBDApp/Terminal/TerminalPanelView.swift index 9b6441c0..53a79f51 100644 --- a/Sources/TBDApp/Terminal/TerminalPanelView.swift +++ b/Sources/TBDApp/Terminal/TerminalPanelView.swift @@ -709,7 +709,7 @@ struct TerminalPanelRepresentable: NSViewRepresentable { prepared = value } - let tmuxPath = findExecutable(prepared.executablePath) + let tmuxPath = prepared.executablePath let processArgs = prepared.arguments debugLog("PANEL: Starting: \(tmuxPath) \(processArgs.joined(separator: " "))") @@ -1404,13 +1404,5 @@ struct TerminalPanelRepresentable: NSViewRepresentable { func iTermContent(source: TerminalView, content: ArraySlice) {} func rangeChanged(source: TerminalView, startY: Int, endY: Int) {} - // MARK: - Helpers - - private func findExecutable(_ name: String) -> String { - for path in ["/opt/homebrew/bin/\(name)", "/usr/local/bin/\(name)", "/usr/bin/\(name)"] { - if FileManager.default.isExecutableFile(atPath: path) { return path } - } - return "/usr/bin/env" - } } } diff --git a/Sources/TBDApp/Terminal/TmuxBridge.swift b/Sources/TBDApp/Terminal/TmuxBridge.swift index 145f7aa8..36a5b73c 100644 --- a/Sources/TBDApp/Terminal/TmuxBridge.swift +++ b/Sources/TBDApp/Terminal/TmuxBridge.swift @@ -55,6 +55,7 @@ func debugLog(_ msg: String) { /// - The "main" session persists even when the app is closed final class TmuxBridge: @unchecked Sendable { private let lock = NSLock() + private let tmuxExecutablePath: String? /// Tracks active grouped sessions: maps panel UUID -> grouped session name private var activeSessions: [UUID: String] = [:] @@ -65,6 +66,10 @@ final class TmuxBridge: @unchecked Sendable { /// `waitUntilExit`) so it doesn't pump the main runloop. private let cleanupQueue = DispatchQueue(label: "com.tbd.app.tmux-cleanup", qos: .utility) + init(tmuxExecutablePath: String?) { + self.tmuxExecutablePath = tmuxExecutablePath + } + static func sessionName(for panelID: UUID) -> String { "tbd-view-\(panelID.uuidString.prefix(8).lowercased())" } @@ -108,14 +113,22 @@ final class TmuxBridge: @unchecked Sendable { static func killSessionArgs(sessionName: String) -> [String] { ["kill-session", "-t", sessionName] } + + /// Complete command used for a tmux preparation subprocess. + func tmuxCommand(server: String, args: [String]) -> [String]? { + guard let tmuxExecutablePath else { return nil } + return [tmuxExecutablePath, "-L", server] + args + } + /// Command used by the SwiftTerm PTY to attach its viewer client. /// /// `-u` is required even when the app environment normally has a UTF-8 /// locale. tmux otherwise may classify this bare PTY client as non-UTF-8 /// and substitute Unicode punctuation (notably curly apostrophes) with /// underscores when it redraws the pane. - static func viewerAttachCommand(server: String, sessionName: String) -> [String] { - ["tmux", "-u", "-L", server, "attach", "-t", sessionName] + func viewerAttachCommand(server: String, sessionName: String) -> [String]? { + guard let tmuxExecutablePath else { return nil } + return [tmuxExecutablePath, "-u", "-L", server, "attach", "-t", sessionName] } /// Prepare a tmux view session for a specific panel. @@ -139,6 +152,12 @@ final class TmuxBridge: @unchecked Sendable { windowID: String ) async -> Result { let sessionName = Self.sessionName(for: panelID) + guard let preparedSession = preparedSession(server: server, sessionName: sessionName) else { + return .failure(.commandFailed( + stage: .createViewSession, + output: "tmux executable unavailable" + )) + } let _ = await runTmux(server: server, args: Self.killSessionArgs(sessionName: sessionName)) @@ -217,7 +236,7 @@ final class TmuxBridge: @unchecked Sendable { debugLog("PREPARE: panelID=\(panelID.uuidString.prefix(8)) server=\(server) window=\(windowID) session=\(sessionName)") - return .success(Self.preparedSession(server: server, sessionName: sessionName)) + return .success(preparedSession) } /// Clean up a view session when a panel is hidden. @@ -261,8 +280,9 @@ final class TmuxBridge: @unchecked Sendable { let output: String } - static func preparedSession(server: String, sessionName: String) -> TmuxPreparedSession { + func preparedSession(server: String, sessionName: String) -> TmuxPreparedSession? { let viewerCommand = viewerAttachCommand(server: server, sessionName: sessionName) + guard let viewerCommand else { return nil } return TmuxPreparedSession( executablePath: viewerCommand[0], arguments: Array(viewerCommand.dropFirst()) @@ -346,13 +366,17 @@ final class TmuxBridge: @unchecked Sendable { /// new-session/select-window), starving SwiftUI's render loop so newly /// inserted terminal panels never displayed content. private func runTmux(server: String, args: [String]) async -> TmuxResult { - await withCheckedContinuation { continuation in + guard let command = tmuxCommand(server: server, args: args) else { + return TmuxResult(success: false, output: "tmux executable unavailable") + } + + return await withCheckedContinuation { continuation in let process = Process() let outPipe = Pipe() let errPipe = Pipe() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = ["tmux", "-L", server] + args + process.executableURL = URL(fileURLWithPath: command[0]) + process.arguments = Array(command.dropFirst()) process.standardOutput = outPipe process.standardError = errPipe diff --git a/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlConnection.swift b/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlConnection.swift index 25278f84..a0e717f1 100644 --- a/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlConnection.swift +++ b/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlConnection.swift @@ -21,7 +21,7 @@ import os /// once BEFORE `start()` and is read only by the reader thread afterwards. final class TmuxControlConnection: @unchecked Sendable { let serverName: String - private let tmuxBinary: String + private let tmuxBinary: String? private let logger = Logger(subsystem: "com.tbd.daemon", category: "tmuxControlMode") private let process = Process() @@ -50,7 +50,7 @@ final class TmuxControlConnection: @unchecked Sendable { let events: AsyncStream private let eventContinuation: AsyncStream.Continuation - init(serverName: String, tmuxBinary: String = TmuxManager.tmuxPath()) { + init(serverName: String, tmuxBinary: String? = TmuxManager.tmuxPath()) { self.serverName = serverName self.tmuxBinary = tmuxBinary var continuation: AsyncStream.Continuation! @@ -61,6 +61,7 @@ final class TmuxControlConnection: @unchecked Sendable { /// Spawn `tmux -CC attach` over a pty and begin draining its output. /// Throws if the pty cannot be allocated or the process fails to launch. func start() throws { + guard let tmuxBinary else { throw POSIXError(.ENOENT) } var primary: Int32 = -1 var replica: Int32 = -1 var term = termios() diff --git a/Sources/TBDDaemon/Tmux/ControlMode/TmuxVersion.swift b/Sources/TBDDaemon/Tmux/ControlMode/TmuxVersion.swift index 4320ada7..467b5188 100644 --- a/Sources/TBDDaemon/Tmux/ControlMode/TmuxVersion.swift +++ b/Sources/TBDDaemon/Tmux/ControlMode/TmuxVersion.swift @@ -50,8 +50,9 @@ struct TmuxVersion: Comparable, CustomStringConvertible { extension TmuxVersion { /// Run `tmux -V` once and parse the result. Returns nil on any failure /// (tmux not installed, non-zero exit, unparseable output). - static func detect(tmuxBinary: String = TmuxManager.tmuxPath()) async -> TmuxVersion? { - await withCheckedContinuation { continuation in + static func detect(tmuxBinary: String? = TmuxManager.tmuxPath()) async -> TmuxVersion? { + guard let tmuxBinary else { return nil } + return await withCheckedContinuation { continuation in let process = Process() let pipe = Pipe() process.executableURL = URL(fileURLWithPath: tmuxBinary) diff --git a/Sources/TBDDaemon/Tmux/TmuxManager.swift b/Sources/TBDDaemon/Tmux/TmuxManager.swift index 35898a70..bf071a44 100644 --- a/Sources/TBDDaemon/Tmux/TmuxManager.swift +++ b/Sources/TBDDaemon/Tmux/TmuxManager.swift @@ -1048,20 +1048,41 @@ public struct TmuxManager: Sendable { // MARK: - Private - /// Resolves the path to the tmux binary, checking common locations. - static func tmuxPath() -> String { - for candidate in ["/usr/bin/tmux", "/usr/local/bin/tmux", "/opt/homebrew/bin/tmux"] { - if FileManager.default.fileExists(atPath: candidate) { + /// Resolves tmux from the daemon's inherited PATH without adding fallback directories. + static func tmuxPath( + path: String? = ProcessInfo.processInfo.environment["PATH"] + ) -> String? { + guard let path, !path.isEmpty else { return nil } + + for entry in path.split(separator: ":", omittingEmptySubsequences: false) { + let directory = String(entry) + guard !directory.isEmpty, (directory as NSString).isAbsolutePath else { + continue + } + + let candidate = URL(fileURLWithPath: directory, isDirectory: true) + .appendingPathComponent("tmux") + .standardizedFileURL + .path + if FileManager.default.isExecutableFile(atPath: candidate) { return candidate } } - return "/usr/bin/tmux" + + return nil } @discardableResult private func runTmux(_ arguments: [String]) async throws -> String { - try await Self.runExternalCommand( - executable: Self.tmuxPath(), + guard let executable = Self.tmuxPath() else { + throw TmuxError.commandFailed( + command: "tmux " + arguments.joined(separator: " "), + status: 127, + output: "tmux is unavailable on PATH" + ) + } + return try await Self.runExternalCommand( + executable: executable, arguments: arguments, label: "tmux", timeout: subprocessTimeout diff --git a/Tests/TBDAppTests/ExecutableResolverTests.swift b/Tests/TBDAppTests/ExecutableResolverTests.swift new file mode 100644 index 00000000..898a3c23 --- /dev/null +++ b/Tests/TBDAppTests/ExecutableResolverTests.swift @@ -0,0 +1,125 @@ +import Foundation +import Testing +@testable import TBDApp + +@Suite("ExecutableResolver") +struct ExecutableResolverTests { + @Test func returnsFirstExecutableInPathOrderAsStandardizedAbsolutePath() throws { + let fixture = try ExecutableFixture() + defer { fixture.remove() } + let firstDirectory = try fixture.directory(named: "first") + let secondDirectory = try fixture.directory(named: "second") + let firstExecutable = try fixture.executable(named: "tmux", in: firstDirectory) + _ = try fixture.executable(named: "tmux", in: secondDirectory) + + let unstandardizedFirstDirectory = firstDirectory + .appendingPathComponent("child") + .appendingPathComponent("..") + try FileManager.default.createDirectory( + at: firstDirectory.appendingPathComponent("child"), + withIntermediateDirectories: false + ) + + #expect(ExecutableResolver.resolve( + "tmux", + path: "\(unstandardizedFirstDirectory.path):\(secondDirectory.path)" + ) == firstExecutable.standardizedFileURL.path) + } + + @Test func skipsNonExecutableCandidateForLaterExecutable() throws { + let fixture = try ExecutableFixture() + defer { fixture.remove() } + let firstDirectory = try fixture.directory(named: "first") + let secondDirectory = try fixture.directory(named: "second") + _ = try fixture.file(named: "tmux", in: firstDirectory, permissions: 0o644) + let secondExecutable = try fixture.executable(named: "tmux", in: secondDirectory) + + #expect(ExecutableResolver.resolve( + "tmux", + path: "\(firstDirectory.path):\(secondDirectory.path)" + ) == secondExecutable.standardizedFileURL.path) + } + + @Test func handlesDirectoryNamesContainingSpaces() throws { + let fixture = try ExecutableFixture() + defer { fixture.remove() } + let directory = try fixture.directory(named: "tools with spaces") + let executable = try fixture.executable(named: "tmux", in: directory) + + #expect(ExecutableResolver.resolve("tmux", path: directory.path) == executable.path) + } + + @Test func ignoresEmptyAndRelativePathEntries() throws { + let fixture = try ExecutableFixture() + defer { fixture.remove() } + let directory = try fixture.directory(named: "absolute") + let executable = try fixture.executable(named: "tmux", in: directory) + + #expect(ExecutableResolver.resolve( + "tmux", + path: ":relative-bin::\(directory.path)" + ) == executable.path) + } + + @Test func returnsNilForMissingNilOrEmptyPath() throws { + let fixture = try ExecutableFixture() + defer { fixture.remove() } + let directory = try fixture.directory(named: "empty") + + #expect(ExecutableResolver.resolve("tmux", path: nil) == nil) + #expect(ExecutableResolver.resolve("tmux", path: "") == nil) + #expect(ExecutableResolver.resolve("tmux", path: directory.path) == nil) + } + + @Test func rejectsEmptyAndSlashedExecutableNames() throws { + let fixture = try ExecutableFixture() + defer { fixture.remove() } + let directory = try fixture.directory(named: "bin") + _ = try fixture.executable(named: "tmux", in: directory) + + #expect(ExecutableResolver.resolve("", path: directory.path) == nil) + #expect(ExecutableResolver.resolve("tools/tmux", path: directory.path) == nil) + } + + @Test func doesNotSearchStandardLocationsOutsidePath() throws { + let fixture = try ExecutableFixture() + defer { fixture.remove() } + let directory = try fixture.directory(named: "empty") + + #expect(ExecutableResolver.resolve("sh", path: directory.path) == nil) + } +} + +private struct ExecutableFixture { + let root: URL + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("ExecutableResolverTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + } + + func directory(named name: String) throws -> URL { + let directory = root.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + return directory + } + + func executable(named name: String, in directory: URL) throws -> URL { + try file(named: name, in: directory, permissions: 0o755) + } + + func file(named name: String, in directory: URL, permissions: Int) throws -> URL { + let file = directory.appendingPathComponent(name) + try Data("fixture".utf8).write(to: file) + try FileManager.default.setAttributes( + [.posixPermissions: permissions], + ofItemAtPath: file.path + ) + return file + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } +} diff --git a/Tests/TBDAppTests/TmuxBridgeTests.swift b/Tests/TBDAppTests/TmuxBridgeTests.swift index d23ed32b..50c4f485 100644 --- a/Tests/TBDAppTests/TmuxBridgeTests.swift +++ b/Tests/TBDAppTests/TmuxBridgeTests.swift @@ -34,11 +34,6 @@ struct TmuxBridgeTests { #expect(TmuxBridge.activeWindowQueryArgs(sessionName: sessionName) == [ "display-message", "-p", "-t", sessionName, "#{window_id}", ]) - #expect(TmuxBridge.viewerAttachCommand( - server: "tbd-repo", sessionName: sessionName - ) == [ - "tmux", "-u", "-L", "tbd-repo", "attach", "-t", sessionName, - ]) #expect(TmuxBridge.windowInventoryQueryArgs() == [ "list-windows", "-a", "-F", "#{window_id}", ]) @@ -50,6 +45,47 @@ struct TmuxBridgeTests { ]) } + @Test func preparationAndViewerCommandsUseProvidedAbsoluteExecutable() throws { + let executablePath = "/nonstandard/tools/tmux" + let bridge = TmuxBridge(tmuxExecutablePath: executablePath) + + let preparation = try #require(bridge.tmuxCommand( + server: "tbd-repo", + args: ["display-message", "-p", "#{window_id}"] + )) + let viewer = try #require(bridge.viewerAttachCommand( + server: "tbd-repo", + sessionName: "tbd-view-4c4f1a61" + )) + + #expect(preparation == [ + executablePath, "-L", "tbd-repo", "display-message", "-p", "#{window_id}", + ]) + #expect(viewer == [ + executablePath, "-u", "-L", "tbd-repo", "attach", "-t", "tbd-view-4c4f1a61", + ]) + #expect(preparation.first == viewer.first) + + let forbiddenExecutables = [ + "/usr/bin/env", + "/opt/homebrew/bin/tmux", + "/usr/local/bin/tmux", + "/usr/bin/tmux", + ] + #expect(!forbiddenExecutables.contains(preparation[0])) + #expect(!forbiddenExecutables.contains(viewer[0])) + } + + @Test func unresolvedExecutableProducesNoPreparationOrViewerCommand() { + let bridge = TmuxBridge(tmuxExecutablePath: nil) + + #expect(bridge.tmuxCommand(server: "tbd-repo", args: ["list-windows"]) == nil) + #expect(bridge.viewerAttachCommand( + server: "tbd-repo", + sessionName: "tbd-view-4c4f1a61" + ) == nil) + } + @Test func clientInventoryConfirmsOnlyTheExpectedAttachedSession() { #expect(TmuxBridge.clientInventoryConfirmsAttachment( querySucceeded: true, @@ -68,13 +104,14 @@ struct TmuxBridgeTests { )) } - @Test func preparedSessionCarriesViewerCommand() { - let prepared = TmuxBridge.preparedSession( + @Test func preparedSessionCarriesViewerCommand() throws { + let bridge = TmuxBridge(tmuxExecutablePath: "/nonstandard/tools/tmux") + let prepared = try #require(bridge.preparedSession( server: "tbd-repo", sessionName: "tbd-view-4c4f1a61" - ) + )) #expect(prepared == TmuxPreparedSession( - executablePath: "tmux", + executablePath: "/nonstandard/tools/tmux", arguments: ["-u", "-L", "tbd-repo", "attach", "-t", "tbd-view-4c4f1a61"] )) } diff --git a/Tests/TBDDaemonLiveTests/HistoryLimitIntegrationTests.swift b/Tests/TBDDaemonLiveTests/HistoryLimitIntegrationTests.swift index 54795c36..c0f91604 100644 --- a/Tests/TBDDaemonLiveTests/HistoryLimitIntegrationTests.swift +++ b/Tests/TBDDaemonLiveTests/HistoryLimitIntegrationTests.swift @@ -24,8 +24,9 @@ struct HistoryLimitIntegrationTests { /// One-shot tmux command via the same binary TmuxManager uses, /// capturing trimmed stdout (nil on nonzero exit). private func tmuxCapture(_ args: [String]) -> String? { + guard let tmuxPath = TmuxManager.tmuxPath() else { return nil } let process = Process() - process.executableURL = URL(fileURLWithPath: TmuxManager.tmuxPath()) + process.executableURL = URL(fileURLWithPath: tmuxPath) process.arguments = args let pipe = Pipe() process.standardOutput = pipe diff --git a/Tests/TBDDaemonTests/TmuxPathResolutionTests.swift b/Tests/TBDDaemonTests/TmuxPathResolutionTests.swift new file mode 100644 index 00000000..b3c3707a --- /dev/null +++ b/Tests/TBDDaemonTests/TmuxPathResolutionTests.swift @@ -0,0 +1,74 @@ +import Foundation +import Testing +@testable import TBDDaemonLib + +@Suite("Tmux PATH resolution") +struct TmuxPathResolutionTests { + @Test + func resolvesExecutableFromNonstandardPathDirectory() throws { + let fixture = try TmuxPathFixture() + defer { fixture.remove() } + let toolsDirectory = try fixture.directory(named: "custom tools") + let executable = try fixture.tmux(in: toolsDirectory, permissions: 0o755) + + #expect(TmuxManager.tmuxPath(path: toolsDirectory.path) == executable.path) + } + + @Test + func doesNotResolveExecutableWhoseDirectoryIsAbsentFromPath() throws { + let fixture = try TmuxPathFixture() + defer { fixture.remove() } + let toolsDirectory = try fixture.directory(named: "custom-tools") + let emptyDirectory = try fixture.directory(named: "empty") + _ = try fixture.tmux(in: toolsDirectory, permissions: 0o755) + + #expect(TmuxManager.tmuxPath(path: emptyDirectory.path) == nil) + } + + @Test + func skipsNonExecutableCandidate() throws { + let fixture = try TmuxPathFixture() + defer { fixture.remove() } + let toolsDirectory = try fixture.directory(named: "custom-tools") + _ = try fixture.tmux(in: toolsDirectory, permissions: 0o644) + + #expect(TmuxManager.tmuxPath(path: toolsDirectory.path) == nil) + } + + @Test + func rejectsMissingEmptyAndRelativePathEntries() { + #expect(TmuxManager.tmuxPath(path: nil) == nil) + #expect(TmuxManager.tmuxPath(path: "") == nil) + #expect(TmuxManager.tmuxPath(path: "::relative-tools:") == nil) + } +} + +private struct TmuxPathFixture { + let root: URL + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("TmuxPathResolutionTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + } + + func directory(named name: String) throws -> URL { + let directory = root.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + return directory + } + + func tmux(in directory: URL, permissions: Int) throws -> URL { + let file = directory.appendingPathComponent("tmux") + try Data("fixture".utf8).write(to: file) + try FileManager.default.setAttributes( + [.posixPermissions: permissions], + ofItemAtPath: file.path + ) + return file + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } +} From 17bf2aa127f3dcc5abffbdf77fc32ae9977213d6 Mon Sep 17 00:00:00 2001 From: Jeffrey Burt Date: Mon, 10 Aug 2026 15:36:27 -0700 Subject: [PATCH 03/14] refactor: remove daemon path augmentation --- Sources/TBDDaemon/ToolPathAugmenter.swift | 50 -------- Sources/TBDDaemon/main.swift | 7 -- .../ToolPathAugmenterTests.swift | 114 ------------------ 3 files changed, 171 deletions(-) delete mode 100644 Sources/TBDDaemon/ToolPathAugmenter.swift delete mode 100644 Tests/TBDDaemonTests/ToolPathAugmenterTests.swift diff --git a/Sources/TBDDaemon/ToolPathAugmenter.swift b/Sources/TBDDaemon/ToolPathAugmenter.swift deleted file mode 100644 index e99a2122..00000000 --- a/Sources/TBDDaemon/ToolPathAugmenter.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation - -/// Augments PATH with standard macOS tool directories when the daemon is spawned -/// from the GUI app with a minimal LaunchServices PATH. -/// -/// When TBDApp launches TBDDaemon directly, the daemon inherits the app's environment, -/// which on macOS is `/usr/bin:/bin:/usr/sbin:/sbin` — a minimal set that excludes -/// Homebrew tools. This causes git subprocesses to fail when they try to invoke -/// `git-lfs` (and other tools only present in `/opt/homebrew/bin`), because git's -/// `filter..process` hook cannot find the executable. -/// -/// This helper prepends the standard macOS tool directories in a deterministic order, -/// preserving any existing PATH entries and avoiding duplicates. -public struct ToolPathAugmenter { - /// Prepends standard macOS tool directories to the PATH. - /// - /// - Parameter currentPath: The current PATH string, or nil/empty to start fresh. - /// Existing entries are preserved and their order is maintained. - /// - Returns: An augmented PATH string with Homebrew and standard directories - /// prepended, or the input unchanged if it already contains all required dirs. - /// - /// Directories prepended (in order): `/opt/homebrew/bin`, `/opt/homebrew/sbin`, - /// `/usr/local/bin`, `/usr/local/sbin`. None are prepended if already present. - nonisolated public static func augmentPath(_ currentPath: String?) -> String { - let dirsToPrepend = [ - "/opt/homebrew/bin", - "/opt/homebrew/sbin", - "/usr/local/bin", - "/usr/local/sbin" - ] - - let existingParts = (currentPath ?? "") - .split(separator: ":", omittingEmptySubsequences: true) - .map { String($0) } - - var toAdd: [String] = [] - for dir in dirsToPrepend { - if !existingParts.contains(dir) { - toAdd.append(dir) - } - } - - if toAdd.isEmpty { - return currentPath ?? "" - } - - let newParts = toAdd + existingParts - return newParts.joined(separator: ":") - } -} diff --git a/Sources/TBDDaemon/main.swift b/Sources/TBDDaemon/main.swift index 606efb0e..9223ff73 100644 --- a/Sources/TBDDaemon/main.swift +++ b/Sources/TBDDaemon/main.swift @@ -6,14 +6,7 @@ import TBDShared private let logger = Logger(subsystem: "com.tbd.daemon", category: "startup") -// Augment PATH with standard macOS tool directories when spawned from GUI app. -// The GUI app launches the daemon with a minimal LaunchServices PATH that excludes -// Homebrew tools, causing git-lfs and other subprocesses to fail. Set a full PATH -// once at startup so all child processes inherit it (see ToolPathAugmenter for details). -let augmentedPath = ToolPathAugmenter.augmentPath(ProcessInfo.processInfo.environment["PATH"]) -setenv("PATH", augmentedPath, 1) logger.info("tbdd v\(TBDConstants.version, privacy: .public) starting...") -logger.info("Daemon PATH: \(augmentedPath, privacy: .public)") let daemon = Daemon() diff --git a/Tests/TBDDaemonTests/ToolPathAugmenterTests.swift b/Tests/TBDDaemonTests/ToolPathAugmenterTests.swift deleted file mode 100644 index 15383d02..00000000 --- a/Tests/TBDDaemonTests/ToolPathAugmenterTests.swift +++ /dev/null @@ -1,114 +0,0 @@ -import Testing -@testable import TBDDaemonLib - -@Suite -struct ToolPathAugmenterTests { - // MARK: - Nil/Empty PATH - - @Test - func augmentPathWithNilPath() { - let result = ToolPathAugmenter.augmentPath(nil) - #expect(result.contains("/opt/homebrew/bin")) - #expect(result.contains("/opt/homebrew/sbin")) - #expect(result.contains("/usr/local/bin")) - #expect(result.contains("/usr/local/sbin")) - // Verify no trailing colon - #expect(!result.hasSuffix(":")) - } - - @Test - func augmentPathWithEmptyPath() { - let result = ToolPathAugmenter.augmentPath("") - #expect(result.contains("/opt/homebrew/bin")) - #expect(result.contains("/opt/homebrew/sbin")) - #expect(result.contains("/usr/local/bin")) - #expect(result.contains("/usr/local/sbin")) - // Verify no trailing colon - #expect(!result.hasSuffix(":")) - } - - @Test - func augmentPathWithNilExactOutput() { - let result = ToolPathAugmenter.augmentPath(nil) - let expected = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin" - #expect(result == expected) - } - - @Test - func augmentPathWithEmptyExactOutput() { - let result = ToolPathAugmenter.augmentPath("") - let expected = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin" - #expect(result == expected) - } - - // MARK: - Existing PATH preservation - - @Test - func augmentPathPreservesExistingEntries() { - let input = "/usr/bin:/bin" - let result = ToolPathAugmenter.augmentPath(input) - // Both existing entries and all homebrew dirs should be present - #expect(result.contains("/opt/homebrew/bin")) - #expect(result.contains("/usr/bin")) - #expect(result.contains("/bin")) - } - - @Test - func augmentPathMaintainsOrder() { - let input = "/usr/bin:/bin:/usr/sbin:/sbin" - let result = ToolPathAugmenter.augmentPath(input) - // Homebrew dirs should come first, then the original entries in order - let parts = result.split(separator: ":", omittingEmptySubsequences: true) - #expect(parts[0] == "/opt/homebrew/bin") - #expect(parts[1] == "/opt/homebrew/sbin") - #expect(parts[2] == "/usr/local/bin") - #expect(parts[3] == "/usr/local/sbin") - } - - // MARK: - Duplicate avoidance - - @Test - func augmentPathNoDuplicateWhenHomebrewAlreadyPresent() { - let input = "/opt/homebrew/bin:/usr/bin:/bin" - let result = ToolPathAugmenter.augmentPath(input) - let parts = result.split(separator: ":") - let homebrewBinCount = parts.filter { $0 == "/opt/homebrew/bin" }.count - #expect(homebrewBinCount == 1) - } - - @Test - func augmentPathHandlesPartialDuplicates() { - let input = "/opt/homebrew/bin:/usr/bin:/bin" - let result = ToolPathAugmenter.augmentPath(input) - // Should add the other homebrew dirs but not duplicate the existing one - #expect(result.contains("/opt/homebrew/sbin")) - #expect(result.contains("/usr/local/bin")) - #expect(result.contains("/usr/local/sbin")) - } - - // MARK: - Complete Homebrew already present - - @Test - func augmentPathWithCompleteHomebrewPath() { - let input = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin" - let result = ToolPathAugmenter.augmentPath(input) - // Should return unchanged - #expect(result == input) - } - - // MARK: - Minimal launchd PATH (the main use case) - - @Test - func augmentPathWithLaunchServicesMinimalPath() { - let input = "/usr/bin:/bin:/usr/sbin:/sbin" - let result = ToolPathAugmenter.augmentPath(input) - // Should prepend all four homebrew directories - #expect(result.hasPrefix("/opt/homebrew/bin")) - #expect(result.contains("/opt/homebrew/sbin")) - #expect(result.contains("/usr/local/bin")) - #expect(result.contains("/usr/local/sbin")) - // And preserve the original entries - #expect(result.contains("/usr/bin")) - #expect(result.contains("/bin")) - } -} From 0497ba77f762ff515f1dfd18bfbff84d142acc31 Mon Sep 17 00:00:00 2001 From: Jeffrey Burt Date: Mon, 10 Aug 2026 17:57:35 -0700 Subject: [PATCH 04/14] feat: persist tmux executable fallback --- Sources/TBDShared/Constants.swift | 10 + .../TBDShared/TmuxExecutableResolver.swift | 114 +++++++ .../TmuxExecutableResolverTests.swift | 302 ++++++++++++++++++ 3 files changed, 426 insertions(+) create mode 100644 Sources/TBDShared/TmuxExecutableResolver.swift create mode 100644 Tests/TBDSharedTests/TmuxExecutableResolverTests.swift diff --git a/Sources/TBDShared/Constants.swift b/Sources/TBDShared/Constants.swift index fd0890e0..2f9e22e2 100644 --- a/Sources/TBDShared/Constants.swift +++ b/Sources/TBDShared/Constants.swift @@ -19,6 +19,16 @@ public enum TBDConstants { /// or empty, preserving production behavior. public static var configDir: URL { configDir(environment: ProcessInfo.processInfo.environment) } + /// File containing the user-selected tmux executable fallback. Honors + /// `TBD_HOME` so the app and daemon share the same configured value. + public static func tmuxExecutablePathFile(environment: [String: String]) -> URL { + configDir(environment: environment).appendingPathComponent("tmux-executable-path") + } + + public static var tmuxExecutablePathFile: URL { + tmuxExecutablePathFile(environment: ProcessInfo.processInfo.environment) + } + /// Unix socket path resolved from the given environment dictionary. /// Honors `TBD_SOCKET_PATH` independently of `TBD_HOME` — darwin caps /// `sun_path` at ~104 bytes, so a deep `TBD_HOME` can overflow even though diff --git a/Sources/TBDShared/TmuxExecutableResolver.swift b/Sources/TBDShared/TmuxExecutableResolver.swift new file mode 100644 index 00000000..015afe18 --- /dev/null +++ b/Sources/TBDShared/TmuxExecutableResolver.swift @@ -0,0 +1,114 @@ +import Foundation + +public struct TmuxExecutableResolution: Equatable, Sendable { + public enum Source: Equatable, Sendable { + case path + case savedFallback + } + + public let path: String + public let source: Source + + public init(path: String, source: Source) { + self.path = path + self.source = source + } +} + +public enum TmuxExecutableResolverError: LocalizedError, Equatable { + case pathMustBeAbsolute + case pathIsNotExecutable + + public var errorDescription: String? { + switch self { + case .pathMustBeAbsolute: + "The tmux executable path must be absolute." + case .pathIsNotExecutable: + "The tmux executable path does not point to an executable file." + } + } +} + +public struct TmuxExecutableResolver: Sendable { + private let environment: [String: String] + private let configurationURL: URL + + public init( + environment: [String: String] = ProcessInfo.processInfo.environment, + configurationURL: URL? = nil + ) { + self.environment = environment + self.configurationURL = configurationURL + ?? TBDConstants.tmuxExecutablePathFile(environment: environment) + } + + public var savedPath: String? { + guard let contents = try? String(contentsOf: configurationURL, encoding: .utf8) else { + return nil + } + let path = contents.trimmingCharacters(in: .whitespacesAndNewlines) + return path.isEmpty ? nil : path + } + + public func resolve() -> TmuxExecutableResolution? { + if let path = executableFromPath() { + return TmuxExecutableResolution(path: path, source: .path) + } + guard let savedPath, isRegularExecutable(savedPath) else { + return nil + } + return TmuxExecutableResolution(path: savedPath, source: .savedFallback) + } + + public func save(_ path: String) throws { + let trimmedPath = path.trimmingCharacters(in: .whitespacesAndNewlines) + try validate(trimmedPath) + try FileManager.default.createDirectory( + at: configurationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try trimmedPath.write(to: configurationURL, atomically: true, encoding: .utf8) + } + + public func clear() throws { + do { + try FileManager.default.removeItem(at: configurationURL) + } catch let error as CocoaError where error.code == .fileNoSuchFile { + // Already clear. + } + } + + private func executableFromPath() -> String? { + for directory in environment["PATH"]?.split(separator: ":", omittingEmptySubsequences: false) ?? [] { + let directory = String(directory) + guard NSString(string: directory).isAbsolutePath else { continue } + let candidate = URL(fileURLWithPath: directory, isDirectory: true) + .appendingPathComponent("tmux") + .standardizedFileURL + .path + if isRegularExecutable(candidate) { + return candidate + } + } + return nil + } + + private func validate(_ path: String) throws { + guard NSString(string: path).isAbsolutePath else { + throw TmuxExecutableResolverError.pathMustBeAbsolute + } + guard isRegularExecutable(path) else { + throw TmuxExecutableResolverError.pathIsNotExecutable + } + } + + private func isRegularExecutable(_ path: String) -> Bool { + guard NSString(string: path).isAbsolutePath, + FileManager.default.isExecutableFile(atPath: path) else { + return false + } + let resolvedURL = URL(fileURLWithPath: path).resolvingSymlinksInPath() + let values = try? resolvedURL.resourceValues(forKeys: [.isRegularFileKey]) + return values?.isRegularFile == true + } +} diff --git a/Tests/TBDSharedTests/TmuxExecutableResolverTests.swift b/Tests/TBDSharedTests/TmuxExecutableResolverTests.swift new file mode 100644 index 00000000..00e68fb8 --- /dev/null +++ b/Tests/TBDSharedTests/TmuxExecutableResolverTests.swift @@ -0,0 +1,302 @@ +import Foundation +import Testing +@testable import TBDShared + +@Suite("Tmux executable resolver") +struct TmuxExecutableResolverTests { + @Test func pathReturnsFirstExecutableAndWinsOverSavedFallback() throws { + let fixture = try TmuxExecutableFixture() + defer { fixture.remove() } + let firstDirectory = try fixture.directory(named: "first") + let secondDirectory = try fixture.directory(named: "second") + let first = try fixture.executable(named: "tmux", in: firstDirectory) + _ = try fixture.executable(named: "tmux", in: secondDirectory) + let saved = try fixture.executable(named: "saved-tmux", in: fixture.root) + try saved.path.write(to: fixture.configurationURL, atomically: true, encoding: .utf8) + + let resolver = fixture.resolver(path: "\(firstDirectory.path):\(secondDirectory.path)") + + #expect(resolver.resolve() == TmuxExecutableResolution(path: first.path, source: .path)) + } + + @Test func pathSkipsEmptyRelativeAndNonExecutableCandidates() throws { + let fixture = try TmuxExecutableFixture() + defer { fixture.remove() } + let nonExecutableDirectory = try fixture.directory(named: "non-executable") + _ = try fixture.file(named: "tmux", in: nonExecutableDirectory, permissions: 0o644) + let executableDirectory = try fixture.directory(named: "executable") + let executable = try fixture.executable(named: "tmux", in: executableDirectory) + + let resolver = fixture.resolver( + path: ":relative-bin:\(nonExecutableDirectory.path)::\(executableDirectory.path)" + ) + + #expect(resolver.resolve() == TmuxExecutableResolution(path: executable.path, source: .path)) + } + + @Test func pathSkipsDirectoryNamedTmuxForLaterRegularExecutable() throws { + let fixture = try TmuxExecutableFixture() + defer { fixture.remove() } + let directoryCandidateParent = try fixture.directory(named: "directory-candidate") + try FileManager.default.createDirectory( + at: directoryCandidateParent.appendingPathComponent("tmux", isDirectory: true), + withIntermediateDirectories: false + ) + let executableDirectory = try fixture.directory(named: "executable") + let executable = try fixture.executable(named: "tmux", in: executableDirectory) + + let resolver = fixture.resolver( + path: "\(directoryCandidateParent.path):\(executableDirectory.path)" + ) + + #expect(resolver.resolve() == TmuxExecutableResolution(path: executable.path, source: .path)) + } + + @Test func pathReturnsStandardizedExecutablePath() throws { + let fixture = try TmuxExecutableFixture() + defer { fixture.remove() } + let executableDirectory = try fixture.directory(named: "executable") + _ = try fixture.directory(named: "child", in: executableDirectory) + let executable = try fixture.executable(named: "tmux", in: executableDirectory) + + let resolver = fixture.resolver( + path: executableDirectory.appendingPathComponent("child/..").path + ) + + #expect(resolver.resolve()?.path == executable.standardizedFileURL.path) + } + + @Test func validSavedExecutableResolvesAfterPathMiss() throws { + let fixture = try TmuxExecutableFixture() + defer { fixture.remove() } + let emptyDirectory = try fixture.directory(named: "empty") + let saved = try fixture.executable(named: "saved-tmux", in: fixture.root) + try " \(saved.path)\n".write(to: fixture.configurationURL, atomically: true, encoding: .utf8) + + let resolver = fixture.resolver(path: emptyDirectory.path) + + #expect(resolver.savedPath == saved.path) + #expect(resolver.resolve() == TmuxExecutableResolution(path: saved.path, source: .savedFallback)) + } + + @Test func explicitEnvironmentDerivesFallbackFileFromTBDHome() throws { + let fixture = try TmuxExecutableFixture() + defer { fixture.remove() } + let tbdHome = try fixture.directory(named: "explicit-tbd-home") + let configurationURL = tbdHome.appendingPathComponent("tmux-executable-path") + let saved = try fixture.executable(named: "saved-tmux", in: fixture.root) + try saved.path.write(to: configurationURL, atomically: true, encoding: .utf8) + let replacement = try fixture.executable(named: "replacement-tmux", in: fixture.root) + let resolver = TmuxExecutableResolver( + environment: ["PATH": "", "TBD_HOME": tbdHome.path] + ) + + #expect(resolver.savedPath == saved.path) + #expect(resolver.resolve() == TmuxExecutableResolution(path: saved.path, source: .savedFallback)) + + try resolver.save(replacement.path) + + #expect(try String(contentsOf: configurationURL, encoding: .utf8) == replacement.path) + } + + @Test func invalidSavedValuesDoNotResolve() throws { + let fixture = try TmuxExecutableFixture() + defer { fixture.remove() } + let nonExecutable = try fixture.file(named: "not-executable", in: fixture.root, permissions: 0o644) + let missing = fixture.root.appendingPathComponent("missing") + let invalidValues = ["", " \n", "relative/tmux", missing.path, nonExecutable.path] + + for value in invalidValues { + try value.write(to: fixture.configurationURL, atomically: true, encoding: .utf8) + #expect(fixture.resolver(path: "").resolve() == nil) + } + } + + @Test func savedPathRemainsReadableWhenExecutableBecomesInvalid() throws { + let fixture = try TmuxExecutableFixture() + defer { fixture.remove() } + let executable = try fixture.executable(named: "tmux", in: fixture.root) + let resolver = fixture.resolver(path: "") + try resolver.save(" \(executable.path)\n") + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], + ofItemAtPath: executable.path + ) + + #expect(resolver.savedPath == executable.path) + #expect(resolver.resolve() == nil) + } + + @Test func saveTrimsWhitespaceAndCreatesOnlyTheBackingFile() throws { + let fixture = try TmuxExecutableFixture(createConfigurationDirectory: false) + defer { fixture.remove() } + let executable = try fixture.executable(named: "tmux", in: fixture.root) + let resolver = fixture.resolver(path: "") + + try resolver.save(" \t\(executable.path)\n") + + #expect(try String(contentsOf: fixture.configurationURL, encoding: .utf8) == executable.path) + #expect(resolver.savedPath == executable.path) + let contents = try FileManager.default.contentsOfDirectory( + at: fixture.configurationURL.deletingLastPathComponent(), + includingPropertiesForKeys: nil + ) + #expect(contents.map { $0.resolvingSymlinksInPath() } == [fixture.configurationURL.resolvingSymlinksInPath()]) + } + + @Test func invalidSavePreservesPriorValidValue() throws { + let fixture = try TmuxExecutableFixture() + defer { fixture.remove() } + let executable = try fixture.executable(named: "tmux", in: fixture.root) + let resolver = fixture.resolver(path: "") + try resolver.save(executable.path) + + #expect(throws: (any Error).self) { + try resolver.save("relative/tmux") + } + + #expect(resolver.savedPath == executable.path) + #expect(try String(contentsOf: fixture.configurationURL, encoding: .utf8) == executable.path) + } + + @Test func saveRejectsEmptyMissingAndNonExecutablePaths() throws { + let fixture = try TmuxExecutableFixture() + defer { fixture.remove() } + let missing = fixture.root.appendingPathComponent("missing") + let nonExecutable = try fixture.file(named: "not-executable", in: fixture.root, permissions: 0o644) + let resolver = fixture.resolver(path: "") + + for value in ["", " \n", missing.path, nonExecutable.path] { + #expect(throws: (any Error).self) { + try resolver.save(value) + } + #expect(!FileManager.default.fileExists(atPath: fixture.configurationURL.path)) + } + } + + @Test func saveRejectsDirectoryWithoutOverwritingOrCreatingFallback() throws { + let fixture = try TmuxExecutableFixture() + defer { fixture.remove() } + let executable = try fixture.executable(named: "tmux", in: fixture.root) + let directory = try fixture.directory(named: "tmux-directory") + let resolver = fixture.resolver(path: "") + try resolver.save(executable.path) + + #expect(throws: (any Error).self) { + try resolver.save(directory.path) + } + #expect(resolver.savedPath == executable.path) + + try resolver.clear() + #expect(throws: (any Error).self) { + try resolver.save(directory.path) + } + #expect(!FileManager.default.fileExists(atPath: fixture.configurationURL.path)) + } + + @Test func clearRemovesFallbackAndIsIdempotent() throws { + let fixture = try TmuxExecutableFixture() + defer { fixture.remove() } + let executable = try fixture.executable(named: "tmux", in: fixture.root) + let resolver = fixture.resolver(path: "") + try resolver.save(executable.path) + + try resolver.clear() + try resolver.clear() + + #expect(resolver.savedPath == nil) + #expect(resolver.resolve() == nil) + #expect(!FileManager.default.fileExists(atPath: fixture.configurationURL.path)) + } + + @Test func clearRemovesDanglingConfigurationSymlink() throws { + let fixture = try TmuxExecutableFixture() + defer { fixture.remove() } + let missingTarget = fixture.root.appendingPathComponent("missing-target") + try FileManager.default.createSymbolicLink( + at: fixture.configurationURL, + withDestinationURL: missingTarget + ) + let resolver = fixture.resolver(path: "") + + try resolver.clear() + + #expect(throws: (any Error).self) { + try FileManager.default.destinationOfSymbolicLink(atPath: fixture.configurationURL.path) + } + } + + @Test func emptyPathAndMissingFallbackDoNotSearchFixedLocations() throws { + let fixture = try TmuxExecutableFixture() + defer { fixture.remove() } + + #expect(fixture.resolver(path: "").resolve() == nil) + } + + @Test func configurationFilePathHonorsExplicitTBDHome() { + let file = TBDConstants.tmuxExecutablePathFile( + environment: ["TBD_HOME": "/tmp/acme-tbd-home"] + ) + + #expect(file.path == "/tmp/acme-tbd-home/tmux-executable-path") + } +} + +private struct TmuxExecutableFixture { + let root: URL + let configurationURL: URL + + init(createConfigurationDirectory: Bool = true) throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("TmuxExecutableResolverTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + configurationURL = root + .appendingPathComponent("configuration", isDirectory: true) + .appendingPathComponent("tmux-executable-path") + if createConfigurationDirectory { + try FileManager.default.createDirectory( + at: configurationURL.deletingLastPathComponent(), + withIntermediateDirectories: false + ) + } + } + + func resolver(path: String?) -> TmuxExecutableResolver { + var environment: [String: String] = [:] + if let path { + environment["PATH"] = path + } + return TmuxExecutableResolver( + environment: environment, + configurationURL: configurationURL + ) + } + + func directory(named name: String) throws -> URL { + try directory(named: name, in: root) + } + + func directory(named name: String, in parent: URL) throws -> URL { + let directory = parent.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + return directory + } + + func executable(named name: String, in directory: URL) throws -> URL { + try file(named: name, in: directory, permissions: 0o755) + } + + func file(named name: String, in directory: URL, permissions: Int) throws -> URL { + let file = directory.appendingPathComponent(name) + try Data("fixture".utf8).write(to: file) + try FileManager.default.setAttributes( + [.posixPermissions: permissions], + ofItemAtPath: file.path + ) + return file + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } +} From 70b939bf8db52e188cf090df4194f346f00f8217 Mon Sep 17 00:00:00 2001 From: Jeffrey Burt Date: Mon, 10 Aug 2026 18:28:43 -0700 Subject: [PATCH 05/14] fix: use saved tmux executable fallback --- Sources/TBDApp/AppState.swift | 7 +- .../TBDApp/Helpers/ExecutableResolver.swift | 26 --- .../TBDApp/Terminal/TerminalPanelView.swift | 6 +- Sources/TBDApp/Terminal/TmuxBridge.swift | 178 ++++++++++++++--- Sources/TBDDaemon/Daemon.swift | 9 +- Sources/TBDDaemon/Server/RPCRouter.swift | 7 +- .../ControlMode/TmuxControlModeBridge.swift | 63 +++++- Sources/TBDDaemon/Tmux/TmuxManager.swift | 34 ++-- .../TBDAppTests/ExecutableResolverTests.swift | 125 ------------ Tests/TBDAppTests/TmuxBridgeTests.swift | 188 ++++++++++++++++-- Tests/TBDDaemonTests/AttachRPCTests.swift | 15 +- .../ControlModeSettingsRPCTests.swift | 162 ++++++++++++++- .../PaneRepairCoordinatorTests.swift | 5 +- .../TmuxPathResolutionTests.swift | 80 +++++++- 14 files changed, 644 insertions(+), 261 deletions(-) delete mode 100644 Sources/TBDApp/Helpers/ExecutableResolver.swift delete mode 100644 Tests/TBDAppTests/ExecutableResolverTests.swift diff --git a/Sources/TBDApp/AppState.swift b/Sources/TBDApp/AppState.swift index 1eab759f..87cc928d 100644 --- a/Sources/TBDApp/AppState.swift +++ b/Sources/TBDApp/AppState.swift @@ -1035,12 +1035,7 @@ final class AppState: ObservableObject { let themeStore = ThemeStore() let daemonClient = DaemonClient() - let tmuxBridge = TmuxBridge( - tmuxExecutablePath: ExecutableResolver.resolve( - "tmux", - path: ProcessInfo.processInfo.environment["PATH"] - ) - ) + let tmuxBridge = TmuxBridge(tmuxExecutableResolver: TmuxExecutableResolver()) /// App-scoped owner of control-mode stream readers (Phase 2 FD vending). /// Lives here — not on any view — so SwiftUI view destruction cannot tear /// down an active reader. Keyed by `FDVendHeader.routingKey`. diff --git a/Sources/TBDApp/Helpers/ExecutableResolver.swift b/Sources/TBDApp/Helpers/ExecutableResolver.swift deleted file mode 100644 index 75a187e3..00000000 --- a/Sources/TBDApp/Helpers/ExecutableResolver.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation - -enum ExecutableResolver { - static func resolve(_ name: String, path: String?) -> String? { - guard !name.isEmpty, !name.contains("/"), let path, !path.isEmpty else { - return nil - } - - for entry in path.split(separator: ":", omittingEmptySubsequences: false) { - let directory = String(entry) - guard !directory.isEmpty, (directory as NSString).isAbsolutePath else { - continue - } - - let candidate = URL(fileURLWithPath: directory, isDirectory: true) - .appendingPathComponent(name) - .standardizedFileURL - .path - if FileManager.default.isExecutableFile(atPath: candidate) { - return candidate - } - } - - return nil - } -} diff --git a/Sources/TBDApp/Terminal/TerminalPanelView.swift b/Sources/TBDApp/Terminal/TerminalPanelView.swift index 53a79f51..e4a14309 100644 --- a/Sources/TBDApp/Terminal/TerminalPanelView.swift +++ b/Sources/TBDApp/Terminal/TerminalPanelView.swift @@ -528,11 +528,11 @@ struct TerminalPanelRepresentable: NSViewRepresentable { guard let tmuxBridge, let processGeneration = beginGroupedViewerAttachmentConfirmation() else { return } let server = tmuxServer - let sessionName = TmuxBridge.sessionName(for: panelID) + let panelID = panelID Task { [weak self] in let attached = await tmuxBridge.hasAttachedClient( - server: server, - sessionName: sessionName + panelID: panelID, + server: server ) let shouldRetry = self?.groupedViewerAttachmentProbeDidComplete( clientAttached: attached, diff --git a/Sources/TBDApp/Terminal/TmuxBridge.swift b/Sources/TBDApp/Terminal/TmuxBridge.swift index 36a5b73c..d595662c 100644 --- a/Sources/TBDApp/Terminal/TmuxBridge.swift +++ b/Sources/TBDApp/Terminal/TmuxBridge.swift @@ -1,5 +1,6 @@ import Foundation import Darwin +import TBDShared import os private let bridgeLogger = Logger(subsystem: "com.tbd.app", category: "TmuxBridge") @@ -54,11 +55,16 @@ func debugLog(_ msg: String) { /// - When the panel is hidden, we kill the view session /// - The "main" session persists even when the app is closed final class TmuxBridge: @unchecked Sendable { + private struct ActiveSession: Sendable { + let name: String + let tmuxExecutablePath: String + } + private let lock = NSLock() - private let tmuxExecutablePath: String? + private let tmuxExecutableResolver: TmuxExecutableResolver - /// Tracks active grouped sessions: maps panel UUID -> grouped session name - private var activeSessions: [UUID: String] = [:] + /// Tracks each grouped session with the executable snapshotted for its lifecycle. + private var activeSessions: [UUID: ActiveSession] = [:] /// Serial background queue retained for any future synchronous teardown /// needs. Today cleanup is fire-and-forget via `Task { ... }` invoking @@ -66,8 +72,8 @@ final class TmuxBridge: @unchecked Sendable { /// `waitUntilExit`) so it doesn't pump the main runloop. private let cleanupQueue = DispatchQueue(label: "com.tbd.app.tmux-cleanup", qos: .utility) - init(tmuxExecutablePath: String?) { - self.tmuxExecutablePath = tmuxExecutablePath + init(tmuxExecutableResolver: TmuxExecutableResolver = TmuxExecutableResolver()) { + self.tmuxExecutableResolver = tmuxExecutableResolver } static func sessionName(for panelID: UUID) -> String { @@ -116,8 +122,8 @@ final class TmuxBridge: @unchecked Sendable { /// Complete command used for a tmux preparation subprocess. func tmuxCommand(server: String, args: [String]) -> [String]? { - guard let tmuxExecutablePath else { return nil } - return [tmuxExecutablePath, "-L", server] + args + guard let tmuxExecutablePath = tmuxExecutableResolver.resolve()?.path else { return nil } + return tmuxCommand(tmuxExecutablePath: tmuxExecutablePath, server: server, args: args) } /// Command used by the SwiftTerm PTY to attach its viewer client. @@ -127,7 +133,27 @@ final class TmuxBridge: @unchecked Sendable { /// and substitute Unicode punctuation (notably curly apostrophes) with /// underscores when it redraws the pane. func viewerAttachCommand(server: String, sessionName: String) -> [String]? { - guard let tmuxExecutablePath else { return nil } + guard let tmuxExecutablePath = tmuxExecutableResolver.resolve()?.path else { return nil } + return viewerAttachCommand( + tmuxExecutablePath: tmuxExecutablePath, + server: server, + sessionName: sessionName + ) + } + + private func tmuxCommand( + tmuxExecutablePath: String, + server: String, + args: [String] + ) -> [String] { + [tmuxExecutablePath, "-L", server] + args + } + + private func viewerAttachCommand( + tmuxExecutablePath: String, + server: String, + sessionName: String + ) -> [String] { return [tmuxExecutablePath, "-u", "-L", server, "attach", "-t", sessionName] } @@ -152,16 +178,29 @@ final class TmuxBridge: @unchecked Sendable { windowID: String ) async -> Result { let sessionName = Self.sessionName(for: panelID) - guard let preparedSession = preparedSession(server: server, sessionName: sessionName) else { + guard let tmuxExecutablePath = tmuxExecutableResolver.resolve()?.path else { return .failure(.commandFailed( stage: .createViewSession, output: "tmux executable unavailable" )) } + let preparedSession = preparedSession( + tmuxExecutablePath: tmuxExecutablePath, + server: server, + sessionName: sessionName + ) - let _ = await runTmux(server: server, args: Self.killSessionArgs(sessionName: sessionName)) + let _ = await runTmux( + tmuxExecutablePath: tmuxExecutablePath, + server: server, + args: Self.killSessionArgs(sessionName: sessionName) + ) - let createResult = await runTmux(server: server, args: Self.newIsolatedSessionArgs(sessionName: sessionName)) + let createResult = await runTmux( + tmuxExecutablePath: tmuxExecutablePath, + server: server, + args: Self.newIsolatedSessionArgs(sessionName: sessionName) + ) guard createResult.success else { debugLog("PREPARE: failed to create view session \(sessionName) on server \(server): \(createResult.output)") return .failure(Self.classifyPreparationFailure( @@ -173,57 +212,86 @@ final class TmuxBridge: @unchecked Sendable { )) } - let linkResult = await runTmux(server: server, args: Self.linkWindowArgs(windowID: windowID, sessionName: sessionName)) + let linkResult = await runTmux( + tmuxExecutablePath: tmuxExecutablePath, + server: server, + args: Self.linkWindowArgs(windowID: windowID, sessionName: sessionName) + ) guard linkResult.success else { return await failureAfterViewSessionCreation( stage: .linkWindow, output: linkResult.output, + tmuxExecutablePath: tmuxExecutablePath, server: server, windowID: windowID, sessionName: sessionName ) } - let _ = await runTmux(server: server, args: Self.killInitialWindowArgs(sessionName: sessionName)) + let _ = await runTmux( + tmuxExecutablePath: tmuxExecutablePath, + server: server, + args: Self.killInitialWindowArgs(sessionName: sessionName) + ) - let selectResult = await runTmux(server: server, args: Self.selectWindowArgs(windowID: windowID, sessionName: sessionName)) + let selectResult = await runTmux( + tmuxExecutablePath: tmuxExecutablePath, + server: server, + args: Self.selectWindowArgs(windowID: windowID, sessionName: sessionName) + ) guard selectResult.success else { return await failureAfterViewSessionCreation( stage: .selectWindow, output: selectResult.output, + tmuxExecutablePath: tmuxExecutablePath, server: server, windowID: windowID, sessionName: sessionName ) } - let remainOnExitResult = await runTmux(server: server, args: Self.remainOnExitArgs(windowID: windowID)) + let remainOnExitResult = await runTmux( + tmuxExecutablePath: tmuxExecutablePath, + server: server, + args: Self.remainOnExitArgs(windowID: windowID) + ) guard remainOnExitResult.success else { return await failureAfterViewSessionCreation( stage: .preserveExitedOutput, output: remainOnExitResult.output, + tmuxExecutablePath: tmuxExecutablePath, server: server, windowID: windowID, sessionName: sessionName ) } - let remainOnExitFormatResult = await runTmux(server: server, args: Self.remainOnExitFormatArgs(windowID: windowID)) + let remainOnExitFormatResult = await runTmux( + tmuxExecutablePath: tmuxExecutablePath, + server: server, + args: Self.remainOnExitFormatArgs(windowID: windowID) + ) guard remainOnExitFormatResult.success else { return await failureAfterViewSessionCreation( stage: .suppressExitedMarker, output: remainOnExitFormatResult.output, + tmuxExecutablePath: tmuxExecutablePath, server: server, windowID: windowID, sessionName: sessionName ) } - let activeResult = await runTmux(server: server, args: Self.activeWindowQueryArgs(sessionName: sessionName)) + let activeResult = await runTmux( + tmuxExecutablePath: tmuxExecutablePath, + server: server, + args: Self.activeWindowQueryArgs(sessionName: sessionName) + ) guard activeResult.success, activeResult.output == windowID else { return await failureAfterViewSessionCreation( stage: .verifySelection, output: activeResult.output, + tmuxExecutablePath: tmuxExecutablePath, server: server, windowID: windowID, sessionName: sessionName @@ -231,7 +299,10 @@ final class TmuxBridge: @unchecked Sendable { } lock.withLock { - activeSessions[panelID] = sessionName + activeSessions[panelID] = ActiveSession( + name: sessionName, + tmuxExecutablePath: tmuxExecutablePath + ) } debugLog("PREPARE: panelID=\(panelID.uuidString.prefix(8)) server=\(server) window=\(windowID) session=\(sessionName)") @@ -246,15 +317,19 @@ final class TmuxBridge: @unchecked Sendable { /// during SwiftUI dismantle. func cleanupSession(panelID: UUID, server: String) { lock.lock() - guard let sessionName = activeSessions.removeValue(forKey: panelID) else { + guard let session = activeSessions.removeValue(forKey: panelID) else { lock.unlock() return } lock.unlock() Task.detached { [self] in - let _ = await runTmux(server: server, args: Self.killSessionArgs(sessionName: sessionName)) - debugLog("CLEANUP: panelID=\(panelID.uuidString.prefix(8)) session=\(sessionName)") + let _ = await runTmux( + tmuxExecutablePath: session.tmuxExecutablePath, + server: server, + args: Self.killSessionArgs(sessionName: session.name) + ) + debugLog("CLEANUP: panelID=\(panelID.uuidString.prefix(8)) session=\(session.name)") } } @@ -266,8 +341,12 @@ final class TmuxBridge: @unchecked Sendable { lock.unlock() Task.detached { [self] in - for (_, sessionName) in sessions { - let _ = await runTmux(server: server, args: Self.killSessionArgs(sessionName: sessionName)) + for (_, session) in sessions { + let _ = await runTmux( + tmuxExecutablePath: session.tmuxExecutablePath, + server: server, + args: Self.killSessionArgs(sessionName: session.name) + ) } debugLog("CLEANUP ALL: server=\(server)") } @@ -281,8 +360,24 @@ final class TmuxBridge: @unchecked Sendable { } func preparedSession(server: String, sessionName: String) -> TmuxPreparedSession? { - let viewerCommand = viewerAttachCommand(server: server, sessionName: sessionName) - guard let viewerCommand else { return nil } + guard let tmuxExecutablePath = tmuxExecutableResolver.resolve()?.path else { return nil } + return preparedSession( + tmuxExecutablePath: tmuxExecutablePath, + server: server, + sessionName: sessionName + ) + } + + private func preparedSession( + tmuxExecutablePath: String, + server: String, + sessionName: String + ) -> TmuxPreparedSession { + let viewerCommand = viewerAttachCommand( + tmuxExecutablePath: tmuxExecutablePath, + server: server, + sessionName: sessionName + ) return TmuxPreparedSession( executablePath: viewerCommand[0], arguments: Array(viewerCommand.dropFirst()) @@ -299,12 +394,17 @@ final class TmuxBridge: @unchecked Sendable { return clientSessions.contains(expectedSessionName) } - func hasAttachedClient(server: String, sessionName: String) async -> Bool { - let result = await runTmux(server: server, args: Self.clientSessionQueryArgs()) + func hasAttachedClient(panelID: UUID, server: String) async -> Bool { + guard let session = lock.withLock({ activeSessions[panelID] }) else { return false } + let result = await runTmux( + tmuxExecutablePath: session.tmuxExecutablePath, + server: server, + args: Self.clientSessionQueryArgs() + ) return Self.clientInventoryConfirmsAttachment( querySucceeded: result.success, output: result.output, - expectedSessionName: sessionName + expectedSessionName: session.name ) } @@ -335,11 +435,13 @@ final class TmuxBridge: @unchecked Sendable { private func failureAfterViewSessionCreation( stage: TmuxPreparationStage, output: String, + tmuxExecutablePath: String, server: String, windowID: String, sessionName: String ) async -> Result { let probeResult = await runTmux( + tmuxExecutablePath: tmuxExecutablePath, server: server, args: Self.windowInventoryQueryArgs() ) @@ -354,7 +456,11 @@ final class TmuxBridge: @unchecked Sendable { bridgeLogger.debug( "Preparation failed at \(stage.rawValue, privacy: .public): \(output, privacy: .public)" ) - let _ = await runTmux(server: server, args: Self.killSessionArgs(sessionName: sessionName)) + let _ = await runTmux( + tmuxExecutablePath: tmuxExecutablePath, + server: server, + args: Self.killSessionArgs(sessionName: sessionName) + ) return .failure(failure) } @@ -365,10 +471,16 @@ final class TmuxBridge: @unchecked Sendable { /// `makeNSView` for tens to hundreds of ms per panel (tmux fork+exec + /// new-session/select-window), starving SwiftUI's render loop so newly /// inserted terminal panels never displayed content. - private func runTmux(server: String, args: [String]) async -> TmuxResult { - guard let command = tmuxCommand(server: server, args: args) else { - return TmuxResult(success: false, output: "tmux executable unavailable") - } + private func runTmux( + tmuxExecutablePath: String, + server: String, + args: [String] + ) async -> TmuxResult { + let command = tmuxCommand( + tmuxExecutablePath: tmuxExecutablePath, + server: server, + args: args + ) return await withCheckedContinuation { continuation in let process = Process() diff --git a/Sources/TBDDaemon/Daemon.swift b/Sources/TBDDaemon/Daemon.swift index 551146ed..0da1ad1c 100644 --- a/Sources/TBDDaemon/Daemon.swift +++ b/Sources/TBDDaemon/Daemon.swift @@ -389,11 +389,13 @@ public final class Daemon: Sendable { ) let pendingQuestions = PendingQuestionStore() - // Detect the local tmux version once. The control-mode bridge is shared + // Snapshot the effective tmux path and its version together. The + // control-mode bridge is shared // by lifecycle + router so every `ensureServer()` call site can open a // gated control connection through a single supervisor. When the gate // is off (the default), `enableIfGated` is a no-op. - let tmuxVersion = await TmuxVersion.detect() + let tmuxExecutableResolver = TmuxExecutableResolver() + let startupTmux = await TmuxVersionSnapshot.detect(using: tmuxExecutableResolver) // Input activity tracker: records the timestamp of the last keystroke // routed to each pane so the idle sweep can veto a park if input arrived // after the session went idle (pending-input detection). @@ -416,7 +418,8 @@ public final class Daemon: Sendable { ) let controlModeBridge = TmuxControlModeBridge( supervisor: controlModeSupervisor, - tmuxVersion: tmuxVersion, + startupTmux: startupTmux, + tmuxExecutableResolver: tmuxExecutableResolver, fdVending: fdVendingServer, inputRouter: controlModeInputRouter, // Live provider, not a snapshot: the gate re-reads the persisted diff --git a/Sources/TBDDaemon/Server/RPCRouter.swift b/Sources/TBDDaemon/Server/RPCRouter.swift index a9ad40f2..450eaef6 100644 --- a/Sources/TBDDaemon/Server/RPCRouter.swift +++ b/Sources/TBDDaemon/Server/RPCRouter.swift @@ -527,12 +527,15 @@ public final class RPCRouter: Sendable { /// visible on the next fetch without a daemon restart. func handleDaemonCapabilities() async throws -> RPCResponse { let enabled: Bool + let version: TmuxVersion? if let bridge = controlMode { - enabled = await bridge.gateEnabled() + let gateState = await bridge.currentGateState() + enabled = gateState.enabled + version = gateState.tmuxVersion } else { enabled = false + version = nil } - let version = controlMode?.tmuxVersion let config = try await db.config.get() return try RPCResponse(result: DaemonCapabilitiesResult( controlModeEnabled: enabled, diff --git a/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlModeBridge.swift b/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlModeBridge.swift index b8128726..101091e6 100644 --- a/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlModeBridge.swift +++ b/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlModeBridge.swift @@ -1,7 +1,23 @@ import Foundation +import TBDShared -/// Bundles the per-daemon `TmuxControlSupervisor` with the once-detected tmux -/// version so every `ensureServer()` call site can open a gated control-mode +/// A tmux version paired with the exact executable path it was detected from. +/// Keeping the two values together prevents a resolver change between version +/// detection and bridge construction from mislabeling one executable's version +/// as another's. +struct TmuxVersionSnapshot: Sendable { + let executablePath: String? + let version: TmuxVersion? + + static func detect(using resolver: TmuxExecutableResolver) async -> TmuxVersionSnapshot { + let executablePath = resolver.resolve()?.path + let version = await TmuxVersion.detect(tmuxBinary: executablePath) + return TmuxVersionSnapshot(executablePath: executablePath, version: version) + } +} + +/// Bundles the per-daemon `TmuxControlSupervisor` with tmux version resolution +/// so every `ensureServer()` call site can open a gated control-mode /// connection through a single shared owner. /// /// `Daemon` constructs exactly one of these at startup and hands the same @@ -12,9 +28,12 @@ struct TmuxControlModeBridge: Sendable { /// The single per-daemon supervisor. Connections are keyed by server name /// and `ensureConnection` is idempotent, so all call sites share one. let supervisor: TmuxControlSupervisor - /// tmux version detected once at daemon startup; `nil` when detection - /// failed (tmux missing/unparseable), which keeps the gate closed. - let tmuxVersion: TmuxVersion? + /// Path/version pair detected at daemon startup. Its version is reused only + /// while the resolver still selects the paired executable. + let startupTmux: TmuxVersionSnapshot + /// Resolves PATH first and the live saved fallback second on every gate + /// and capabilities decision. + let tmuxExecutableResolver: TmuxExecutableResolver /// Environment the gate reads. Injectable so tests can flip the gate. let environment: [String: String] /// Sidecar over which attach handlers vend pane fds. @@ -56,7 +75,8 @@ struct TmuxControlModeBridge: Sendable { let clock: any Clock init(supervisor: TmuxControlSupervisor, - tmuxVersion: TmuxVersion?, + startupTmux: TmuxVersionSnapshot, + tmuxExecutableResolver: TmuxExecutableResolver = TmuxExecutableResolver(), environment: [String: String] = ProcessInfo.processInfo.environment, fdVending: FDVendingServer, readyTimeout: Duration = .seconds(5), @@ -68,7 +88,8 @@ struct TmuxControlModeBridge: Sendable { clock: any Clock = ContinuousClock()) { self.supervisor = supervisor self.clock = clock - self.tmuxVersion = tmuxVersion + self.tmuxExecutableResolver = tmuxExecutableResolver + self.startupTmux = startupTmux self.environment = environment self.fdVending = fdVending self.readyTimeout = readyTimeout @@ -110,15 +131,35 @@ struct TmuxControlModeBridge: Sendable { } } + /// Current tmux version. The startup result is cached only for the same + /// effective executable path; a changed PATH or saved fallback is detected + /// at use so Settings changes take effect without a daemon restart. + func currentTmuxVersion() async -> TmuxVersion? { + guard let executablePath = tmuxExecutableResolver.resolve()?.path else { return nil } + if executablePath == startupTmux.executablePath, let version = startupTmux.version { + return version + } + return await TmuxVersion.detect(tmuxBinary: executablePath) + } + + /// Effective gate decision and the version used for it, evaluated from one + /// snapshot so capability fields cannot disagree with the enabled state. + func currentGateState() async -> (enabled: Bool, tmuxVersion: TmuxVersion?) { + let version = await currentTmuxVersion() + let enabled = ControlModeGate.shouldEnable( + environment: environment, + persistedFlag: await persistedFlagProvider(), + tmuxVersion: version + ) + return (enabled, version) + } + /// Effective gate decision, evaluated fresh on every call: /// `(env opt-in || persisted flag) && tmux >= 3.2`. The persisted flag is /// read through `persistedFlagProvider`, so a Settings toggle takes /// effect on the next decision without a daemon restart. func gateEnabled() async -> Bool { - ControlModeGate.shouldEnable( - environment: environment, - persistedFlag: await persistedFlagProvider(), - tmuxVersion: tmuxVersion) + await currentGateState().enabled } /// Open a logging-only `tmux -CC` connection for `serverName` when the diff --git a/Sources/TBDDaemon/Tmux/TmuxManager.swift b/Sources/TBDDaemon/Tmux/TmuxManager.swift index bf071a44..96e91cd9 100644 --- a/Sources/TBDDaemon/Tmux/TmuxManager.swift +++ b/Sources/TBDDaemon/Tmux/TmuxManager.swift @@ -1,4 +1,5 @@ import Foundation +import TBDShared import os private let logger = Logger(subsystem: "com.tbd.daemon", category: "TmuxManager") @@ -1048,28 +1049,21 @@ public struct TmuxManager: Sendable { // MARK: - Private - /// Resolves tmux from the daemon's inherited PATH without adding fallback directories. + /// Resolves tmux from the daemon's inherited PATH, then the saved executable fallback. static func tmuxPath( - path: String? = ProcessInfo.processInfo.environment["PATH"] + path: String? = ProcessInfo.processInfo.environment["PATH"], + configurationURL: URL? = nil ) -> String? { - guard let path, !path.isEmpty else { return nil } - - for entry in path.split(separator: ":", omittingEmptySubsequences: false) { - let directory = String(entry) - guard !directory.isEmpty, (directory as NSString).isAbsolutePath else { - continue - } - - let candidate = URL(fileURLWithPath: directory, isDirectory: true) - .appendingPathComponent("tmux") - .standardizedFileURL - .path - if FileManager.default.isExecutableFile(atPath: candidate) { - return candidate - } + var environment = ProcessInfo.processInfo.environment + if let path { + environment["PATH"] = path + } else { + environment.removeValue(forKey: "PATH") } - - return nil + return TmuxExecutableResolver( + environment: environment, + configurationURL: configurationURL + ).resolve()?.path } @discardableResult @@ -1078,7 +1072,7 @@ public struct TmuxManager: Sendable { throw TmuxError.commandFailed( command: "tmux " + arguments.joined(separator: " "), status: 127, - output: "tmux is unavailable on PATH" + output: "tmux executable is unavailable" ) } return try await Self.runExternalCommand( diff --git a/Tests/TBDAppTests/ExecutableResolverTests.swift b/Tests/TBDAppTests/ExecutableResolverTests.swift deleted file mode 100644 index 898a3c23..00000000 --- a/Tests/TBDAppTests/ExecutableResolverTests.swift +++ /dev/null @@ -1,125 +0,0 @@ -import Foundation -import Testing -@testable import TBDApp - -@Suite("ExecutableResolver") -struct ExecutableResolverTests { - @Test func returnsFirstExecutableInPathOrderAsStandardizedAbsolutePath() throws { - let fixture = try ExecutableFixture() - defer { fixture.remove() } - let firstDirectory = try fixture.directory(named: "first") - let secondDirectory = try fixture.directory(named: "second") - let firstExecutable = try fixture.executable(named: "tmux", in: firstDirectory) - _ = try fixture.executable(named: "tmux", in: secondDirectory) - - let unstandardizedFirstDirectory = firstDirectory - .appendingPathComponent("child") - .appendingPathComponent("..") - try FileManager.default.createDirectory( - at: firstDirectory.appendingPathComponent("child"), - withIntermediateDirectories: false - ) - - #expect(ExecutableResolver.resolve( - "tmux", - path: "\(unstandardizedFirstDirectory.path):\(secondDirectory.path)" - ) == firstExecutable.standardizedFileURL.path) - } - - @Test func skipsNonExecutableCandidateForLaterExecutable() throws { - let fixture = try ExecutableFixture() - defer { fixture.remove() } - let firstDirectory = try fixture.directory(named: "first") - let secondDirectory = try fixture.directory(named: "second") - _ = try fixture.file(named: "tmux", in: firstDirectory, permissions: 0o644) - let secondExecutable = try fixture.executable(named: "tmux", in: secondDirectory) - - #expect(ExecutableResolver.resolve( - "tmux", - path: "\(firstDirectory.path):\(secondDirectory.path)" - ) == secondExecutable.standardizedFileURL.path) - } - - @Test func handlesDirectoryNamesContainingSpaces() throws { - let fixture = try ExecutableFixture() - defer { fixture.remove() } - let directory = try fixture.directory(named: "tools with spaces") - let executable = try fixture.executable(named: "tmux", in: directory) - - #expect(ExecutableResolver.resolve("tmux", path: directory.path) == executable.path) - } - - @Test func ignoresEmptyAndRelativePathEntries() throws { - let fixture = try ExecutableFixture() - defer { fixture.remove() } - let directory = try fixture.directory(named: "absolute") - let executable = try fixture.executable(named: "tmux", in: directory) - - #expect(ExecutableResolver.resolve( - "tmux", - path: ":relative-bin::\(directory.path)" - ) == executable.path) - } - - @Test func returnsNilForMissingNilOrEmptyPath() throws { - let fixture = try ExecutableFixture() - defer { fixture.remove() } - let directory = try fixture.directory(named: "empty") - - #expect(ExecutableResolver.resolve("tmux", path: nil) == nil) - #expect(ExecutableResolver.resolve("tmux", path: "") == nil) - #expect(ExecutableResolver.resolve("tmux", path: directory.path) == nil) - } - - @Test func rejectsEmptyAndSlashedExecutableNames() throws { - let fixture = try ExecutableFixture() - defer { fixture.remove() } - let directory = try fixture.directory(named: "bin") - _ = try fixture.executable(named: "tmux", in: directory) - - #expect(ExecutableResolver.resolve("", path: directory.path) == nil) - #expect(ExecutableResolver.resolve("tools/tmux", path: directory.path) == nil) - } - - @Test func doesNotSearchStandardLocationsOutsidePath() throws { - let fixture = try ExecutableFixture() - defer { fixture.remove() } - let directory = try fixture.directory(named: "empty") - - #expect(ExecutableResolver.resolve("sh", path: directory.path) == nil) - } -} - -private struct ExecutableFixture { - let root: URL - - init() throws { - root = FileManager.default.temporaryDirectory - .appendingPathComponent("ExecutableResolverTests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) - } - - func directory(named name: String) throws -> URL { - let directory = root.appendingPathComponent(name, isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) - return directory - } - - func executable(named name: String, in directory: URL) throws -> URL { - try file(named: name, in: directory, permissions: 0o755) - } - - func file(named name: String, in directory: URL, permissions: Int) throws -> URL { - let file = directory.appendingPathComponent(name) - try Data("fixture".utf8).write(to: file) - try FileManager.default.setAttributes( - [.posixPermissions: permissions], - ofItemAtPath: file.path - ) - return file - } - - func remove() { - try? FileManager.default.removeItem(at: root) - } -} diff --git a/Tests/TBDAppTests/TmuxBridgeTests.swift b/Tests/TBDAppTests/TmuxBridgeTests.swift index 50c4f485..ea562baf 100644 --- a/Tests/TBDAppTests/TmuxBridgeTests.swift +++ b/Tests/TBDAppTests/TmuxBridgeTests.swift @@ -1,4 +1,5 @@ import Foundation +import TBDShared import Testing @testable import TBDApp @@ -45,9 +46,20 @@ struct TmuxBridgeTests { ]) } - @Test func preparationAndViewerCommandsUseProvidedAbsoluteExecutable() throws { - let executablePath = "/nonstandard/tools/tmux" - let bridge = TmuxBridge(tmuxExecutablePath: executablePath) + @Test func commandsObserveSavedFallbackAfterInitializationAndPathStillWins() throws { + let fixture = try TmuxBridgeFixture() + defer { fixture.remove() } + let pathDirectory = try fixture.directory(named: "path") + let savedExecutable = try fixture.executable(named: "saved-tmux", in: fixture.root) + let resolver = TmuxExecutableResolver( + environment: ["PATH": pathDirectory.path], + configurationURL: fixture.configurationURL + ) + let bridge = TmuxBridge(tmuxExecutableResolver: resolver) + + #expect(bridge.tmuxCommand(server: "tbd-repo", args: ["list-windows"]) == nil) + + try resolver.save(savedExecutable.path) let preparation = try #require(bridge.tmuxCommand( server: "tbd-repo", @@ -59,25 +71,29 @@ struct TmuxBridgeTests { )) #expect(preparation == [ - executablePath, "-L", "tbd-repo", "display-message", "-p", "#{window_id}", + savedExecutable.path, "-L", "tbd-repo", "display-message", "-p", "#{window_id}", ]) #expect(viewer == [ - executablePath, "-u", "-L", "tbd-repo", "attach", "-t", "tbd-view-4c4f1a61", + savedExecutable.path, "-u", "-L", "tbd-repo", "attach", "-t", "tbd-view-4c4f1a61", ]) #expect(preparation.first == viewer.first) - let forbiddenExecutables = [ - "/usr/bin/env", - "/opt/homebrew/bin/tmux", - "/usr/local/bin/tmux", - "/usr/bin/tmux", - ] - #expect(!forbiddenExecutables.contains(preparation[0])) - #expect(!forbiddenExecutables.contains(viewer[0])) + let pathExecutable = try fixture.executable(named: "tmux", in: pathDirectory) + + #expect(bridge.tmuxCommand(server: "tbd-repo", args: ["list-windows"])?.first == pathExecutable.path) + #expect(bridge.viewerAttachCommand( + server: "tbd-repo", + sessionName: "tbd-view-4c4f1a61" + )?.first == pathExecutable.path) } - @Test func unresolvedExecutableProducesNoPreparationOrViewerCommand() { - let bridge = TmuxBridge(tmuxExecutablePath: nil) + @Test func unresolvedExecutableProducesNoPreparationOrViewerCommand() throws { + let fixture = try TmuxBridgeFixture() + defer { fixture.remove() } + let bridge = TmuxBridge(tmuxExecutableResolver: TmuxExecutableResolver( + environment: ["PATH": ""], + configurationURL: fixture.configurationURL + )) #expect(bridge.tmuxCommand(server: "tbd-repo", args: ["list-windows"]) == nil) #expect(bridge.viewerAttachCommand( @@ -105,13 +121,21 @@ struct TmuxBridgeTests { } @Test func preparedSessionCarriesViewerCommand() throws { - let bridge = TmuxBridge(tmuxExecutablePath: "/nonstandard/tools/tmux") + let fixture = try TmuxBridgeFixture() + defer { fixture.remove() } + let executable = try fixture.executable(named: "saved-tmux", in: fixture.root) + let resolver = TmuxExecutableResolver( + environment: ["PATH": ""], + configurationURL: fixture.configurationURL + ) + try resolver.save(executable.path) + let bridge = TmuxBridge(tmuxExecutableResolver: resolver) let prepared = try #require(bridge.preparedSession( server: "tbd-repo", sessionName: "tbd-view-4c4f1a61" )) #expect(prepared == TmuxPreparedSession( - executablePath: "/nonstandard/tools/tmux", + executablePath: executable.path, arguments: ["-u", "-L", "tbd-repo", "attach", "-t", "tbd-view-4c4f1a61"] )) } @@ -155,4 +179,134 @@ struct TmuxBridgeTests { expectedWindowID: "@147" ) == .windowMissing(failedStage: .linkWindow)) } + + // Tier 2: real filesystem and subprocesses, all contained by the fixture. + @Test func preparedSessionKeepsExecutableSnapshotForViewerAndCleanup() async throws { + let fixture = try TmuxBridgeFixture() + defer { fixture.remove() } + let firstLog = fixture.root.appendingPathComponent("first.log") + let secondLog = fixture.root.appendingPathComponent("second.log") + let secondExecutable = try fixture.tmuxExecutable(named: "second-tmux", logURL: secondLog) + let firstExecutable = try fixture.tmuxExecutable( + named: "first-tmux", + logURL: firstLog, + replacement: (configurationURL: fixture.configurationURL, executable: secondExecutable), + clientSessionName: "tbd-view-4c4f1a61" + ) + let resolver = TmuxExecutableResolver( + environment: ["PATH": ""], + configurationURL: fixture.configurationURL + ) + try resolver.save(firstExecutable.path) + let bridge = TmuxBridge(tmuxExecutableResolver: resolver) + let panelID = UUID(uuidString: "4C4F1A61-F385-46AB-861D-42A425DB427B")! + + let result = await bridge.prepareSession( + panelID: panelID, + server: "tbd-repo", + windowID: "@147" + ) + let prepared = try result.get() + + #expect(resolver.savedPath == secondExecutable.path) + #expect(prepared.executablePath == firstExecutable.path) + #expect(prepared.arguments == [ + "-u", "-L", "tbd-repo", "attach", "-t", TmuxBridge.sessionName(for: panelID), + ]) + let firstInvocationCount = fixture.lineCount(at: firstLog) + #expect(firstInvocationCount > 0) + #expect(fixture.lineCount(at: secondLog) == 0) + + #expect(await bridge.hasAttachedClient(panelID: panelID, server: "tbd-repo")) + let afterConfirmationInvocationCount = fixture.lineCount(at: firstLog) + #expect(afterConfirmationInvocationCount == firstInvocationCount + 1) + #expect(fixture.lineCount(at: secondLog) == 0) + + bridge.cleanupSession(panelID: panelID, server: "tbd-repo") + try await fixture.waitForLineCount(afterConfirmationInvocationCount + 1, at: firstLog) + + #expect(fixture.lineCount(at: firstLog) == afterConfirmationInvocationCount + 1) + #expect(fixture.lineCount(at: secondLog) == 0) + #expect(bridge.tmuxCommand(server: "tbd-repo", args: ["list-windows"])?.first + == secondExecutable.path) + } +} + +private struct TmuxBridgeFixture { + let root: URL + let configurationURL: URL + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("TmuxBridgeTests-\(UUID().uuidString)", isDirectory: true) + configurationURL = root.appendingPathComponent("tmux-executable-path") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + } + + func directory(named name: String) throws -> URL { + let directory = root.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + return directory + } + + func executable(named name: String, in directory: URL) throws -> URL { + let executable = directory.appendingPathComponent(name) + try Data("fixture".utf8).write(to: executable) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: executable.path + ) + return executable + } + + func tmuxExecutable( + named name: String, + logURL: URL, + replacement: (configurationURL: URL, executable: URL)? = nil, + clientSessionName: String? = nil + ) throws -> URL { + let executable = root.appendingPathComponent(name) + let replacementScript: String + if let replacement { + replacementScript = "printf '%s' '\(replacement.executable.path)' > '\(replacement.configurationURL.path)'" + } else { + replacementScript = ":" + } + let clientSessionScript = clientSessionName.map { "printf '%s\\n' '\($0)'" } ?? ":" + let script = """ + #!/bin/sh + printf '%s\\n' "$*" >> '\(logURL.path)' + case "$*" in + *display-message*) + \(replacementScript) + printf '%s\\n' '@147' + ;; + *list-clients*) + \(clientSessionScript) + ;; + esac + """ + try script.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: executable.path + ) + return executable + } + + func lineCount(at url: URL) -> Int { + let contents = (try? String(contentsOf: url, encoding: .utf8)) ?? "" + return contents.split(separator: "\n").count + } + + func waitForLineCount(_ expectedCount: Int, at url: URL) async throws { + for _ in 0..<100 { + if lineCount(at: url) >= expectedCount { return } + try await Task.sleep(for: .milliseconds(10)) + } + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } } diff --git a/Tests/TBDDaemonTests/AttachRPCTests.swift b/Tests/TBDDaemonTests/AttachRPCTests.swift index 9d5b8a34..674eaff4 100644 --- a/Tests/TBDDaemonTests/AttachRPCTests.swift +++ b/Tests/TBDDaemonTests/AttachRPCTests.swift @@ -60,7 +60,10 @@ struct AttachRPCStubTests { let worktreeID = try await makeWorktree(in: db) router.controlMode = TmuxControlModeBridge( supervisor: TmuxControlSupervisor(), - tmuxVersion: TmuxVersion(major: 3, minor: 6), + startupTmux: TmuxVersionSnapshot( + executablePath: TmuxExecutableResolver().resolve()?.path, + version: TmuxVersion(major: 3, minor: 6) + ), environment: ["TBD_TMUX_CONTROL_MODE": "1"], fdVending: FDVendingServer()) let request = try RPCRequest( @@ -99,7 +102,10 @@ struct AttachRPCStubTests { let (router, _) = try makeRouterAndDB() router.controlMode = TmuxControlModeBridge( supervisor: TmuxControlSupervisor(), - tmuxVersion: TmuxVersion(major: 3, minor: 6), + startupTmux: TmuxVersionSnapshot( + executablePath: TmuxExecutableResolver().resolve()?.path, + version: TmuxVersion(major: 3, minor: 6) + ), environment: ["TBD_TMUX_CONTROL_MODE": "1"], fdVending: FDVendingServer()) let request = RPCRequest(method: RPCMethod.daemonCapabilities) @@ -144,7 +150,10 @@ struct AttachRPCOrchestrationTests { ) -> TmuxControlModeBridge { TmuxControlModeBridge( supervisor: supervisor, - tmuxVersion: TmuxVersion(major: 3, minor: 6), + startupTmux: TmuxVersionSnapshot( + executablePath: TmuxExecutableResolver().resolve()?.path, + version: TmuxVersion(major: 3, minor: 6) + ), environment: gateOn ? ["TBD_TMUX_CONTROL_MODE": "1"] : [:], fdVending: vending, readyTimeout: readyTimeout, diff --git a/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift b/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift index 4a6a36ac..fd319089 100644 --- a/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift +++ b/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift @@ -60,11 +60,17 @@ struct ControlModeSettingsRPCTests { db: TBDDatabase, vending: FDVendingServer = FDVendingServer(), environment: [String: String] = [:], - tmuxVersion: TmuxVersion? = TmuxVersion(major: 3, minor: 6) + tmuxVersion: TmuxVersion? = TmuxVersion(major: 3, minor: 6), + tmuxExecutableResolver: TmuxExecutableResolver = TmuxExecutableResolver(), + startupTmux: TmuxVersionSnapshot? = nil ) -> TmuxControlModeBridge { TmuxControlModeBridge( supervisor: TmuxControlSupervisor(), - tmuxVersion: tmuxVersion, + startupTmux: startupTmux ?? TmuxVersionSnapshot( + executablePath: tmuxExecutableResolver.resolve()?.path, + version: tmuxVersion + ), + tmuxExecutableResolver: tmuxExecutableResolver, environment: environment, fdVending: vending, persistedFlagProvider: { [config = db.config] in @@ -149,6 +155,122 @@ struct ControlModeSettingsRPCTests { #expect(result.controlModeEnabled == true) } + // Tier 2: real filesystem and a fixture-owned version subprocess. + @Test("capabilities and gate retry version detection after a fallback is saved") + func capabilitiesRetryVersionDetectionAfterSavedFallback() async throws { + let fixture = try TmuxVersionFallbackFixture() + defer { fixture.remove() } + let emptyDirectory = try fixture.directory(named: "empty-path") + let resolver = TmuxExecutableResolver( + environment: ["PATH": emptyDirectory.path], + configurationURL: fixture.configurationURL + ) + let (router, db) = try makeRouterAndDB() + try await db.config.setControlModeEnabled(true) + let liveBridge = bridge( + db: db, + tmuxVersion: nil, + tmuxExecutableResolver: resolver + ) + router.controlMode = liveBridge + + #expect(await liveBridge.currentTmuxVersion() == nil) + #expect(await liveBridge.gateEnabled() == false) + + let executable = try fixture.versionExecutable(version: "3.6a") + try resolver.save(executable.path) + + #expect(await liveBridge.currentTmuxVersion()?.description == "3.6a") + #expect(await liveBridge.gateEnabled() == true) + let response = await router.handle(RPCRequest(method: RPCMethod.daemonCapabilities)) + let result = try response.decodeResult(DaemonCapabilitiesResult.self) + #expect(result.tmuxVersion == "3.6a") + #expect(result.controlModeSupported == true) + #expect(result.controlModeEnabled == true) + } + + // Tier 2: real filesystem and fixture-owned version subprocesses. + @Test("capabilities and gate follow a changed saved fallback after successful startup detection") + func capabilitiesFollowChangedSavedFallback() async throws { + let fixture = try TmuxVersionFallbackFixture() + defer { fixture.remove() } + let emptyDirectory = try fixture.directory(named: "empty-path") + let executableA = try fixture.versionExecutable(named: "tmux-a", version: "3.6") + let executableB = try fixture.versionExecutable(named: "tmux-b", version: "3.1") + let resolver = TmuxExecutableResolver( + environment: ["PATH": emptyDirectory.path], + configurationURL: fixture.configurationURL + ) + try resolver.save(executableA.path) + let startupVersion = try #require(await TmuxVersion.detect(tmuxBinary: executableA.path)) + let (router, db) = try makeRouterAndDB() + try await db.config.setControlModeEnabled(true) + let liveBridge = bridge( + db: db, + tmuxVersion: startupVersion, + tmuxExecutableResolver: resolver, + startupTmux: TmuxVersionSnapshot( + executablePath: executableA.path, + version: startupVersion + ) + ) + router.controlMode = liveBridge + + #expect(await liveBridge.currentTmuxVersion()?.description == "3.6") + #expect(await liveBridge.gateEnabled()) + + try resolver.save(executableB.path) + + #expect(await liveBridge.currentTmuxVersion()?.description == "3.1") + #expect(await liveBridge.gateEnabled() == false) + let response = await router.handle(RPCRequest(method: RPCMethod.daemonCapabilities)) + let result = try response.decodeResult(DaemonCapabilitiesResult.self) + #expect(result.tmuxVersion == "3.1") + #expect(result.controlModeSupported == false) + #expect(result.controlModeEnabled == false) + } + + // Tier 2: real filesystem and fixture-owned version subprocesses. + @Test("startup detection remains paired with its executable when fallback changes before bridge construction") + func startupDetectionKeepsItsExecutablePath() async throws { + let fixture = try TmuxVersionFallbackFixture() + defer { fixture.remove() } + let emptyDirectory = try fixture.directory(named: "empty-path") + let executableA = try fixture.versionExecutable(named: "tmux-a", version: "3.6") + let executableB = try fixture.versionExecutable(named: "tmux-b", version: "3.1") + let resolver = TmuxExecutableResolver( + environment: ["PATH": emptyDirectory.path], + configurationURL: fixture.configurationURL + ) + try resolver.save(executableA.path) + let startupVersion = try #require(await TmuxVersion.detect(tmuxBinary: executableA.path)) + + // Model a Settings write in the interval between startup detection and + // bridge construction. The detected version still belongs to A. + try resolver.save(executableB.path) + + let (router, db) = try makeRouterAndDB() + try await db.config.setControlModeEnabled(true) + let liveBridge = bridge( + db: db, + tmuxVersion: startupVersion, + tmuxExecutableResolver: resolver, + startupTmux: TmuxVersionSnapshot( + executablePath: executableA.path, + version: startupVersion + ) + ) + router.controlMode = liveBridge + + #expect(await liveBridge.currentTmuxVersion()?.description == "3.1") + #expect(await liveBridge.gateEnabled() == false) + let response = await router.handle(RPCRequest(method: RPCMethod.daemonCapabilities)) + let result = try response.decodeResult(DaemonCapabilitiesResult.self) + #expect(result.tmuxVersion == "3.1") + #expect(result.controlModeSupported == false) + #expect(result.controlModeEnabled == false) + } + /// Codable back-compat: capabilities JSON from a pre-M5 daemon (no new /// keys) must still decode in a newer app. @Test("capabilities JSON without the new keys decodes with safe defaults") @@ -234,3 +356,39 @@ struct ControlModeSettingsRPCTests { #expect(result.status == "unavailable") } } + +private struct TmuxVersionFallbackFixture { + let root: URL + let configurationURL: URL + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("TmuxVersionFallbackTests-\(UUID().uuidString)", isDirectory: true) + configurationURL = root.appendingPathComponent("tmux-executable-path") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + } + + func directory(named name: String) throws -> URL { + let directory = root.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + return directory + } + + func versionExecutable(named name: String = "custom-tmux", version: String) throws -> URL { + let executable = root.appendingPathComponent(name) + let script = """ + #!/bin/sh + printf '%s\\n' 'tmux \(version)' + """ + try script.write(to: executable, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: executable.path + ) + return executable + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } +} diff --git a/Tests/TBDDaemonTests/PaneRepairCoordinatorTests.swift b/Tests/TBDDaemonTests/PaneRepairCoordinatorTests.swift index 7f789e63..5d6acf7e 100644 --- a/Tests/TBDDaemonTests/PaneRepairCoordinatorTests.swift +++ b/Tests/TBDDaemonTests/PaneRepairCoordinatorTests.swift @@ -694,7 +694,10 @@ struct PaneRepairCoordinatorTests { let clock = TestClock() let bridge = TmuxControlModeBridge( supervisor: supervisor, - tmuxVersion: TmuxVersion(major: 3, minor: 6), + startupTmux: TmuxVersionSnapshot( + executablePath: TmuxManager.tmuxPath(), + version: TmuxVersion(major: 3, minor: 6) + ), environment: [:], fdVending: FDVendingServer(), commandProvider: { [server] in $0 == server ? client : nil }, diff --git a/Tests/TBDDaemonTests/TmuxPathResolutionTests.swift b/Tests/TBDDaemonTests/TmuxPathResolutionTests.swift index b3c3707a..69026811 100644 --- a/Tests/TBDDaemonTests/TmuxPathResolutionTests.swift +++ b/Tests/TBDDaemonTests/TmuxPathResolutionTests.swift @@ -1,4 +1,5 @@ import Foundation +import TBDShared import Testing @testable import TBDDaemonLib @@ -11,7 +12,10 @@ struct TmuxPathResolutionTests { let toolsDirectory = try fixture.directory(named: "custom tools") let executable = try fixture.tmux(in: toolsDirectory, permissions: 0o755) - #expect(TmuxManager.tmuxPath(path: toolsDirectory.path) == executable.path) + #expect(TmuxManager.tmuxPath( + path: toolsDirectory.path, + configurationURL: fixture.configurationURL + ) == executable.path) } @Test @@ -22,7 +26,10 @@ struct TmuxPathResolutionTests { let emptyDirectory = try fixture.directory(named: "empty") _ = try fixture.tmux(in: toolsDirectory, permissions: 0o755) - #expect(TmuxManager.tmuxPath(path: emptyDirectory.path) == nil) + #expect(TmuxManager.tmuxPath( + path: emptyDirectory.path, + configurationURL: fixture.configurationURL + ) == nil) } @Test @@ -32,23 +39,78 @@ struct TmuxPathResolutionTests { let toolsDirectory = try fixture.directory(named: "custom-tools") _ = try fixture.tmux(in: toolsDirectory, permissions: 0o644) - #expect(TmuxManager.tmuxPath(path: toolsDirectory.path) == nil) + #expect(TmuxManager.tmuxPath( + path: toolsDirectory.path, + configurationURL: fixture.configurationURL + ) == nil) } @Test - func rejectsMissingEmptyAndRelativePathEntries() { - #expect(TmuxManager.tmuxPath(path: nil) == nil) - #expect(TmuxManager.tmuxPath(path: "") == nil) - #expect(TmuxManager.tmuxPath(path: "::relative-tools:") == nil) + func rejectsMissingEmptyAndRelativePathEntries() throws { + let fixture = try TmuxPathFixture() + defer { fixture.remove() } + + #expect(TmuxManager.tmuxPath(path: nil, configurationURL: fixture.configurationURL) == nil) + #expect(TmuxManager.tmuxPath(path: "", configurationURL: fixture.configurationURL) == nil) + #expect(TmuxManager.tmuxPath( + path: "::relative-tools:", + configurationURL: fixture.configurationURL + ) == nil) + } + + @Test + func resolvesSavedFallbackAfterPathMiss() throws { + let fixture = try TmuxPathFixture() + defer { fixture.remove() } + let emptyDirectory = try fixture.directory(named: "empty") + let savedExecutable = try fixture.tmux( + named: "saved-tmux", + in: fixture.root, + permissions: 0o755 + ) + try savedExecutable.path.write( + to: fixture.configurationURL, + atomically: true, + encoding: .utf8 + ) + + #expect(TmuxManager.tmuxPath( + path: emptyDirectory.path, + configurationURL: fixture.configurationURL + ) == savedExecutable.path) + } + + @Test + func rejectsInvalidSavedFallbackAfterPathMiss() throws { + let fixture = try TmuxPathFixture() + defer { fixture.remove() } + let emptyDirectory = try fixture.directory(named: "empty") + let savedFile = try fixture.tmux( + named: "saved-tmux", + in: fixture.root, + permissions: 0o644 + ) + try savedFile.path.write( + to: fixture.configurationURL, + atomically: true, + encoding: .utf8 + ) + + #expect(TmuxManager.tmuxPath( + path: emptyDirectory.path, + configurationURL: fixture.configurationURL + ) == nil) } } private struct TmuxPathFixture { let root: URL + let configurationURL: URL init() throws { root = FileManager.default.temporaryDirectory .appendingPathComponent("TmuxPathResolutionTests-\(UUID().uuidString)", isDirectory: true) + configurationURL = root.appendingPathComponent("tmux-executable-path") try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) } @@ -58,8 +120,8 @@ private struct TmuxPathFixture { return directory } - func tmux(in directory: URL, permissions: Int) throws -> URL { - let file = directory.appendingPathComponent("tmux") + func tmux(named name: String = "tmux", in directory: URL, permissions: Int) throws -> URL { + let file = directory.appendingPathComponent(name) try Data("fixture".utf8).write(to: file) try FileManager.default.setAttributes( [.posixPermissions: permissions], From c06ce42f8d04da3a7888f91aff8823dd6189e914 Mon Sep 17 00:00:00 2001 From: Jeffrey Burt Date: Mon, 10 Aug 2026 19:05:57 -0700 Subject: [PATCH 06/14] feat: locate tmux at startup --- Sources/TBDApp/AppState.swift | 44 ++++- Sources/TBDApp/ContentView.swift | 40 +++++ .../Settings/TerminalSettingsView.swift | 87 +++++++++ .../TmuxExecutableSettingsTests.swift | 166 ++++++++++++++++++ 4 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 Tests/TBDAppTests/TmuxExecutableSettingsTests.swift diff --git a/Sources/TBDApp/AppState.swift b/Sources/TBDApp/AppState.swift index 87cc928d..0d21eb83 100644 --- a/Sources/TBDApp/AppState.swift +++ b/Sources/TBDApp/AppState.swift @@ -1032,10 +1032,16 @@ final class AppState: ObservableObject { @Published var alertMessage: String? = nil @Published var alertIsError: Bool = false + @Published private(set) var tmuxExecutableResolution: TmuxExecutableResolution? + @Published private(set) var savedTmuxExecutablePath: String? + @Published private(set) var isTmuxLocationPromptPresented = false + private var hasCheckedTmuxAvailabilityAtStartup = false + let themeStore = ThemeStore() let daemonClient = DaemonClient() - let tmuxBridge = TmuxBridge(tmuxExecutableResolver: TmuxExecutableResolver()) + let tmuxExecutableResolver: TmuxExecutableResolver + let tmuxBridge: TmuxBridge /// App-scoped owner of control-mode stream readers (Phase 2 FD vending). /// Lives here — not on any view — so SwiftUI view destruction cannot tear /// down an active reader. Keyed by `FDVendHeader.routingKey`. @@ -1206,8 +1212,15 @@ final class AppState: ObservableObject { /// so they never clobber the developer's running app preferences. let userDefaults: UserDefaults - init(userDefaults: UserDefaults = .standard) { + init( + userDefaults: UserDefaults = .standard, + tmuxExecutableResolver: TmuxExecutableResolver = TmuxExecutableResolver() + ) { self.userDefaults = userDefaults + self.tmuxExecutableResolver = tmuxExecutableResolver + self.tmuxBridge = TmuxBridge(tmuxExecutableResolver: tmuxExecutableResolver) + self.tmuxExecutableResolution = tmuxExecutableResolver.resolve() + self.savedTmuxExecutablePath = tmuxExecutableResolver.savedPath restoreLayouts() restorePaneHistories() restoreRemoteSessionDisplayNames() @@ -1244,6 +1257,33 @@ final class AppState: ObservableObject { } } + func refreshTmuxExecutableState() { + savedTmuxExecutablePath = tmuxExecutableResolver.savedPath + tmuxExecutableResolution = tmuxExecutableResolver.resolve() + } + + func checkTmuxAvailabilityAtStartup() { + guard !hasCheckedTmuxAvailabilityAtStartup else { return } + hasCheckedTmuxAvailabilityAtStartup = true + refreshTmuxExecutableState() + isTmuxLocationPromptPresented = tmuxExecutableResolution == nil + } + + func dismissTmuxLocationPrompt() { + isTmuxLocationPromptPresented = false + } + + func saveTmuxExecutableFallback(_ path: String) throws { + try tmuxExecutableResolver.save(path) + refreshTmuxExecutableState() + isTmuxLocationPromptPresented = false + } + + func clearTmuxExecutableFallback() throws { + try tmuxExecutableResolver.clear() + refreshTmuxExecutableState() + } + /// True when this process is a SwiftPM / XCTest test harness. Detected by /// looking for a `.xctest` bundle path in the process arguments, which /// both XCTest and Swift Testing (via `swiftpm-testing-helper`) pass. diff --git a/Sources/TBDApp/ContentView.swift b/Sources/TBDApp/ContentView.swift index 23d02ea9..c99204ce 100644 --- a/Sources/TBDApp/ContentView.swift +++ b/Sources/TBDApp/ContentView.swift @@ -288,7 +288,29 @@ struct ContentView: View { } message: { Text(appState.alertMessage ?? "") } + .alert( + "tmux Not Found", + isPresented: Binding( + get: { appState.isTmuxLocationPromptPresented }, + set: { presented in + if !presented { + appState.dismissTmuxLocationPrompt() + } + } + ) + ) { + Button("Locate tmux…") { + appState.dismissTmuxLocationPrompt() + locateTmuxExecutable() + } + Button("Not Now", role: .cancel) { + appState.dismissTmuxLocationPrompt() + } + } message: { + Text("TBD could not find tmux in PATH and no saved fallback is available. Locate the tmux executable to use TBD terminals.") + } .onAppear { + appState.checkTmuxAvailabilityAtStartup() // Keep-alive: seed recentlyVisitedWorktreeIDs with the initially-restored // selection so the ZStack renders the right SingleWorktreeView on first frame. if appState.selectedWorktreeIDs.count == 1, let id = appState.selectedWorktreeIDs.first { @@ -298,6 +320,24 @@ struct ContentView: View { } } + private func locateTmuxExecutable() { + let panel = NSOpenPanel() + panel.title = "Locate tmux" + panel.message = "Choose the tmux executable." + panel.prompt = "Choose" + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url else { return } + + do { + try appState.saveTmuxExecutableFallback(url.path) + } catch { + appState.alertIsError = true + appState.alertMessage = error.localizedDescription + } + } + private func markSelectedWorktreesAsRead(_ selection: Set) { for worktreeID in selection { appState.unreadByWorktree[worktreeID] = nil diff --git a/Sources/TBDApp/Settings/TerminalSettingsView.swift b/Sources/TBDApp/Settings/TerminalSettingsView.swift index de28b3df..62e7a0ff 100644 --- a/Sources/TBDApp/Settings/TerminalSettingsView.swift +++ b/Sources/TBDApp/Settings/TerminalSettingsView.swift @@ -20,6 +20,7 @@ struct TerminalSettingsView: View { @State private var pendingSchemeSwitch: String? @State private var saveAsError: String? @State private var showingPendingSwitchConfirm = false + @State private var tmuxFallbackDraft = "" var body: some View { Form { @@ -133,6 +134,44 @@ struct TerminalSettingsView: View { .pickerStyle(.menu) } + Section { + LabeledContent("Active executable") { + if let resolution = appState.tmuxExecutableResolution { + VStack(alignment: .trailing, spacing: 2) { + Text(resolution.path) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + Text(resolution.source == .path ? "From PATH" : "Saved fallback") + .font(.caption) + .foregroundStyle(.secondary) + } + } else { + Text("Not found") + .foregroundStyle(.secondary) + } + } + + TextField( + "Fallback executable", + text: $tmuxFallbackDraft, + prompt: Text("/absolute/path/to/tmux") + ) + .onSubmit { saveTmuxFallback() } + + HStack { + Button("Save") { saveTmuxFallback() } + Button("Choose…") { chooseTmuxFallback() } + Button("Clear") { clearTmuxFallback() } + .disabled(appState.savedTmuxExecutablePath == nil) + } + } header: { + Text("tmux") + } footer: { + Text("TBD uses tmux from PATH when available. The saved executable is a fallback for app launches whose PATH does not contain tmux.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section { Toggle("Auto-resize tmux windows to match the app pane (WIP)", isOn: $enableTerminalAutoResize) .help("When on, TBD broadcasts the live pane size to the daemon and resizes every tmux window on app resize. Currently unstable — can leave panes smaller than the visible area and clip the bottom rows.") @@ -160,6 +199,13 @@ struct TerminalSettingsView: View { } .formStyle(.grouped) .padding() + .onAppear { + appState.refreshTmuxExecutableState() + tmuxFallbackDraft = appState.savedTmuxExecutablePath ?? "" + } + .onChange(of: appState.savedTmuxExecutablePath) { _, savedPath in + tmuxFallbackDraft = savedPath ?? "" + } .sheet(isPresented: $showingSaveAsDialog, onDismiss: { pendingSchemeSwitch = nil saveAsError = nil @@ -260,6 +306,47 @@ struct TerminalSettingsView: View { // MARK: - Actions + private func saveTmuxFallback() { + do { + try appState.saveTmuxExecutableFallback(tmuxFallbackDraft) + tmuxFallbackDraft = appState.savedTmuxExecutablePath ?? "" + } catch { + showTmuxError(error) + } + } + + private func chooseTmuxFallback() { + let panel = NSOpenPanel() + panel.title = "Locate tmux" + panel.message = "Choose the tmux executable." + panel.prompt = "Choose" + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url else { return } + + do { + try appState.saveTmuxExecutableFallback(url.path) + tmuxFallbackDraft = appState.savedTmuxExecutablePath ?? "" + } catch { + showTmuxError(error) + } + } + + private func clearTmuxFallback() { + do { + try appState.clearTmuxExecutableFallback() + tmuxFallbackDraft = "" + } catch { + showTmuxError(error) + } + } + + private func showTmuxError(_ error: any Error) { + errorTitle = "Couldn’t save tmux" + importError = error.localizedDescription + } + private func performSave() { do { let theme = editorVM.snapshot(id: appearance.schemeID) diff --git a/Tests/TBDAppTests/TmuxExecutableSettingsTests.swift b/Tests/TBDAppTests/TmuxExecutableSettingsTests.swift new file mode 100644 index 00000000..b0b43f4d --- /dev/null +++ b/Tests/TBDAppTests/TmuxExecutableSettingsTests.swift @@ -0,0 +1,166 @@ +import Foundation +import TBDShared +import Testing +@testable import TBDApp + +/// Tier 2: exercises only isolated filesystem configuration and executable fixtures. +@MainActor +@Suite("Tmux executable settings") +struct TmuxExecutableSettingsTests { + @Test func missingExecutablePromptsOnlyOncePerAppStateLifetime() throws { + try withFixture { fixture, state in + #expect(state.tmuxExecutableResolution == nil) + #expect(state.savedTmuxExecutablePath == nil) + #expect(state.isTmuxLocationPromptPresented == false) + + state.checkTmuxAvailabilityAtStartup() + #expect(state.isTmuxLocationPromptPresented) + + state.dismissTmuxLocationPrompt() + state.checkTmuxAvailabilityAtStartup() + #expect(state.isTmuxLocationPromptPresented == false) + } + } + + @Test func pathExecutableSuppressesStartupPrompt() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let pathDirectory = try fixture.directory(named: "path") + let executable = try fixture.executable(named: "tmux", in: pathDirectory) + try withAppState(fixture: fixture, path: pathDirectory.path) { state in + state.checkTmuxAvailabilityAtStartup() + + #expect(state.tmuxExecutableResolution == TmuxExecutableResolution( + path: executable.path, + source: .path + )) + #expect(state.isTmuxLocationPromptPresented == false) + } + } + + @Test func savedFallbackSuppressesStartupPrompt() throws { + let fixture = try Fixture() + defer { fixture.remove() } + let executable = try fixture.executable(named: "saved-tmux", in: fixture.root) + try fixture.resolver(path: "").save(executable.path) + try withAppState(fixture: fixture, path: "") { state in + state.checkTmuxAvailabilityAtStartup() + + #expect(state.savedTmuxExecutablePath == executable.path) + #expect(state.tmuxExecutableResolution == TmuxExecutableResolution( + path: executable.path, + source: .savedFallback + )) + #expect(state.isTmuxLocationPromptPresented == false) + } + } + + @Test func savingValidFallbackRefreshesStateAndDismissesPrompt() throws { + try withFixture { fixture, state in + let executable = try fixture.executable(named: "saved-tmux", in: fixture.root) + state.checkTmuxAvailabilityAtStartup() + #expect(state.isTmuxLocationPromptPresented) + + try state.saveTmuxExecutableFallback(executable.path) + + #expect(state.savedTmuxExecutablePath == executable.path) + #expect(state.tmuxExecutableResolution == TmuxExecutableResolution( + path: executable.path, + source: .savedFallback + )) + #expect(state.isTmuxLocationPromptPresented == false) + } + } + + @Test func invalidFallbackPreservesPriorValidValue() throws { + try withFixture { fixture, state in + let executable = try fixture.executable(named: "saved-tmux", in: fixture.root) + try state.saveTmuxExecutableFallback(executable.path) + + #expect(throws: TmuxExecutableResolverError.self) { + try state.saveTmuxExecutableFallback("relative/tmux") + } + + #expect(state.savedTmuxExecutablePath == executable.path) + #expect(state.tmuxExecutableResolution?.path == executable.path) + } + } + + @Test func clearingFallbackRefreshesWithoutReopeningStartupPrompt() throws { + try withFixture { fixture, state in + let executable = try fixture.executable(named: "saved-tmux", in: fixture.root) + try state.saveTmuxExecutableFallback(executable.path) + state.checkTmuxAvailabilityAtStartup() + #expect(state.isTmuxLocationPromptPresented == false) + + try state.clearTmuxExecutableFallback() + state.checkTmuxAvailabilityAtStartup() + + #expect(state.savedTmuxExecutablePath == nil) + #expect(state.tmuxExecutableResolution == nil) + #expect(state.isTmuxLocationPromptPresented == false) + } + } + + private func withFixture(_ body: (Fixture, AppState) throws -> Void) throws { + let fixture = try Fixture() + defer { fixture.remove() } + try withAppState(fixture: fixture, path: "") { state in + try body(fixture, state) + } + } + + private func withAppState( + fixture: Fixture, + path: String, + _ body: (AppState) throws -> Void + ) throws { + let suiteName = "TmuxExecutableSettingsTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let state = AppState( + userDefaults: defaults, + tmuxExecutableResolver: fixture.resolver(path: path) + ) + try body(state) + } +} + +private struct Fixture { + let root: URL + let configurationURL: URL + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("TmuxExecutableSettingsTests-\(UUID().uuidString)", isDirectory: true) + configurationURL = root.appendingPathComponent("tmux-executable-path") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + } + + func resolver(path: String) -> TmuxExecutableResolver { + TmuxExecutableResolver( + environment: ["PATH": path], + configurationURL: configurationURL + ) + } + + func directory(named name: String) throws -> URL { + let directory = root.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false) + return directory + } + + func executable(named name: String, in directory: URL) throws -> URL { + let executable = directory.appendingPathComponent(name) + try Data("fixture".utf8).write(to: executable) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: executable.path + ) + return executable + } + + func remove() { + try? FileManager.default.removeItem(at: root) + } +} From 166a8fd5beec176faeae879750e2b4289d892778 Mon Sep 17 00:00:00 2001 From: Jeffrey Burt Date: Tue, 11 Aug 2026 18:14:01 -0700 Subject: [PATCH 07/14] test: isolate tmux gate fixtures --- Tests/TBDDaemonTests/AttachRPCTests.swift | 124 +++++++++++++++--- .../ControlModeSettingsRPCTests.swift | 54 ++++++-- .../TmuxExecutableTestFixture.swift | 40 ++++++ 3 files changed, 192 insertions(+), 26 deletions(-) create mode 100644 Tests/TestSupport/TmuxExecutableTestFixture.swift diff --git a/Tests/TBDDaemonTests/AttachRPCTests.swift b/Tests/TBDDaemonTests/AttachRPCTests.swift index 674eaff4..8178bcfd 100644 --- a/Tests/TBDDaemonTests/AttachRPCTests.swift +++ b/Tests/TBDDaemonTests/AttachRPCTests.swift @@ -56,14 +56,17 @@ struct AttachRPCStubTests { @Test("attach.ready without a live attach fails (M4.3: app must fall back)") func readyWithoutAttachFails() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (router, db) = try makeRouterAndDB() let worktreeID = try await makeWorktree(in: db) router.controlMode = TmuxControlModeBridge( supervisor: TmuxControlSupervisor(), startupTmux: TmuxVersionSnapshot( - executablePath: TmuxExecutableResolver().resolve()?.path, + executablePath: tmux.resolver.resolve()?.path, version: TmuxVersion(major: 3, minor: 6) ), + tmuxExecutableResolver: tmux.resolver, environment: ["TBD_TMUX_CONTROL_MODE": "1"], fdVending: FDVendingServer()) let request = try RPCRequest( @@ -99,13 +102,16 @@ struct AttachRPCStubTests { @Test("daemon.capabilities reports control mode on when the bridge gate passes") func capabilitiesOnWhenGated() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (router, _) = try makeRouterAndDB() router.controlMode = TmuxControlModeBridge( supervisor: TmuxControlSupervisor(), startupTmux: TmuxVersionSnapshot( - executablePath: TmuxExecutableResolver().resolve()?.path, + executablePath: tmux.resolver.resolve()?.path, version: TmuxVersion(major: 3, minor: 6) ), + tmuxExecutableResolver: tmux.resolver, environment: ["TBD_TMUX_CONTROL_MODE": "1"], fdVending: FDVendingServer()) let request = RPCRequest(method: RPCMethod.daemonCapabilities) @@ -131,6 +137,7 @@ struct AttachRPCOrchestrationTests { private func bridge( supervisor: TmuxControlSupervisor, vending: FDVendingServer, + tmuxExecutableResolver: TmuxExecutableResolver, gateOn: Bool = true, // Effectively-infinite default: the ready-timeout is incidental // machinery in these orchestration tests, and it spawns a REAL @@ -151,9 +158,10 @@ struct AttachRPCOrchestrationTests { TmuxControlModeBridge( supervisor: supervisor, startupTmux: TmuxVersionSnapshot( - executablePath: TmuxExecutableResolver().resolve()?.path, + executablePath: tmuxExecutableResolver.resolve()?.path, version: TmuxVersion(major: 3, minor: 6) ), + tmuxExecutableResolver: tmuxExecutableResolver, environment: gateOn ? ["TBD_TMUX_CONTROL_MODE": "1"] : [:], fdVending: vending, readyTimeout: readyTimeout, @@ -213,6 +221,8 @@ struct AttachRPCOrchestrationTests { @Test("attach.request with the gate on vends an fd whose header carries the pane identity") func vendsFDWhenGateOn() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (serverSide, clientSide) = try makeSocketPair() defer { Darwin.close(clientSide) } @@ -221,7 +231,11 @@ struct AttachRPCOrchestrationTests { await vending.adoptConnection(fd: serverSide) let (router, db) = try makeRouterAndDB() let worktreeID = try await makeWorktree(in: db) - router.controlMode = bridge(supervisor: supervisor, vending: vending) + router.controlMode = bridge( + supervisor: supervisor, + vending: vending, + tmuxExecutableResolver: tmux.resolver + ) let request = try RPCRequest( method: RPCMethod.attachRequest, @@ -239,6 +253,8 @@ struct AttachRPCOrchestrationTests { @Test("attach.request with the gate off returns unavailable and does not send an fd") func gateOffReturnsUnavailable() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (serverSide, clientSide) = try makeSocketPair() defer { Darwin.close(serverSide) @@ -249,7 +265,12 @@ struct AttachRPCOrchestrationTests { let vending = FDVendingServer() let (router, db) = try makeRouterAndDB() let worktreeID = try await makeWorktree(in: db) - router.controlMode = bridge(supervisor: supervisor, vending: vending, gateOn: false) + router.controlMode = bridge( + supervisor: supervisor, + vending: vending, + tmuxExecutableResolver: tmux.resolver, + gateOn: false + ) let request = try RPCRequest( method: RPCMethod.attachRequest, @@ -261,6 +282,8 @@ struct AttachRPCOrchestrationTests { @Test("attach.request for an unknown worktree fails") func unknownWorktreeFails() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (serverSide, clientSide) = try makeSocketPair() defer { Darwin.close(clientSide) } @@ -268,7 +291,11 @@ struct AttachRPCOrchestrationTests { let vending = FDVendingServer() await vending.adoptConnection(fd: serverSide) let (router, _) = try makeRouterAndDB() - router.controlMode = bridge(supervisor: supervisor, vending: vending) + router.controlMode = bridge( + supervisor: supervisor, + vending: vending, + tmuxExecutableResolver: tmux.resolver + ) let request = try RPCRequest( method: RPCMethod.attachRequest, @@ -279,6 +306,8 @@ struct AttachRPCOrchestrationTests { @Test("an attach the app never acks is torn down after readyTimeout") func unackedAttachTornDownAfterTimeout() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (serverSide, clientSide) = try makeSocketPair() defer { Darwin.close(clientSide) } @@ -290,7 +319,12 @@ struct AttachRPCOrchestrationTests { let clock = TestClock() let readyTimeout: Duration = .seconds(5) router.controlMode = bridge( - supervisor: supervisor, vending: vending, readyTimeout: readyTimeout, clock: clock) + supervisor: supervisor, + vending: vending, + tmuxExecutableResolver: tmux.resolver, + readyTimeout: readyTimeout, + clock: clock + ) let request = try RPCRequest( method: RPCMethod.attachRequest, @@ -314,6 +348,8 @@ struct AttachRPCOrchestrationTests { @Test("attach.ready triggers the replay sequence; the gate opens only after the replay lands") func readyTriggersSequenceGateOpensAfterReplay() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (serverSide, clientSide) = try makeSocketPair() defer { Darwin.close(clientSide) } @@ -324,7 +360,11 @@ struct AttachRPCOrchestrationTests { let worktreeID = try await makeWorktree(in: db, tmuxServer: "tbd-gate-test") let (client, recorder) = makeFakeClient() router.controlMode = bridge( - supervisor: supervisor, vending: vending, commandProvider: { _ in client }) + supervisor: supervisor, + vending: vending, + tmuxExecutableResolver: tmux.resolver, + commandProvider: { _ in client } + ) let attach = try RPCRequest( method: RPCMethod.attachRequest, @@ -366,6 +406,8 @@ struct AttachRPCOrchestrationTests { @Test("an acked attach whose replay is still in flight survives the ready-timeout") func ackedReplayInFlightSurvivesTimeout() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (serverSide, clientSide) = try makeSocketPair() defer { Darwin.close(clientSide) } @@ -378,8 +420,13 @@ struct AttachRPCOrchestrationTests { let clock = TestClock() let readyTimeout: Duration = .seconds(5) router.controlMode = bridge( - supervisor: supervisor, vending: vending, - readyTimeout: readyTimeout, commandProvider: { _ in client }, clock: clock) + supervisor: supervisor, + vending: vending, + tmuxExecutableResolver: tmux.resolver, + readyTimeout: readyTimeout, + commandProvider: { _ in client }, + clock: clock + ) let attach = try RPCRequest( method: RPCMethod.attachRequest, @@ -415,6 +462,8 @@ struct AttachRPCOrchestrationTests { @Test("a re-attach mid-sequence supersedes: attach.ready still returns success") func supersededMidSequenceReturnsSuccess() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (serverSide, clientSide) = try makeSocketPair() defer { Darwin.close(clientSide) } @@ -425,7 +474,11 @@ struct AttachRPCOrchestrationTests { let worktreeID = try await makeWorktree(in: db, tmuxServer: "tbd-supersede-test") let (client, recorder) = makeFakeClient() router.controlMode = bridge( - supervisor: supervisor, vending: vending, commandProvider: { _ in client }) + supervisor: supervisor, + vending: vending, + tmuxExecutableResolver: tmux.resolver, + commandProvider: { _ in client } + ) func attachRequest() throws -> RPCRequest { try RPCRequest( @@ -466,6 +519,8 @@ struct AttachRPCOrchestrationTests { @Test("a stale attach.ready (echoed older generation) sends ZERO commands on the shared correlator") func staleReadyEchoedGenerationSendsNothing() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (serverSide, clientSide) = try makeSocketPair() defer { Darwin.close(clientSide) } @@ -479,7 +534,11 @@ struct AttachRPCOrchestrationTests { // the reviewer flagged as untested with per-generation clients. let (client, recorder) = makeFakeClient() router.controlMode = bridge( - supervisor: supervisor, vending: vending, commandProvider: { _ in client }) + supervisor: supervisor, + vending: vending, + tmuxExecutableResolver: tmux.resolver, + commandProvider: { _ in client } + ) func attach() async throws -> UInt64 { let request = try RPCRequest( @@ -523,6 +582,8 @@ struct AttachRPCOrchestrationTests { @Test("mid-sequence supersession on ONE shared correlator: stale generation sends no continue; the successor's sequence ends with its own") func midSequenceSupersedeOnSharedClientSkipsUnpause() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (serverSide, clientSide) = try makeSocketPair() defer { Darwin.close(clientSide) } @@ -533,7 +594,11 @@ struct AttachRPCOrchestrationTests { let worktreeID = try await makeWorktree(in: db, tmuxServer: "tbd-sharedsup-test") let (client, recorder) = makeFakeClient() router.controlMode = bridge( - supervisor: supervisor, vending: vending, commandProvider: { _ in client }) + supervisor: supervisor, + vending: vending, + tmuxExecutableResolver: tmux.resolver, + commandProvider: { _ in client } + ) func attach() async throws -> UInt64 { let request = try RPCRequest( @@ -587,6 +652,8 @@ struct AttachRPCOrchestrationTests { @Test("a stale attach's late failure must not kill a healthy successor's sink") func staleFailureCleanupSparesSuccessor() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (serverSide, clientSide) = try makeSocketPair() defer { Darwin.close(clientSide) } @@ -604,8 +671,11 @@ struct AttachRPCOrchestrationTests { let (clientB, recorderB) = makeFakeClient() let calls = CallCounter() router.controlMode = bridge( - supervisor: supervisor, vending: vending, - commandProvider: { _ in calls.next() == 0 ? clientA : clientB }) + supervisor: supervisor, + vending: vending, + tmuxExecutableResolver: tmux.resolver, + commandProvider: { _ in calls.next() == 0 ? clientA : clientB } + ) func attachRequest() throws -> RPCRequest { try RPCRequest( @@ -663,6 +733,8 @@ struct AttachRPCOrchestrationTests { @Test("a stale pane.detach (older generation) no-ops against a newer attach's sink") func stalePaneDetachSparesSuccessor() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (serverSide, clientSide) = try makeSocketPair() defer { Darwin.close(clientSide) } @@ -671,7 +743,11 @@ struct AttachRPCOrchestrationTests { await vending.adoptConnection(fd: serverSide) let (router, db) = try makeRouterAndDB() let worktreeID = try await makeWorktree(in: db, tmuxServer: "tbd-staledet-test") - router.controlMode = bridge(supervisor: supervisor, vending: vending) + router.controlMode = bridge( + supervisor: supervisor, + vending: vending, + tmuxExecutableResolver: tmux.resolver + ) func attach() async throws -> AttachRequestResult { let request = try RPCRequest( @@ -729,6 +805,8 @@ struct AttachRPCOrchestrationTests { @Test("pane.detach without a generation detaches unconditionally (back-compat)") func paneDetachWithoutGenerationDetaches() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (serverSide, clientSide) = try makeSocketPair() defer { Darwin.close(clientSide) } @@ -737,7 +815,11 @@ struct AttachRPCOrchestrationTests { await vending.adoptConnection(fd: serverSide) let (router, db) = try makeRouterAndDB() let worktreeID = try await makeWorktree(in: db, tmuxServer: "tbd-nogendet-test") - router.controlMode = bridge(supervisor: supervisor, vending: vending) + router.controlMode = bridge( + supervisor: supervisor, + vending: vending, + tmuxExecutableResolver: tmux.resolver + ) let request = try RPCRequest( method: RPCMethod.attachRequest, @@ -759,6 +841,8 @@ struct AttachRPCOrchestrationTests { @Test("a capture failure detaches the pane and fails the RPC (app falls back)") func captureFailureDetachesAndErrors() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (serverSide, clientSide) = try makeSocketPair() defer { Darwin.close(clientSide) } @@ -769,7 +853,11 @@ struct AttachRPCOrchestrationTests { let worktreeID = try await makeWorktree(in: db, tmuxServer: "tbd-capfail-test") let (client, recorder) = makeFakeClient() router.controlMode = bridge( - supervisor: supervisor, vending: vending, commandProvider: { _ in client }) + supervisor: supervisor, + vending: vending, + tmuxExecutableResolver: tmux.resolver, + commandProvider: { _ in client } + ) let attach = try RPCRequest( method: RPCMethod.attachRequest, diff --git a/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift b/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift index fd319089..91102ec8 100644 --- a/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift +++ b/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift @@ -61,7 +61,7 @@ struct ControlModeSettingsRPCTests { vending: FDVendingServer = FDVendingServer(), environment: [String: String] = [:], tmuxVersion: TmuxVersion? = TmuxVersion(major: 3, minor: 6), - tmuxExecutableResolver: TmuxExecutableResolver = TmuxExecutableResolver(), + tmuxExecutableResolver: TmuxExecutableResolver, startupTmux: TmuxVersionSnapshot? = nil ) -> TmuxControlModeBridge { TmuxControlModeBridge( @@ -110,8 +110,14 @@ struct ControlModeSettingsRPCTests { @Test("capabilities carries the tmux version and support flag") func capabilitiesCarriesVersion() async throws { + let tmux = try TmuxExecutableTestFixture(version: "3.6a") + defer { tmux.remove() } let (router, db) = try makeRouterAndDB() - router.controlMode = bridge(db: db, tmuxVersion: TmuxVersion(major: 3, minor: 6, suffix: "a")) + router.controlMode = bridge( + db: db, + tmuxVersion: TmuxVersion(major: 3, minor: 6, suffix: "a"), + tmuxExecutableResolver: tmux.resolver + ) let response = await router.handle(RPCRequest(method: RPCMethod.daemonCapabilities)) let result = try response.decodeResult(DaemonCapabilitiesResult.self) #expect(result.tmuxVersion == "3.6a") @@ -120,9 +126,15 @@ struct ControlModeSettingsRPCTests { @Test("capabilities reports unsupported (and gate closed) for tmux < 3.2 even with the flag on") func capabilitiesUnsupportedOldTmux() async throws { + let tmux = try TmuxExecutableTestFixture(version: "3.1") + defer { tmux.remove() } let (router, db) = try makeRouterAndDB() try await db.config.setControlModeEnabled(true) - router.controlMode = bridge(db: db, tmuxVersion: TmuxVersion(major: 3, minor: 1)) + router.controlMode = bridge( + db: db, + tmuxVersion: TmuxVersion(major: 3, minor: 1), + tmuxExecutableResolver: tmux.resolver + ) let response = await router.handle(RPCRequest(method: RPCMethod.daemonCapabilities)) let result = try response.decodeResult(DaemonCapabilitiesResult.self) #expect(result.tmuxVersion == "3.1") @@ -142,8 +154,10 @@ struct ControlModeSettingsRPCTests { @Test("capabilities reflects a flag flip without a daemon restart") func capabilitiesReEvaluatesFlag() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (router, db) = try makeRouterAndDB() - router.controlMode = bridge(db: db) + router.controlMode = bridge(db: db, tmuxExecutableResolver: tmux.resolver) var response = await router.handle(RPCRequest(method: RPCMethod.daemonCapabilities)) var result = try response.decodeResult(DaemonCapabilitiesResult.self) @@ -286,6 +300,8 @@ struct ControlModeSettingsRPCTests { @Test("flag on, env off: attach proceeds (fd vended)") func attachProceedsOnFlag() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (serverSide, clientSide) = try makeSocketPair() defer { Darwin.close(clientSide) } @@ -294,7 +310,11 @@ struct ControlModeSettingsRPCTests { let (router, db) = try makeRouterAndDB() let worktreeID = try await makeWorktree(in: db) try await db.config.setControlModeEnabled(true) - router.controlMode = bridge(db: db, vending: vending) + router.controlMode = bridge( + db: db, + vending: vending, + tmuxExecutableResolver: tmux.resolver + ) let result = try await attach(router, worktreeID: worktreeID, paneID: "%11", windowID: "@11") #expect(result.status == "pending") @@ -306,6 +326,8 @@ struct ControlModeSettingsRPCTests { @Test("flag off, env off: attach is unavailable; flipping the flag affects the NEXT attach") func toggleMidSessionAffectsNextAttach() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (serverSide, clientSide) = try makeSocketPair() defer { Darwin.close(clientSide) } @@ -313,7 +335,11 @@ struct ControlModeSettingsRPCTests { await vending.adoptConnection(fd: serverSide) let (router, db) = try makeRouterAndDB() let worktreeID = try await makeWorktree(in: db) - router.controlMode = bridge(db: db, vending: vending) + router.controlMode = bridge( + db: db, + vending: vending, + tmuxExecutableResolver: tmux.resolver + ) let before = try await attach(router, worktreeID: worktreeID, paneID: "%12", windowID: "@12") #expect(before.status == "unavailable") @@ -328,6 +354,8 @@ struct ControlModeSettingsRPCTests { @Test("env on, flag off: attach proceeds (env is the developer override)") func envOverridePrecedence() async throws { + let tmux = try TmuxExecutableTestFixture() + defer { tmux.remove() } let (serverSide, clientSide) = try makeSocketPair() defer { Darwin.close(clientSide) } @@ -337,7 +365,11 @@ struct ControlModeSettingsRPCTests { let worktreeID = try await makeWorktree(in: db) try await db.config.setControlModeEnabled(false) router.controlMode = bridge( - db: db, vending: vending, environment: ["TBD_TMUX_CONTROL_MODE": "1"]) + db: db, + vending: vending, + environment: ["TBD_TMUX_CONTROL_MODE": "1"], + tmuxExecutableResolver: tmux.resolver + ) let result = try await attach(router, worktreeID: worktreeID, paneID: "%13", windowID: "@13") #expect(result.status == "pending") @@ -347,10 +379,16 @@ struct ControlModeSettingsRPCTests { @Test("flag on but tmux < 3.2: attach stays unavailable") func flagOnOldTmuxUnavailable() async throws { + let tmux = try TmuxExecutableTestFixture(version: "3.1") + defer { tmux.remove() } let (router, db) = try makeRouterAndDB() let worktreeID = try await makeWorktree(in: db) try await db.config.setControlModeEnabled(true) - router.controlMode = bridge(db: db, tmuxVersion: TmuxVersion(major: 3, minor: 1)) + router.controlMode = bridge( + db: db, + tmuxVersion: TmuxVersion(major: 3, minor: 1), + tmuxExecutableResolver: tmux.resolver + ) let result = try await attach(router, worktreeID: worktreeID, paneID: "%14", windowID: "@14") #expect(result.status == "unavailable") diff --git a/Tests/TestSupport/TmuxExecutableTestFixture.swift b/Tests/TestSupport/TmuxExecutableTestFixture.swift new file mode 100644 index 00000000..ed2982d0 --- /dev/null +++ b/Tests/TestSupport/TmuxExecutableTestFixture.swift @@ -0,0 +1,40 @@ +import Foundation +import TBDShared + +/// Isolated executable and resolver for tests whose behavior assumes a known +/// tmux version. The fixture keeps gate tests independent of the host PATH and +/// the user's saved fallback configuration. +public struct TmuxExecutableTestFixture: Sendable { + public let root: URL + public let executableURL: URL + public let configurationURL: URL + + public init(version: String = "3.6") throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("TmuxExecutableTestFixture-\(UUID().uuidString)", isDirectory: true) + executableURL = root.appendingPathComponent("tmux") + configurationURL = root.appendingPathComponent("tmux-executable-path") + + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) + let script = """ + #!/bin/sh + printf '%s\\n' 'tmux \(version)' + """ + try script.write(to: executableURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: executableURL.path + ) + } + + public var resolver: TmuxExecutableResolver { + TmuxExecutableResolver( + environment: ["PATH": root.path], + configurationURL: configurationURL + ) + } + + public func remove() { + try? FileManager.default.removeItem(at: root) + } +} From 4d534e1d6053d45b3972f8809a6a4431ffec3570 Mon Sep 17 00:00:00 2001 From: Jeffrey Burt Date: Tue, 11 Aug 2026 18:15:01 -0700 Subject: [PATCH 08/14] docs: specify tmux executable fallback --- ...08-11-tmux-executable-resolution-design.md | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 docs/specs/2026-08-11-tmux-executable-resolution-design.md diff --git a/docs/specs/2026-08-11-tmux-executable-resolution-design.md b/docs/specs/2026-08-11-tmux-executable-resolution-design.md new file mode 100644 index 00000000..eb291d3e --- /dev/null +++ b/docs/specs/2026-08-11-tmux-executable-resolution-design.md @@ -0,0 +1,195 @@ +# tmux executable resolution and saved fallback — design + +Status: **implemented**. + +## Problem + +TBD is installed from an interactive shell but normally launched by macOS. Those +launch contexts can have different `PATH` values. A tmux executable that was +available during installation can therefore become unavailable after a crash or a +LaunchServices relaunch, even though the installation itself is otherwise healthy. + +TBD needs one predictable way to find tmux without inventing a second environment +policy. It also needs a user-controlled recovery path for installations whose launch +environment genuinely does not expose tmux. + +## Design goals + +- Preserve the installation shell's exact `PATH` for initial and subsequent app + launches. +- Resolve tmux from that inherited `PATH` before consulting any fallback. +- Let a user locate tmux when it is absent from `PATH`, and inspect, edit, or clear + that choice later. +- Make a changed saved fallback visible to the running app and daemon on subsequent + operations without requiring them to restart. +- Run every multi-command terminal preparation and viewer attachment with one stable + executable snapshot. +- Keep executable discovery explicit, narrow, and safe. + +## Launch environment authority + +The installer captures its current, non-empty `PATH` in the generated app bundle's +`LSEnvironment.PATH`. It also supplies that same value explicitly when opening the +freshly installed app. The bundle value lets macOS apply the installation environment +again when LaunchServices relaunches the app after the original process exits. + +The app passes its inherited environment to the daemon. The daemon does not append +directories, invoke a shell, or otherwise reinterpret `PATH`. This gives the app and +daemon one installation-defined search path instead of competing policies. + +Only `PATH` is captured. TBD does not persist the installation shell's whole +environment. + +## Resolution order + +Every fresh resolution applies this precedence: + +1. Split the inherited `PATH` in order. +2. Ignore empty and relative entries. +3. For each absolute entry, look for a regular executable file named `tmux`. +4. Return the first qualifying PATH candidate. +5. If no PATH candidate qualifies, read and validate the saved executable fallback. +6. If neither source produces a valid executable, report tmux as unavailable. + +`PATH` always wins, including when a saved fallback exists. A fallback is therefore a +recovery mechanism for a deficient launch environment, not a user override of a valid +installation path. + +Resolution performs filesystem inspection only. It does not start a subprocess to +discover an executable. + +## Saved fallback + +The fallback is a UTF-8 file named `tmux-executable-path` in TBD's configuration +directory. It follows `TBD_HOME`, so app and daemon processes agree on the location +and tests can isolate it from the user's real configuration. + +The file stores one trimmed absolute path. Saving validates the value before replacing +the prior file. An invalid edit leaves the previous valid value intact. Clearing the +setting removes the file and is idempotent when no file exists. + +A saved value is usable only when it resolves to a regular executable file. Symlinks +are allowed when their resolved target is a regular executable. A missing, +non-executable, relative, directory, or otherwise invalid target is treated as absent. + +The file contains only the selected executable path. It does not store `PATH`, other +environment variables, or shell initialization output. + +## Startup experience + +After app startup, TBD performs a fresh resolution. When tmux is available from +`PATH` or the saved fallback, startup proceeds without interruption. + +When resolution fails, TBD presents a Locate tmux prompt. The user can choose an +executable with the system file picker or dismiss the prompt. The prompt appears at +most once during one app-state lifetime; clearing or invalidating the value later does +not repeatedly interrupt the same running session. + +A successful selection is validated, saved, and immediately reflected in app state. +Cancelling the picker or dismissing the prompt does not write configuration. + +## Settings experience + +Terminal Settings shows three distinct facts and controls: + +- **Active executable** — the currently resolved executable and whether it came from + `PATH` or the saved fallback. +- **Fallback executable** — an editable absolute path with Save, Choose, and Clear + actions. +- **Backing file** — the tilde-abbreviated path to the fallback configuration file, + with an affordance that copies the full absolute path. + +The active executable remains read-only because it reports the outcome of precedence, +not an override. Editing the fallback while tmux is present on `PATH` does not change +the active source; the saved value becomes relevant only when PATH resolution fails. + +## Live behavior + +App and daemon owners keep a resolver configured with their inherited environment and +the shared fallback-file location. They resolve again at operation boundaries instead +of permanently caching the selected path. + +This means a Settings save or clear affects later terminal preparation, later daemon +tmux commands, and later control-mode gate or capability decisions. The daemon pairs a +detected tmux version with the executable path that produced it. It may reuse a startup +version only while the effective path remains unchanged; otherwise it detects the +version from the newly resolved executable. + +Within one terminal preparation, TBD resolves exactly once. The absolute executable +path is carried through session creation, window selection, confirmation, cleanup, +and viewer attachment. A Settings change during that sequence cannot split one +operation across two different tmux executables. The next operation resolves again. + +## Failure behavior + +An unresolved executable fails closed. TBD does not substitute `/usr/bin/env`, guess a +location, or create terminal state through a different tmux installation. The app +offers the Locate tmux recovery surface, while daemon operations that require tmux +report their existing unavailable or failed result. + +An invalid saved path is ignored during resolution and remains visible for correction +in Settings. It never outranks a valid PATH candidate. + +## Security and validation + +- Only absolute paths are accepted for saved fallbacks and PATH entries. +- Candidates must be executable regular files after resolving symlinks. +- The resolver never evaluates shell syntax or expands variables from saved content. +- The executable is launched directly with an argument array; its path is not + interpolated into a shell command. +- Diagnostics may report resolution source or success, but must not log the complete + `PATH` or other environment contents. +- Tests use temporary executable fixtures and configuration files rather than the + developer's PATH or saved fallback. + +## Rejected alternatives + +### Fixed installation directories + +Searching package-manager or system directories outside `PATH` creates a second, +hidden precedence policy and can silently select a different tmux than the installer +selected. There is no universal directory list across package managers, architectures, +or user-managed toolchains. TBD searches only the authoritative inherited `PATH` and +the explicit saved fallback. + +### Login shell or `path_helper` + +Starting a login shell or invoking `path_helper` would execute user-controlled startup +configuration, add latency, and produce an environment that may differ from the one +used to install TBD. It also makes binary selection depend on shell choice and startup +file health. Installation-time `PATH` capture is deterministic and does not run shell +initialization during app startup. + +### Persisting the whole environment + +An environment snapshot would retain unrelated and potentially sensitive values long +after installation. TBD needs only executable discovery, so persisting anything beyond +the bundle's launch `PATH` and the optional tmux fallback path is unnecessary. + +### Making the saved value override PATH + +An override would make Settings silently diverge from the installation environment and +could pin TBD to a removed or outdated executable while a healthy `PATH` candidate is +available. PATH-first precedence keeps installation intent authoritative and makes the +fallback's role unambiguous. + +### Requiring restart after edits + +The fallback file is shared configuration, and resolving it at operation boundaries is +cheap. Restart-only behavior would make Settings appear stale and would leave the app +and daemon disagreeing until both processes restarted. Live re-resolution provides +consistent subsequent behavior while stable per-operation snapshots prevent mid-flight +changes. + +## Verification contract + +- Installation-path tests verify exact round trips, replacement, invalid input, source + plist preservation, and generated plist validity. +- Resolver tests verify PATH order, executable validation, ignored entries, paths with + spaces, fallback precedence, and absence of implicit directory search. +- App tests verify startup prompting, saving, clearing, PATH authority, live + re-resolution, and stable preparation/viewer snapshots. +- Daemon tests verify PATH-only execution, saved-fallback updates, executable/version + pairing, and hermetic control-mode gate decisions. +- Settings presentation tests verify that the displayed backing path is + tilde-abbreviated while copying retains the full absolute path. From b4b88d4a81aeeb1e434eac23e4d70771e8516240 Mon Sep 17 00:00:00 2001 From: Jeffrey Burt Date: Tue, 11 Aug 2026 18:17:36 -0700 Subject: [PATCH 09/14] fix: show tmux fallback config path --- .../Settings/TerminalSettingsView.swift | 44 +++++++++++++++++++ .../TmuxExecutableSettingsTests.swift | 10 +++++ 2 files changed, 54 insertions(+) diff --git a/Sources/TBDApp/Settings/TerminalSettingsView.swift b/Sources/TBDApp/Settings/TerminalSettingsView.swift index 62e7a0ff..7765f04e 100644 --- a/Sources/TBDApp/Settings/TerminalSettingsView.swift +++ b/Sources/TBDApp/Settings/TerminalSettingsView.swift @@ -6,6 +6,28 @@ import UniformTypeIdentifiers private typealias SwiftUIColor = SwiftUI.Color +struct TmuxConfigurationPathPresentation: Equatable { + let fullPath: String + let displayPath: String + + init( + configurationURL: URL = TBDConstants.tmuxExecutablePathFile, + homeDirectory: String = NSHomeDirectory() + ) { + fullPath = configurationURL.path + let home = homeDirectory.hasSuffix("/") + ? String(homeDirectory.dropLast()) + : homeDirectory + if !home.isEmpty, fullPath == home { + displayPath = "~" + } else if !home.isEmpty, fullPath.hasPrefix(home + "/") { + displayPath = "~" + fullPath.dropFirst(home.count) + } else { + displayPath = fullPath + } + } +} + struct TerminalSettingsView: View { @EnvironmentObject var appearance: AppearanceSettings @EnvironmentObject var appState: AppState @@ -164,6 +186,28 @@ struct TerminalSettingsView: View { Button("Clear") { clearTmuxFallback() } .disabled(appState.savedTmuxExecutablePath == nil) } + + LabeledContent("Fallback file") { + HStack(spacing: 4) { + let path = TmuxConfigurationPathPresentation() + Text(path.displayPath) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(path.fullPath, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + .font(.caption) + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + .help("Copy full path") + } + } } header: { Text("tmux") } footer: { diff --git a/Tests/TBDAppTests/TmuxExecutableSettingsTests.swift b/Tests/TBDAppTests/TmuxExecutableSettingsTests.swift index b0b43f4d..61e451a5 100644 --- a/Tests/TBDAppTests/TmuxExecutableSettingsTests.swift +++ b/Tests/TBDAppTests/TmuxExecutableSettingsTests.swift @@ -7,6 +7,16 @@ import Testing @MainActor @Suite("Tmux executable settings") struct TmuxExecutableSettingsTests { + @Test func backingFilePresentationAbbreviatesHomeAndPreservesCopyPath() { + let presentation = TmuxConfigurationPathPresentation( + configurationURL: URL(fileURLWithPath: "/Users/acme/tbd/tmux-executable-path"), + homeDirectory: "/Users/acme" + ) + + #expect(presentation.displayPath == "~/tbd/tmux-executable-path") + #expect(presentation.fullPath == "/Users/acme/tbd/tmux-executable-path") + } + @Test func missingExecutablePromptsOnlyOncePerAppStateLifetime() throws { try withFixture { fixture, state in #expect(state.tmuxExecutableResolution == nil) From 8f6c0a459ea7b71d654ea69152fd1ee3d0db9535 Mon Sep 17 00:00:00 2001 From: Jeffrey Burt Date: Tue, 11 Aug 2026 18:51:52 -0700 Subject: [PATCH 10/14] docs: clarify daemon path authority --- ...26-08-11-tmux-executable-resolution-design.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/specs/2026-08-11-tmux-executable-resolution-design.md b/docs/specs/2026-08-11-tmux-executable-resolution-design.md index eb291d3e..396bd108 100644 --- a/docs/specs/2026-08-11-tmux-executable-resolution-design.md +++ b/docs/specs/2026-08-11-tmux-executable-resolution-design.md @@ -37,6 +37,12 @@ The app passes its inherited environment to the daemon. The daemon does not appe directories, invoke a shell, or otherwise reinterpret `PATH`. This gives the app and daemon one installation-defined search path instead of competing policies. +This policy is daemon-wide, not limited to tmux. Every process the daemon starts, +including `git` and helpers that `git` invokes such as `git-lfs`, inherits the captured +installation `PATH`. Those tools must therefore be available through that path. Tmux +alone has the explicit saved-executable fallback described below; other daemon +descendants do not gain per-tool fallbacks. + Only `PATH` is captured. TBD does not persist the installation shell's whole environment. @@ -150,7 +156,9 @@ Searching package-manager or system directories outside `PATH` creates a second, hidden precedence policy and can silently select a different tmux than the installer selected. There is no universal directory list across package managers, architectures, or user-managed toolchains. TBD searches only the authoritative inherited `PATH` and -the explicit saved fallback. +the explicit saved tmux fallback. Restoring fixed-directory augmentation for `git-lfs` +or other daemon descendants would reintroduce the same hidden policy daemon-wide and +could make subprocess behavior differ from the installation environment. ### Login shell or `path_helper` @@ -183,8 +191,10 @@ changes. ## Verification contract -- Installation-path tests verify exact round trips, replacement, invalid input, source - plist preservation, and generated plist validity. +- The installation-path shell harness verifies the plist-generation helper's exact + round trips, replacement, invalid input, source plist preservation, and generated + plist validity. It does not exercise an OS-level crash and LaunchServices relaunch + end to end. - Resolver tests verify PATH order, executable validation, ignored entries, paths with spaces, fallback precedence, and absence of implicit directory search. - App tests verify startup prompting, saving, clearing, PATH authority, live From 8301bc228e85e9e4d638e58594a6d3e45dc527b8 Mon Sep 17 00:00:00 2001 From: Jeffrey Burt Date: Tue, 11 Aug 2026 19:08:52 -0700 Subject: [PATCH 11/14] fix: refresh tmux version for live gates --- .../ControlMode/TmuxControlModeBridge.swift | 13 +++--- .../ControlModeSettingsRPCTests.swift | 40 +++++++++++++++++++ ...08-11-tmux-executable-resolution-design.md | 7 ++-- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlModeBridge.swift b/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlModeBridge.swift index 101091e6..0ff4467a 100644 --- a/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlModeBridge.swift +++ b/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlModeBridge.swift @@ -28,8 +28,8 @@ struct TmuxControlModeBridge: Sendable { /// The single per-daemon supervisor. Connections are keyed by server name /// and `ensureConnection` is idempotent, so all call sites share one. let supervisor: TmuxControlSupervisor - /// Path/version pair detected at daemon startup. Its version is reused only - /// while the resolver still selects the paired executable. + /// Path/version pair detected at daemon startup. Live decisions do not + /// reuse its version because the executable may be replaced in place. let startupTmux: TmuxVersionSnapshot /// Resolves PATH first and the live saved fallback second on every gate /// and capabilities decision. @@ -131,14 +131,11 @@ struct TmuxControlModeBridge: Sendable { } } - /// Current tmux version. The startup result is cached only for the same - /// effective executable path; a changed PATH or saved fallback is detected - /// at use so Settings changes take effect without a daemon restart. + /// Current tmux version. Both the effective path and the version are + /// detected at use so Settings changes and in-place executable upgrades + /// take effect without a daemon restart. func currentTmuxVersion() async -> TmuxVersion? { guard let executablePath = tmuxExecutableResolver.resolve()?.path else { return nil } - if executablePath == startupTmux.executablePath, let version = startupTmux.version { - return version - } return await TmuxVersion.detect(tmuxBinary: executablePath) } diff --git a/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift b/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift index 91102ec8..0e6a8a69 100644 --- a/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift +++ b/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift @@ -244,6 +244,46 @@ struct ControlModeSettingsRPCTests { #expect(result.controlModeEnabled == false) } + // Tier 2: real filesystem and fixture-owned version subprocesses. + @Test("capabilities and gate follow an executable replaced at the same path") + func capabilitiesFollowSamePathExecutableReplacement() async throws { + let fixture = try TmuxVersionFallbackFixture() + defer { fixture.remove() } + let emptyDirectory = try fixture.directory(named: "empty-path") + let executable = try fixture.versionExecutable(version: "3.6") + let resolver = TmuxExecutableResolver( + environment: ["PATH": emptyDirectory.path], + configurationURL: fixture.configurationURL + ) + try resolver.save(executable.path) + let startupVersion = try #require(await TmuxVersion.detect(tmuxBinary: executable.path)) + let (router, db) = try makeRouterAndDB() + try await db.config.setControlModeEnabled(true) + let liveBridge = bridge( + db: db, + tmuxVersion: startupVersion, + tmuxExecutableResolver: resolver, + startupTmux: TmuxVersionSnapshot( + executablePath: executable.path, + version: startupVersion + ) + ) + router.controlMode = liveBridge + + #expect(await liveBridge.currentTmuxVersion()?.description == "3.6") + #expect(await liveBridge.gateEnabled()) + + _ = try fixture.versionExecutable(version: "3.1") + + #expect(await liveBridge.currentTmuxVersion()?.description == "3.1") + #expect(await liveBridge.gateEnabled() == false) + let response = await router.handle(RPCRequest(method: RPCMethod.daemonCapabilities)) + let result = try response.decodeResult(DaemonCapabilitiesResult.self) + #expect(result.tmuxVersion == "3.1") + #expect(result.controlModeSupported == false) + #expect(result.controlModeEnabled == false) + } + // Tier 2: real filesystem and fixture-owned version subprocesses. @Test("startup detection remains paired with its executable when fallback changes before bridge construction") func startupDetectionKeepsItsExecutablePath() async throws { diff --git a/docs/specs/2026-08-11-tmux-executable-resolution-design.md b/docs/specs/2026-08-11-tmux-executable-resolution-design.md index 396bd108..ba67ceed 100644 --- a/docs/specs/2026-08-11-tmux-executable-resolution-design.md +++ b/docs/specs/2026-08-11-tmux-executable-resolution-design.md @@ -117,9 +117,10 @@ of permanently caching the selected path. This means a Settings save or clear affects later terminal preparation, later daemon tmux commands, and later control-mode gate or capability decisions. The daemon pairs a -detected tmux version with the executable path that produced it. It may reuse a startup -version only while the effective path remains unchanged; otherwise it detects the -version from the newly resolved executable. +detected tmux version with the executable path that produced it. Control-mode gate and +capability decisions detect the version again from each newly resolved executable, so +replacing the executable in place at an unchanged path also takes effect without a +daemon restart. Within one terminal preparation, TBD resolves exactly once. The absolute executable path is carried through session creation, window selection, confirmation, cleanup, From 8aeecd1c0473aff24b078d02792323b36681fb28 Mon Sep 17 00:00:00 2001 From: Jeffrey Burt Date: Tue, 11 Aug 2026 19:38:22 -0700 Subject: [PATCH 12/14] fix: make tmux startup resolution observable --- Sources/TBDApp/AppState.swift | 40 +++++++++ Sources/TBDDaemon/Daemon.swift | 11 +-- .../ControlMode/TmuxControlModeBridge.swift | 20 ----- Sources/TBDShared/RPCProtocol.swift | 4 +- .../TmuxExecutableSettingsTests.swift | 12 +++ Tests/TBDDaemonTests/AttachRPCTests.swift | 12 --- .../ControlModeSettingsRPCTests.swift | 82 ++----------------- .../PaneRepairCoordinatorTests.swift | 4 - ...08-11-tmux-executable-resolution-design.md | 29 ++++--- 9 files changed, 81 insertions(+), 133 deletions(-) diff --git a/Sources/TBDApp/AppState.swift b/Sources/TBDApp/AppState.swift index 9a4c0668..edd53b43 100644 --- a/Sources/TBDApp/AppState.swift +++ b/Sources/TBDApp/AppState.swift @@ -6,6 +6,10 @@ import TBDShared import os private let logger = Logger(subsystem: "com.tbd.app", category: "AppState") +private let tmuxResolutionLogger = Logger( + subsystem: "com.tbd.app", + category: "tmux" +) /// Spec C §11.3 — log-only shadow-compare diagnostic. Dedicated category so /// it can be streamed/filtered independently of the rest of AppState. private let shadowCompareLogger = Logger(subsystem: "com.tbd.app", category: "panelShadow") @@ -41,6 +45,41 @@ struct ControlModePaneKey: Hashable { let paneID: String } +enum TmuxStartupResolutionDiagnostic: Equatable { + case path(String) + case savedFallback(String) + case unavailable + + init(resolution: TmuxExecutableResolution?) { + switch resolution { + case .some(let resolution): + switch resolution.source { + case .path: + self = .path(resolution.path) + case .savedFallback: + self = .savedFallback(resolution.path) + } + case .none: + self = .unavailable + } + } + + func log() { + switch self { + case .path(let path): + tmuxResolutionLogger.notice( + "startup resolution source=PATH path=\(path, privacy: .public)" + ) + case .savedFallback(let path): + tmuxResolutionLogger.notice( + "startup resolution source=saved-fallback path=\(path, privacy: .public)" + ) + case .unavailable: + tmuxResolutionLogger.error("startup resolution source=unavailable") + } + } +} + @MainActor final class AppState: ObservableObject { /// Reference to the global appearance settings, wired by `TBDAppMain` @@ -1274,6 +1313,7 @@ final class AppState: ObservableObject { guard !hasCheckedTmuxAvailabilityAtStartup else { return } hasCheckedTmuxAvailabilityAtStartup = true refreshTmuxExecutableState() + TmuxStartupResolutionDiagnostic(resolution: tmuxExecutableResolution).log() isTmuxLocationPromptPresented = tmuxExecutableResolution == nil } diff --git a/Sources/TBDDaemon/Daemon.swift b/Sources/TBDDaemon/Daemon.swift index 0da1ad1c..5ff55eaa 100644 --- a/Sources/TBDDaemon/Daemon.swift +++ b/Sources/TBDDaemon/Daemon.swift @@ -389,13 +389,11 @@ public final class Daemon: Sendable { ) let pendingQuestions = PendingQuestionStore() - // Snapshot the effective tmux path and its version together. The - // control-mode bridge is shared - // by lifecycle + router so every `ensureServer()` call site can open a - // gated control connection through a single supervisor. When the gate - // is off (the default), `enableIfGated` is a no-op. + // The control-mode bridge is shared by lifecycle + router so every + // `ensureServer()` call site can open a gated control connection + // through a single supervisor. When the gate is off (the default), + // `enableIfGated` is a no-op. let tmuxExecutableResolver = TmuxExecutableResolver() - let startupTmux = await TmuxVersionSnapshot.detect(using: tmuxExecutableResolver) // Input activity tracker: records the timestamp of the last keystroke // routed to each pane so the idle sweep can veto a park if input arrived // after the session went idle (pending-input detection). @@ -418,7 +416,6 @@ public final class Daemon: Sendable { ) let controlModeBridge = TmuxControlModeBridge( supervisor: controlModeSupervisor, - startupTmux: startupTmux, tmuxExecutableResolver: tmuxExecutableResolver, fdVending: fdVendingServer, inputRouter: controlModeInputRouter, diff --git a/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlModeBridge.swift b/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlModeBridge.swift index 0ff4467a..5091fdfe 100644 --- a/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlModeBridge.swift +++ b/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlModeBridge.swift @@ -1,21 +1,6 @@ import Foundation import TBDShared -/// A tmux version paired with the exact executable path it was detected from. -/// Keeping the two values together prevents a resolver change between version -/// detection and bridge construction from mislabeling one executable's version -/// as another's. -struct TmuxVersionSnapshot: Sendable { - let executablePath: String? - let version: TmuxVersion? - - static func detect(using resolver: TmuxExecutableResolver) async -> TmuxVersionSnapshot { - let executablePath = resolver.resolve()?.path - let version = await TmuxVersion.detect(tmuxBinary: executablePath) - return TmuxVersionSnapshot(executablePath: executablePath, version: version) - } -} - /// Bundles the per-daemon `TmuxControlSupervisor` with tmux version resolution /// so every `ensureServer()` call site can open a gated control-mode /// connection through a single shared owner. @@ -28,9 +13,6 @@ struct TmuxControlModeBridge: Sendable { /// The single per-daemon supervisor. Connections are keyed by server name /// and `ensureConnection` is idempotent, so all call sites share one. let supervisor: TmuxControlSupervisor - /// Path/version pair detected at daemon startup. Live decisions do not - /// reuse its version because the executable may be replaced in place. - let startupTmux: TmuxVersionSnapshot /// Resolves PATH first and the live saved fallback second on every gate /// and capabilities decision. let tmuxExecutableResolver: TmuxExecutableResolver @@ -75,7 +57,6 @@ struct TmuxControlModeBridge: Sendable { let clock: any Clock init(supervisor: TmuxControlSupervisor, - startupTmux: TmuxVersionSnapshot, tmuxExecutableResolver: TmuxExecutableResolver = TmuxExecutableResolver(), environment: [String: String] = ProcessInfo.processInfo.environment, fdVending: FDVendingServer, @@ -89,7 +70,6 @@ struct TmuxControlModeBridge: Sendable { self.supervisor = supervisor self.clock = clock self.tmuxExecutableResolver = tmuxExecutableResolver - self.startupTmux = startupTmux self.environment = environment self.fdVending = fdVending self.readyTimeout = readyTimeout diff --git a/Sources/TBDShared/RPCProtocol.swift b/Sources/TBDShared/RPCProtocol.swift index 7c0f23db..8ae7f320 100644 --- a/Sources/TBDShared/RPCProtocol.swift +++ b/Sources/TBDShared/RPCProtocol.swift @@ -1994,8 +1994,8 @@ public struct DaemonCapabilitiesResult: Codable, Sendable { /// Effective control-mode gate: `(env || persisted flag) && tmux >= 3.2`, /// re-evaluated by the daemon on every call. public let controlModeEnabled: Bool - /// tmux version the daemon detected at startup (e.g. "3.6a"); nil when - /// detection failed (tmux missing/unparseable). + /// tmux version the daemon detects for this request (e.g. "3.6a"); nil + /// when detection fails (tmux missing/unparseable). public let tmuxVersion: String? /// Whether the detected tmux meets the control-mode minimum (>= 3.2). /// Computed daemon-side so the app never parses version strings. diff --git a/Tests/TBDAppTests/TmuxExecutableSettingsTests.swift b/Tests/TBDAppTests/TmuxExecutableSettingsTests.swift index 61e451a5..24d2520f 100644 --- a/Tests/TBDAppTests/TmuxExecutableSettingsTests.swift +++ b/Tests/TBDAppTests/TmuxExecutableSettingsTests.swift @@ -7,6 +7,18 @@ import Testing @MainActor @Suite("Tmux executable settings") struct TmuxExecutableSettingsTests { + @Test func startupDiagnosticDistinguishesResolutionSourceAndUnavailability() { + #expect(TmuxStartupResolutionDiagnostic(resolution: TmuxExecutableResolution( + path: "/opt/tools/tmux", + source: .path + )) == .path("/opt/tools/tmux")) + #expect(TmuxStartupResolutionDiagnostic(resolution: TmuxExecutableResolution( + path: "/custom/tools/tmux", + source: .savedFallback + )) == .savedFallback("/custom/tools/tmux")) + #expect(TmuxStartupResolutionDiagnostic(resolution: nil) == .unavailable) + } + @Test func backingFilePresentationAbbreviatesHomeAndPreservesCopyPath() { let presentation = TmuxConfigurationPathPresentation( configurationURL: URL(fileURLWithPath: "/Users/acme/tbd/tmux-executable-path"), diff --git a/Tests/TBDDaemonTests/AttachRPCTests.swift b/Tests/TBDDaemonTests/AttachRPCTests.swift index 8178bcfd..c51a6084 100644 --- a/Tests/TBDDaemonTests/AttachRPCTests.swift +++ b/Tests/TBDDaemonTests/AttachRPCTests.swift @@ -62,10 +62,6 @@ struct AttachRPCStubTests { let worktreeID = try await makeWorktree(in: db) router.controlMode = TmuxControlModeBridge( supervisor: TmuxControlSupervisor(), - startupTmux: TmuxVersionSnapshot( - executablePath: tmux.resolver.resolve()?.path, - version: TmuxVersion(major: 3, minor: 6) - ), tmuxExecutableResolver: tmux.resolver, environment: ["TBD_TMUX_CONTROL_MODE": "1"], fdVending: FDVendingServer()) @@ -107,10 +103,6 @@ struct AttachRPCStubTests { let (router, _) = try makeRouterAndDB() router.controlMode = TmuxControlModeBridge( supervisor: TmuxControlSupervisor(), - startupTmux: TmuxVersionSnapshot( - executablePath: tmux.resolver.resolve()?.path, - version: TmuxVersion(major: 3, minor: 6) - ), tmuxExecutableResolver: tmux.resolver, environment: ["TBD_TMUX_CONTROL_MODE": "1"], fdVending: FDVendingServer()) @@ -157,10 +149,6 @@ struct AttachRPCOrchestrationTests { ) -> TmuxControlModeBridge { TmuxControlModeBridge( supervisor: supervisor, - startupTmux: TmuxVersionSnapshot( - executablePath: tmuxExecutableResolver.resolve()?.path, - version: TmuxVersion(major: 3, minor: 6) - ), tmuxExecutableResolver: tmuxExecutableResolver, environment: gateOn ? ["TBD_TMUX_CONTROL_MODE": "1"] : [:], fdVending: vending, diff --git a/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift b/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift index 0e6a8a69..2fe64ba9 100644 --- a/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift +++ b/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift @@ -60,16 +60,10 @@ struct ControlModeSettingsRPCTests { db: TBDDatabase, vending: FDVendingServer = FDVendingServer(), environment: [String: String] = [:], - tmuxVersion: TmuxVersion? = TmuxVersion(major: 3, minor: 6), - tmuxExecutableResolver: TmuxExecutableResolver, - startupTmux: TmuxVersionSnapshot? = nil + tmuxExecutableResolver: TmuxExecutableResolver ) -> TmuxControlModeBridge { TmuxControlModeBridge( supervisor: TmuxControlSupervisor(), - startupTmux: startupTmux ?? TmuxVersionSnapshot( - executablePath: tmuxExecutableResolver.resolve()?.path, - version: tmuxVersion - ), tmuxExecutableResolver: tmuxExecutableResolver, environment: environment, fdVending: vending, @@ -115,7 +109,6 @@ struct ControlModeSettingsRPCTests { let (router, db) = try makeRouterAndDB() router.controlMode = bridge( db: db, - tmuxVersion: TmuxVersion(major: 3, minor: 6, suffix: "a"), tmuxExecutableResolver: tmux.resolver ) let response = await router.handle(RPCRequest(method: RPCMethod.daemonCapabilities)) @@ -132,7 +125,6 @@ struct ControlModeSettingsRPCTests { try await db.config.setControlModeEnabled(true) router.controlMode = bridge( db: db, - tmuxVersion: TmuxVersion(major: 3, minor: 1), tmuxExecutableResolver: tmux.resolver ) let response = await router.handle(RPCRequest(method: RPCMethod.daemonCapabilities)) @@ -181,11 +173,7 @@ struct ControlModeSettingsRPCTests { ) let (router, db) = try makeRouterAndDB() try await db.config.setControlModeEnabled(true) - let liveBridge = bridge( - db: db, - tmuxVersion: nil, - tmuxExecutableResolver: resolver - ) + let liveBridge = bridge(db: db, tmuxExecutableResolver: resolver) router.controlMode = liveBridge #expect(await liveBridge.currentTmuxVersion() == nil) @@ -204,7 +192,7 @@ struct ControlModeSettingsRPCTests { } // Tier 2: real filesystem and fixture-owned version subprocesses. - @Test("capabilities and gate follow a changed saved fallback after successful startup detection") + @Test("capabilities and gate follow a changed saved fallback") func capabilitiesFollowChangedSavedFallback() async throws { let fixture = try TmuxVersionFallbackFixture() defer { fixture.remove() } @@ -216,18 +204,9 @@ struct ControlModeSettingsRPCTests { configurationURL: fixture.configurationURL ) try resolver.save(executableA.path) - let startupVersion = try #require(await TmuxVersion.detect(tmuxBinary: executableA.path)) let (router, db) = try makeRouterAndDB() try await db.config.setControlModeEnabled(true) - let liveBridge = bridge( - db: db, - tmuxVersion: startupVersion, - tmuxExecutableResolver: resolver, - startupTmux: TmuxVersionSnapshot( - executablePath: executableA.path, - version: startupVersion - ) - ) + let liveBridge = bridge(db: db, tmuxExecutableResolver: resolver) router.controlMode = liveBridge #expect(await liveBridge.currentTmuxVersion()?.description == "3.6") @@ -256,18 +235,9 @@ struct ControlModeSettingsRPCTests { configurationURL: fixture.configurationURL ) try resolver.save(executable.path) - let startupVersion = try #require(await TmuxVersion.detect(tmuxBinary: executable.path)) let (router, db) = try makeRouterAndDB() try await db.config.setControlModeEnabled(true) - let liveBridge = bridge( - db: db, - tmuxVersion: startupVersion, - tmuxExecutableResolver: resolver, - startupTmux: TmuxVersionSnapshot( - executablePath: executable.path, - version: startupVersion - ) - ) + let liveBridge = bridge(db: db, tmuxExecutableResolver: resolver) router.controlMode = liveBridge #expect(await liveBridge.currentTmuxVersion()?.description == "3.6") @@ -284,47 +254,6 @@ struct ControlModeSettingsRPCTests { #expect(result.controlModeEnabled == false) } - // Tier 2: real filesystem and fixture-owned version subprocesses. - @Test("startup detection remains paired with its executable when fallback changes before bridge construction") - func startupDetectionKeepsItsExecutablePath() async throws { - let fixture = try TmuxVersionFallbackFixture() - defer { fixture.remove() } - let emptyDirectory = try fixture.directory(named: "empty-path") - let executableA = try fixture.versionExecutable(named: "tmux-a", version: "3.6") - let executableB = try fixture.versionExecutable(named: "tmux-b", version: "3.1") - let resolver = TmuxExecutableResolver( - environment: ["PATH": emptyDirectory.path], - configurationURL: fixture.configurationURL - ) - try resolver.save(executableA.path) - let startupVersion = try #require(await TmuxVersion.detect(tmuxBinary: executableA.path)) - - // Model a Settings write in the interval between startup detection and - // bridge construction. The detected version still belongs to A. - try resolver.save(executableB.path) - - let (router, db) = try makeRouterAndDB() - try await db.config.setControlModeEnabled(true) - let liveBridge = bridge( - db: db, - tmuxVersion: startupVersion, - tmuxExecutableResolver: resolver, - startupTmux: TmuxVersionSnapshot( - executablePath: executableA.path, - version: startupVersion - ) - ) - router.controlMode = liveBridge - - #expect(await liveBridge.currentTmuxVersion()?.description == "3.1") - #expect(await liveBridge.gateEnabled() == false) - let response = await router.handle(RPCRequest(method: RPCMethod.daemonCapabilities)) - let result = try response.decodeResult(DaemonCapabilitiesResult.self) - #expect(result.tmuxVersion == "3.1") - #expect(result.controlModeSupported == false) - #expect(result.controlModeEnabled == false) - } - /// Codable back-compat: capabilities JSON from a pre-M5 daemon (no new /// keys) must still decode in a newer app. @Test("capabilities JSON without the new keys decodes with safe defaults") @@ -426,7 +355,6 @@ struct ControlModeSettingsRPCTests { try await db.config.setControlModeEnabled(true) router.controlMode = bridge( db: db, - tmuxVersion: TmuxVersion(major: 3, minor: 1), tmuxExecutableResolver: tmux.resolver ) diff --git a/Tests/TBDDaemonTests/PaneRepairCoordinatorTests.swift b/Tests/TBDDaemonTests/PaneRepairCoordinatorTests.swift index 5d6acf7e..82b44c55 100644 --- a/Tests/TBDDaemonTests/PaneRepairCoordinatorTests.swift +++ b/Tests/TBDDaemonTests/PaneRepairCoordinatorTests.swift @@ -694,10 +694,6 @@ struct PaneRepairCoordinatorTests { let clock = TestClock() let bridge = TmuxControlModeBridge( supervisor: supervisor, - startupTmux: TmuxVersionSnapshot( - executablePath: TmuxManager.tmuxPath(), - version: TmuxVersion(major: 3, minor: 6) - ), environment: [:], fdVending: FDVendingServer(), commandProvider: { [server] in $0 == server ? client : nil }, diff --git a/docs/specs/2026-08-11-tmux-executable-resolution-design.md b/docs/specs/2026-08-11-tmux-executable-resolution-design.md index ba67ceed..5904a62c 100644 --- a/docs/specs/2026-08-11-tmux-executable-resolution-design.md +++ b/docs/specs/2026-08-11-tmux-executable-resolution-design.md @@ -37,11 +37,12 @@ The app passes its inherited environment to the daemon. The daemon does not appe directories, invoke a shell, or otherwise reinterpret `PATH`. This gives the app and daemon one installation-defined search path instead of competing policies. -This policy is daemon-wide, not limited to tmux. Every process the daemon starts, -including `git` and helpers that `git` invokes such as `git-lfs`, inherits the captured -installation `PATH`. Those tools must therefore be available through that path. Tmux -alone has the explicit saved-executable fallback described below; other daemon -descendants do not gain per-tool fallbacks. +This policy is daemon-wide, not limited to tmux. Every process the daemon starts +inherits the captured installation `PATH`. The daemon launches the `git` executable +at its existing fixed `/usr/bin/git` path, while helpers that `git` invokes, such as +`git-lfs`, depend on the inherited `PATH`. Tmux alone has the explicit +saved-executable fallback described below; other daemon descendants do not gain +per-tool fallbacks. Only `PATH` is captured. TBD does not persist the installation shell's whole environment. @@ -91,6 +92,12 @@ executable with the system file picker or dismiss the prompt. The prompt appears most once during one app-state lifetime; clearing or invalidating the value later does not repeatedly interrupt the same running session. +The same once-per-lifetime startup check emits a diagnostic that reports the resolved +tmux path and whether it came from `PATH` or the saved fallback, or reports that tmux +is unavailable. It does not log the complete `PATH` or any other environment value. +This makes field regressions in LaunchServices relaunches diagnosable, but does not +replace an OS-level crash-and-relaunch test. + A successful selection is validated, saved, and immediately reflected in app state. Cancelling the picker or dismissing the prompt does not write configuration. @@ -116,11 +123,10 @@ the shared fallback-file location. They resolve again at operation boundaries in of permanently caching the selected path. This means a Settings save or clear affects later terminal preparation, later daemon -tmux commands, and later control-mode gate or capability decisions. The daemon pairs a -detected tmux version with the executable path that produced it. Control-mode gate and -capability decisions detect the version again from each newly resolved executable, so -replacing the executable in place at an unchanged path also takes effect without a -daemon restart. +tmux commands, and later control-mode gate or capability decisions. Control-mode gate +and capability decisions resolve the executable and detect its version again for each +decision, so replacing the executable in place at an unchanged path also takes effect +without a daemon restart. Within one terminal preparation, TBD resolves exactly once. The absolute executable path is carried through session creation, window selection, confirmation, cleanup, @@ -195,7 +201,8 @@ changes. - The installation-path shell harness verifies the plist-generation helper's exact round trips, replacement, invalid input, source plist preservation, and generated plist validity. It does not exercise an OS-level crash and LaunchServices relaunch - end to end. + end to end; the once-per-startup resolution diagnostic is the explicit field + observability mitigation for that automation gap. - Resolver tests verify PATH order, executable validation, ignored entries, paths with spaces, fallback precedence, and absence of implicit directory search. - App tests verify startup prompting, saving, clearing, PATH authority, live From c1456f8651c74b26eff76b909626513bfd2ce46b Mon Sep 17 00:00:00 2001 From: Jeffrey Burt Date: Tue, 11 Aug 2026 20:03:16 -0700 Subject: [PATCH 13/14] fix: skip disabled tmux version checks --- .../Tmux/ControlMode/TmuxControlModeBridge.swift | 15 +++++++++++++-- .../ControlModeSettingsRPCTests.swift | 5 ++++- Tests/TestSupport/TmuxExecutableTestFixture.swift | 3 +++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlModeBridge.swift b/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlModeBridge.swift index 5091fdfe..132be846 100644 --- a/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlModeBridge.swift +++ b/Sources/TBDDaemon/Tmux/ControlMode/TmuxControlModeBridge.swift @@ -121,6 +121,8 @@ struct TmuxControlModeBridge: Sendable { /// Effective gate decision and the version used for it, evaluated from one /// snapshot so capability fields cannot disagree with the enabled state. + /// Capability queries report tmux support even while control mode is off, + /// so this path intentionally detects the version before applying the gate. func currentGateState() async -> (enabled: Bool, tmuxVersion: TmuxVersion?) { let version = await currentTmuxVersion() let enabled = ControlModeGate.shouldEnable( @@ -134,9 +136,18 @@ struct TmuxControlModeBridge: Sendable { /// Effective gate decision, evaluated fresh on every call: /// `(env opt-in || persisted flag) && tmux >= 3.2`. The persisted flag is /// read through `persistedFlagProvider`, so a Settings toggle takes - /// effect on the next decision without a daemon restart. + /// effect on the next decision without a daemon restart. Hot-path gate + /// checks avoid resolving or launching tmux while both opt-ins are off. func gateEnabled() async -> Bool { - await currentGateState().enabled + let persistedFlag = await persistedFlagProvider() + guard ControlModeGate.optedIn(environment: environment) || persistedFlag else { + return false + } + return ControlModeGate.shouldEnable( + environment: environment, + persistedFlag: persistedFlag, + tmuxVersion: await currentTmuxVersion() + ) } /// Open a logging-only `tmux -CC` connection for `serverName` when the diff --git a/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift b/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift index 2fe64ba9..713fde47 100644 --- a/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift +++ b/Tests/TBDDaemonTests/ControlModeSettingsRPCTests.swift @@ -113,6 +113,7 @@ struct ControlModeSettingsRPCTests { ) let response = await router.handle(RPCRequest(method: RPCMethod.daemonCapabilities)) let result = try response.decodeResult(DaemonCapabilitiesResult.self) + #expect(result.controlModeEnabled == false) #expect(result.tmuxVersion == "3.6a") #expect(result.controlModeSupported == true) } @@ -293,7 +294,7 @@ struct ControlModeSettingsRPCTests { #expect(header.paneID == "%11") } - @Test("flag off, env off: attach is unavailable; flipping the flag affects the NEXT attach") + @Test("gate-off attach skips version detection; flipping the flag affects the NEXT attach") func toggleMidSessionAffectsNextAttach() async throws { let tmux = try TmuxExecutableTestFixture() defer { tmux.remove() } @@ -312,11 +313,13 @@ struct ControlModeSettingsRPCTests { let before = try await attach(router, worktreeID: worktreeID, paneID: "%12", windowID: "@12") #expect(before.status == "unavailable") + #expect(!FileManager.default.fileExists(atPath: tmux.invocationLogURL.path)) try await setControlMode(router, enabled: true) let after = try await attach(router, worktreeID: worktreeID, paneID: "%12", windowID: "@12") #expect(after.status == "pending") + #expect(FileManager.default.fileExists(atPath: tmux.invocationLogURL.path)) let (rxFD, _) = try SidecarTestSupport.receiveVend(from: clientSide) Darwin.close(rxFD) } diff --git a/Tests/TestSupport/TmuxExecutableTestFixture.swift b/Tests/TestSupport/TmuxExecutableTestFixture.swift index ed2982d0..fe8f40a5 100644 --- a/Tests/TestSupport/TmuxExecutableTestFixture.swift +++ b/Tests/TestSupport/TmuxExecutableTestFixture.swift @@ -7,17 +7,20 @@ import TBDShared public struct TmuxExecutableTestFixture: Sendable { public let root: URL public let executableURL: URL + public let invocationLogURL: URL public let configurationURL: URL public init(version: String = "3.6") throws { root = FileManager.default.temporaryDirectory .appendingPathComponent("TmuxExecutableTestFixture-\(UUID().uuidString)", isDirectory: true) executableURL = root.appendingPathComponent("tmux") + invocationLogURL = root.appendingPathComponent("invocations") configurationURL = root.appendingPathComponent("tmux-executable-path") try FileManager.default.createDirectory(at: root, withIntermediateDirectories: false) let script = """ #!/bin/sh + printf '%s\\n' invoked >> '\(invocationLogURL.path)' printf '%s\\n' 'tmux \(version)' """ try script.write(to: executableURL, atomically: true, encoding: .utf8) From 744c4eca8d30bdd8a475ccd62cbb716ae8a488b3 Mon Sep 17 00:00:00 2001 From: Jeffrey Burt Date: Tue, 11 Aug 2026 20:10:27 -0700 Subject: [PATCH 14/14] docs: clarify disabled control-mode checks --- .../2026-08-11-tmux-executable-resolution-design.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/specs/2026-08-11-tmux-executable-resolution-design.md b/docs/specs/2026-08-11-tmux-executable-resolution-design.md index 5904a62c..3483ad05 100644 --- a/docs/specs/2026-08-11-tmux-executable-resolution-design.md +++ b/docs/specs/2026-08-11-tmux-executable-resolution-design.md @@ -123,10 +123,13 @@ the shared fallback-file location. They resolve again at operation boundaries in of permanently caching the selected path. This means a Settings save or clear affects later terminal preparation, later daemon -tmux commands, and later control-mode gate or capability decisions. Control-mode gate -and capability decisions resolve the executable and detect its version again for each -decision, so replacing the executable in place at an unchanged path also takes effect -without a daemon restart. +tmux commands, and later control-mode gate or capability decisions. An operational +control-mode gate check short-circuits without resolving the executable or detecting +its version when both the environment and persisted opt-ins are off. When either +opt-in is on, the gate resolves the executable and detects its version again. Explicit +capability queries also resolve and detect the version while the gate is off so +Settings can report support and allow opt-in. These live checks make replacing the +executable in place at an unchanged path take effect without a daemon restart. Within one terminal preparation, TBD resolves exactly once. The absolute executable path is carried through session creation, window selection, confirmation, cleanup,