diff --git a/.github/workflows/build-ghosttykit.yml b/.github/workflows/build-ghosttykit.yml index ec787452..80850f50 100644 --- a/.github/workflows/build-ghosttykit.yml +++ b/.github/workflows/build-ghosttykit.yml @@ -8,9 +8,10 @@ on: jobs: build-ghosttykit: - # Never run Depot jobs for fork pull requests (avoid billing on external PRs). + # Never run WarpBuild jobs for fork pull requests (avoid billing on external PRs). if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: depot-macos-latest + runs-on: warp-macos-15-arm64-6x + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -61,13 +62,18 @@ jobs: if: steps.check-release.outputs.exists == 'false' run: | set -euo pipefail - if ! command -v zig >/dev/null 2>&1; then - if command -v brew >/dev/null 2>&1; then - brew install zig - else - echo "zig is required to build GhosttyKit.xcframework. Install zig and retry." >&2 - exit 1 - fi + ZIG_REQUIRED="0.15.2" + if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then + echo "zig ${ZIG_REQUIRED} already installed" + else + echo "Installing zig ${ZIG_REQUIRED} from tarball" + curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz + tar xf /tmp/zig.tar.xz -C /tmp + sudo mkdir -p /usr/local/bin /usr/local/lib + sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig + sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig + export PATH="/usr/local/bin:$PATH" + zig version fi cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=universal -Doptimize=ReleaseFast diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml index 2b7e06c9..a0c72b11 100644 --- a/.github/workflows/ci-macos-compat.yml +++ b/.github/workflows/ci-macos-compat.yml @@ -13,8 +13,17 @@ jobs: strategy: fail-fast: false matrix: - os: [macos-14, macos-15] + include: + - os: warp-macos-15-arm64-6x + timeout: 20 + smoke: true + skip_zig: false + - os: warp-macos-26-arm64-6x + timeout: 20 + smoke: false + skip_zig: true # zig 0.15.2 MachO linker can't resolve libSystem on macOS 26 runs-on: ${{ matrix.os }} + timeout-minutes: ${{ matrix.timeout }} steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -39,45 +48,49 @@ jobs: echo "Selected: $XCODE_APP" echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" export DEVELOPER_DIR="$XCODE_DIR" - xcodebuild -version + XCODE_VER="$(xcodebuild -version | head -1)" + echo "XCODE_VER=$XCODE_VER" >> "$GITHUB_ENV" + echo "$XCODE_VER" xcrun --sdk macosx --show-sdk-path sw_vers + - name: Cache GhosttyKit.xcframework + id: cache-ghosttykit + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: GhosttyKit.xcframework + key: ghosttykit-${{ hashFiles('.gitmodules', 'ghostty') }} + - name: Download pre-built GhosttyKit.xcframework - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + if: steps.cache-ghosttykit.outputs.cache-hit != 'true' run: | - set -euo pipefail - GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD) - TAG="xcframework-$GHOSTTY_SHA" - URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz" - echo "Downloading xcframework for ghostty $GHOSTTY_SHA" - MAX_RETRIES=30 - RETRY_DELAY=20 - for i in $(seq 1 $MAX_RETRIES); do - if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then - echo "Download succeeded on attempt $i" - break - fi - if [ "$i" -eq "$MAX_RETRIES" ]; then - echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2 - exit 1 - fi - echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..." - sleep $RETRY_DELAY - done - tar xzf GhosttyKit.xcframework.tar.gz - rm GhosttyKit.xcframework.tar.gz - test -d GhosttyKit.xcframework + ./scripts/download-prebuilt-ghosttykit.sh - name: Install zig + if: ${{ !matrix.skip_zig }} run: | - if ! command -v zig >/dev/null 2>&1; then - brew install zig + ZIG_REQUIRED="0.15.2" + if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then + echo "zig ${ZIG_REQUIRED} already installed" + else + echo "Installing zig ${ZIG_REQUIRED} from tarball" + curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz + tar xf /tmp/zig.tar.xz -C /tmp + sudo mkdir -p /usr/local/bin /usr/local/lib + sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig + sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig + export PATH="/usr/local/bin:$PATH" + zig version fi - - name: Clean DerivedData - run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + - name: Cache DerivedData + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + key: deriveddata-${{ matrix.os }}-${{ env.XCODE_VER }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.pbxproj') }} + restore-keys: | + deriveddata-${{ matrix.os }}-${{ env.XCODE_VER }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}- + deriveddata-${{ matrix.os }}-${{ env.XCODE_VER }}- - name: Cache Swift packages uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 @@ -107,6 +120,8 @@ jobs: done - name: Run unit tests + env: + CMUX_SKIP_ZIG_BUILD: ${{ matrix.skip_zig && '1' || '0' }} run: | set -euo pipefail SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" @@ -147,6 +162,7 @@ jobs: fi - name: Create virtual display + if: matrix.smoke run: | set -euo pipefail echo "=== Display before ===" @@ -162,6 +178,7 @@ jobs: system_profiler SPDisplaysDataType 2>/dev/null || echo "(no display info)" - name: Build app for smoke test + if: matrix.smoke run: | set -euo pipefail SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" @@ -171,6 +188,7 @@ jobs: -destination "platform=macOS" build - name: Smoke test + if: matrix.smoke run: | set -euo pipefail chmod +x scripts/smoke-test-ci.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34a52dbc..2d24e983 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - name: Validate Depot runner guards + - name: Validate WarpBuild runner guards run: ./tests/test_ci_self_hosted_guard.sh - name: Validate create-dmg version pinning @@ -28,9 +28,30 @@ jobs: - name: Validate GhosttyKit checksum verification run: ./tests/test_ci_ghosttykit_checksum_verification.sh + - name: Validate release asset guard + run: node scripts/release_asset_guard.test.js + - name: Validate current GhosttyKit checksum pin run: ./tests/test_ci_ghosttykit_checksum_present.sh + remote-daemon-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Setup Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: daemon/remote/go.mod + + - name: Run remote daemon tests + working-directory: daemon/remote + run: go test ./... + + - name: Validate remote daemon release assets + run: ./tests/test_remote_daemon_release_assets.sh + web-typecheck: runs-on: ubuntu-latest defaults: @@ -50,7 +71,10 @@ jobs: run: bun tsc --noEmit tests: - runs-on: macos-15 + # Never run WarpBuild jobs for fork pull requests (avoid billing on external PRs). + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: warp-macos-15-arm64-6x + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -60,35 +84,57 @@ jobs: - name: Select Xcode run: | set -euo pipefail - XCODE_APP="$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort | tail -n 1 || true)" - if [ -z "$XCODE_APP" ]; then - XCODE_APP="/Applications/Xcode.app" + if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then + XCODE_DIR="/Applications/Xcode.app/Contents/Developer" + else + XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | sort | tail -n 1 || true)" + if [ -n "$XCODE_APP" ]; then + XCODE_DIR="$XCODE_APP/Contents/Developer" + else + echo "No Xcode.app found under /Applications" >&2 + exit 1 + fi fi - XCODE_DIR="$XCODE_APP/Contents/Developer" - if [ ! -d "$XCODE_DIR" ]; then - echo "No Xcode found under /Applications" >&2 - exit 1 - fi - echo "Selected: $XCODE_APP" echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV" export DEVELOPER_DIR="$XCODE_DIR" xcodebuild -version - xcrun --sdk macosx --show-sdk-path + + - name: Cache GhosttyKit.xcframework + id: cache-ghosttykit + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: GhosttyKit.xcframework + key: ghosttykit-${{ hashFiles('.gitmodules', 'ghostty') }} - name: Download pre-built GhosttyKit.xcframework + if: steps.cache-ghosttykit.outputs.cache-hit != 'true' run: | ./scripts/download-prebuilt-ghosttykit.sh - name: Install zig run: | - if ! command -v zig >/dev/null 2>&1; then - brew install zig + ZIG_REQUIRED="0.15.2" + if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then + echo "zig ${ZIG_REQUIRED} already installed" + else + echo "Installing zig ${ZIG_REQUIRED} from tarball" + curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz + tar xf /tmp/zig.tar.xz -C /tmp + sudo mkdir -p /usr/local/bin /usr/local/lib + sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig + sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig + export PATH="/usr/local/bin:$PATH" + zig version fi - - name: Clean DerivedData - run: | - # Remove stale build cache to avoid incremental build errors - rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + - name: Cache DerivedData + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + key: deriveddata-tests-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.pbxproj') }} + restore-keys: | + deriveddata-tests-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}- + deriveddata-tests- - name: Cache Swift packages uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 @@ -183,10 +229,14 @@ jobs: CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_version_memory_guard.py - tests-depot: - # Never run Depot jobs for fork pull requests (avoid billing on external PRs). + tests-build-and-lag: + # Build the full cmux scheme and run the lag regression on WarpBuild. + # XCUITests cannot run on WarpBuild (Virtualization.framework limitation: + # XCUIApplication stuck "Running Background", 62s activation timeout per + # test). Interactive UI tests run via test-e2e.yml on GitHub-hosted runners. if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: depot-macos-latest + runs-on: warp-macos-15-arm64-6x + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -211,25 +261,49 @@ jobs: export DEVELOPER_DIR="$XCODE_DIR" xcodebuild -version + - name: Cache GhosttyKit.xcframework + id: cache-ghosttykit-lag + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: GhosttyKit.xcframework + key: ghosttykit-${{ hashFiles('.gitmodules', 'ghostty') }} + - name: Download pre-built GhosttyKit.xcframework + if: steps.cache-ghosttykit-lag.outputs.cache-hit != 'true' run: | ./scripts/download-prebuilt-ghosttykit.sh - name: Install zig run: | - if ! command -v zig >/dev/null 2>&1; then - brew install zig + ZIG_REQUIRED="0.15.2" + if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then + echo "zig ${ZIG_REQUIRED} already installed" + else + echo "Installing zig ${ZIG_REQUIRED} from tarball" + curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz + tar xf /tmp/zig.tar.xz -C /tmp + sudo mkdir -p /usr/local/bin /usr/local/lib + sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig + sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig + export PATH="/usr/local/bin:$PATH" + zig version fi - - name: Clean DerivedData - run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + - name: Cache DerivedData + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + path: ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-* + key: deriveddata-build-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.pbxproj') }} + restore-keys: | + deriveddata-build-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}- + deriveddata-build- - name: Cache Swift packages uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 with: path: .ci-source-packages - key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} - restore-keys: spm- + key: spm-build-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: spm-build- - name: Resolve Swift packages run: | @@ -251,6 +325,15 @@ jobs: sleep $((attempt * 5)) done + - name: Build app + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -disableAutomaticPackageResolution \ + -destination "platform=macOS" build + - name: Create virtual display run: | set -euo pipefail @@ -261,41 +344,6 @@ jobs: echo "VDISPLAY_PID=$VDISPLAY_PID" >> "$GITHUB_ENV" sleep 3 - - name: Run UI tests - run: | - set -euo pipefail - SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" - # SidebarResizeUITests hangs on headless runners (mouse drag simulation - # doesn't work without a physical display, even with virtual display). - # Skip it in CI; it runs fine on local machines. - run_ui_tests() { - xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \ - -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ - -disableAutomaticPackageResolution \ - -destination "platform=macOS" \ - -maximum-test-execution-time-allowance 120 \ - -only-testing:cmuxUITests \ - -skip-testing:cmuxUITests/SidebarResizeUITests test 2>&1 - } - - # xcodebuild exits 65 even for expected failures (XCTExpectFailure). - # Capture output and fail only if there are unexpected failures. - set +e - OUTPUT=$(run_ui_tests) - EXIT_CODE=$? - set -e - - echo "$OUTPUT" - if [ "$EXIT_CODE" -ne 0 ]; then - SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1) - if echo "$SUMMARY" | grep -q "(0 unexpected)"; then - echo "All failures are expected, treating as pass" - else - echo "Unexpected test failures detected" - exit 1 - fi - fi - - name: Run workspace churn typing-lag regression run: | set -euo pipefail diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 8f7e48de..5cb26392 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -19,6 +19,8 @@ concurrency: permissions: contents: write + attestations: write + id-token: write env: CREATE_DMG_VERSION: 8.0.0 @@ -99,7 +101,8 @@ jobs: build-sign-notarize-nightly: needs: decide if: needs.decide.outputs.should_build == 'true' - runs-on: depot-macos-latest + runs-on: warp-macos-15-arm64-6x + timeout-minutes: 20 steps: - name: Checkout build ref uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -151,8 +154,18 @@ jobs: - name: Install build deps if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' run: | - if ! command -v zig >/dev/null 2>&1; then - brew install zig + ZIG_REQUIRED="0.15.2" + if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then + echo "zig ${ZIG_REQUIRED} already installed" + else + echo "Installing zig ${ZIG_REQUIRED} from tarball" + curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz + tar xf /tmp/zig.tar.xz -C /tmp + sudo mkdir -p /usr/local/bin /usr/local/lib + sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig + sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig + export PATH="/usr/local/bin:$PATH" + zig version fi npm install --global "create-dmg@${CREATE_DMG_VERSION}" @@ -169,6 +182,11 @@ jobs: key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} restore-keys: spm- + - name: Setup Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: daemon/remote/go.mod + - name: Derive Sparkle public key from private key if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' env: @@ -256,7 +274,10 @@ jobs: else NIGHTLY_BUILD="${NIGHTLY_DATE}000000" fi + NIGHTLY_MARKETING_VERSION="${BASE_MARKETING}-nightly.${NIGHTLY_BUILD}" echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV" + echo "NIGHTLY_MARKETING_VERSION=${NIGHTLY_MARKETING_VERSION}" >> "$GITHUB_ENV" + echo "NIGHTLY_REMOTE_DAEMON_VERSION=${NIGHTLY_MARKETING_VERSION}" >> "$GITHUB_ENV" NIGHTLY_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg" echo "NIGHTLY_DMG_IMMUTABLE=${NIGHTLY_DMG_IMMUTABLE}" >> "$GITHUB_ENV" @@ -274,7 +295,7 @@ jobs: /usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$app_plist" >/dev/null 2>&1 || true /usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_KEY}" "$app_plist" /usr/libexec/PlistBuddy -c "Add :SUFeedURL string ${feed_url}" "$app_plist" - /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" "$app_plist" + /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${NIGHTLY_MARKETING_VERSION}" "$app_plist" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${NIGHTLY_BUILD}" "$app_plist" /usr/libexec/PlistBuddy -c "Delete :CMUXCommit" "$app_plist" >/dev/null 2>&1 || true /usr/libexec/PlistBuddy -c "Add :CMUXCommit string ${SHORT_SHA}" "$app_plist" @@ -288,11 +309,29 @@ jobs: echo "Nightly app name: cmux NIGHTLY" echo "Nightly bundle ID: com.cmuxterm.app.nightly" - echo "Nightly marketing version: ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" + echo "Nightly marketing version: ${NIGHTLY_MARKETING_VERSION}" echo "Nightly build number: ${NIGHTLY_BUILD}" echo "Nightly immutable DMG: ${NIGHTLY_DMG_IMMUTABLE}" echo "Commit SHA: ${SHORT_SHA}" + - name: Build remote daemon nightly assets and inject manifest + if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true') + run: | + set -euo pipefail + ./scripts/build_remote_daemon_release_assets.sh \ + --version "$NIGHTLY_REMOTE_DAEMON_VERSION" \ + --release-tag "nightly" \ + --repo "manaflow-ai/cmux" \ + --output-dir "remote-daemon-assets" + MANIFEST_JSON="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1], encoding="utf-8")), separators=(",",":")))' remote-daemon-assets/cmuxd-remote-manifest.json)" + APP_PLIST="build-universal/Build/Products/Release/cmux NIGHTLY.app/Contents/Info.plist" + if [ ! -f "$APP_PLIST" ]; then + echo "Missing nightly app Info.plist at $APP_PLIST" >&2 + exit 1 + fi + plutil -remove CMUXRemoteDaemonManifestJSON "$APP_PLIST" >/dev/null 2>&1 || true + plutil -insert CMUXRemoteDaemonManifestJSON -string "$MANIFEST_JSON" "$APP_PLIST" + - name: Import signing cert if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true') env: @@ -436,6 +475,18 @@ jobs: # installs to migrate onto the unified nightly appcast. cp appcast.xml appcast-universal.xml + - name: Attest remote daemon nightly assets + if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true') + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: | + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json + - name: Upload branch nightly artifacts if: needs.decide.outputs.should_publish != 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 @@ -444,6 +495,12 @@ jobs: path: | cmux-nightly-macos*.dmg appcast.xml + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json appcast-universal.xml if-no-files-found: error @@ -477,6 +534,12 @@ jobs: cmux-nightly-macos-${{ github.run_id }}*.dmg cmux-nightly-macos.dmg appcast.xml + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json appcast-universal.xml overwrite_files: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff6b33b1..e5601d05 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,13 +8,16 @@ on: permissions: contents: write + attestations: write + id-token: write env: CREATE_DMG_VERSION: 8.0.0 jobs: build-sign-notarize: - runs-on: depot-macos-latest + runs-on: warp-macos-15-arm64-6x + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -99,8 +102,18 @@ jobs: - name: Install build deps if: steps.guard_release_assets.outputs.skip_all != 'true' run: | - if ! command -v zig >/dev/null 2>&1; then - brew install zig + ZIG_REQUIRED="0.15.2" + if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then + echo "zig ${ZIG_REQUIRED} already installed" + else + echo "Installing zig ${ZIG_REQUIRED} from tarball" + curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz + tar xf /tmp/zig.tar.xz -C /tmp + sudo mkdir -p /usr/local/bin /usr/local/lib + sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig + sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig + export PATH="/usr/local/bin:$PATH" + zig version fi npm install --global "create-dmg@${CREATE_DMG_VERSION}" @@ -117,6 +130,12 @@ jobs: key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} restore-keys: spm- + - name: Setup Go + if: steps.guard_release_assets.outputs.skip_all != 'true' + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: daemon/remote/go.mod + - name: Derive Sparkle public key from private key if: steps.guard_release_assets.outputs.skip_all != 'true' env: @@ -137,6 +156,21 @@ jobs: -clonedSourcePackagesDirPath .spm-cache \ CODE_SIGNING_ALLOWED=NO build + - name: Build remote daemon release assets and inject manifest + if: steps.guard_release_assets.outputs.skip_all != 'true' + run: | + set -euo pipefail + APP_PLIST="build/Build/Products/Release/cmux.app/Contents/Info.plist" + APP_VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PLIST") + ./scripts/build_remote_daemon_release_assets.sh \ + --version "$APP_VERSION" \ + --release-tag "$GITHUB_REF_NAME" \ + --repo "manaflow-ai/cmux" \ + --output-dir "remote-daemon-assets" + MANIFEST_JSON="$(python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1], encoding="utf-8")), separators=(",",":")))' remote-daemon-assets/cmuxd-remote-manifest.json)" + plutil -remove CMUXRemoteDaemonManifestJSON "$APP_PLIST" >/dev/null 2>&1 || true + plutil -insert CMUXRemoteDaemonManifestJSON -string "$MANIFEST_JSON" "$APP_PLIST" + - name: Run CLI version memory guard regression if: steps.guard_release_assets.outputs.skip_all != 'true' run: | @@ -282,6 +316,18 @@ jobs: fi ./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.xml + - name: Attest remote daemon release assets + if: steps.guard_release_assets.outputs.skip_all != 'true' + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: | + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json + - name: Upload release asset if: steps.guard_release_assets.outputs.skip_upload != 'true' uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 @@ -289,6 +335,12 @@ jobs: files: | cmux-macos.dmg appcast.xml + remote-daemon-assets/cmuxd-remote-darwin-arm64 + remote-daemon-assets/cmuxd-remote-darwin-amd64 + remote-daemon-assets/cmuxd-remote-linux-arm64 + remote-daemon-assets/cmuxd-remote-linux-amd64 + remote-daemon-assets/cmuxd-remote-checksums.txt + remote-daemon-assets/cmuxd-remote-manifest.json generate_release_notes: true overwrite_files: false diff --git a/.github/workflows/test-depot.yml b/.github/workflows/test-depot.yml index 536c5a14..c6edb871 100644 --- a/.github/workflows/test-depot.yml +++ b/.github/workflows/test-depot.yml @@ -28,7 +28,8 @@ on: jobs: tests: - runs-on: depot-macos-latest + runs-on: warp-macos-15-arm64-6x + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -84,8 +85,18 @@ jobs: - name: Install zig run: | - if ! command -v zig >/dev/null 2>&1; then - brew install zig + ZIG_REQUIRED="0.15.2" + if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then + echo "zig ${ZIG_REQUIRED} already installed" + else + echo "Installing zig ${ZIG_REQUIRED} from tarball" + curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz + tar xf /tmp/zig.tar.xz -C /tmp + sudo mkdir -p /usr/local/bin /usr/local/lib + sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig + sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig + export PATH="/usr/local/bin:$PATH" + zig version fi - name: Create virtual display diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index a61e5eee..54d145bd 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -20,17 +20,18 @@ on: default: true type: boolean runner: - description: "Runner OS (macos-15 or macos-26)" + description: "Runner OS (Depot runners for GUI activation support)" required: false - default: "macos-15" + default: "depot-macos-latest" type: choice options: - - macos-15 - - macos-26 + - depot-macos-latest + - depot-macos-14 jobs: e2e: - runs-on: ${{ inputs.runner || 'macos-15' }} + runs-on: ${{ inputs.runner || 'depot-macos-latest' }} + timeout-minutes: 20 env: TEST_REF: ${{ inputs.ref || github.ref }} steps: @@ -92,8 +93,18 @@ jobs: - name: Install zig run: | - if ! command -v zig >/dev/null 2>&1; then - brew install zig + ZIG_REQUIRED="0.15.2" + if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then + echo "zig ${ZIG_REQUIRED} already installed" + else + echo "Installing zig ${ZIG_REQUIRED} from tarball" + curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-aarch64-macos-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz + tar xf /tmp/zig.tar.xz -C /tmp + sudo mkdir -p /usr/local/bin /usr/local/lib + sudo cp -f /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/zig /usr/local/bin/zig + sudo cp -rf /tmp/zig-aarch64-macos-${ZIG_REQUIRED}/lib /usr/local/lib/zig + export PATH="/usr/local/bin:$PATH" + zig version fi - name: Create virtual display @@ -161,8 +172,8 @@ jobs: uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 with: path: .ci-source-packages - key: spm-${{ inputs.runner || 'macos-15' }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} - restore-keys: spm-${{ inputs.runner || 'macos-15' }}- + key: spm-${{ inputs.runner || 'depot-macos-latest' }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: spm-${{ inputs.runner || 'depot-macos-latest' }}- - name: Resolve Swift packages run: | diff --git a/CLAUDE.md b/CLAUDE.md index 0fcbfce3..8a8e4e0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,8 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug - Untagged Debug app: `/tmp/cmux-debug.log` - Tagged Debug app (`./scripts/reload.sh --tag `): `/tmp/cmux-debug-.log` - `reload.sh` writes the current path to `/tmp/cmux-last-debug-log-path` +- `reload.sh` writes the selected dev CLI path to `/tmp/cmux-last-cli-path` +- `reload.sh` updates `/tmp/cmux-cli` and `$HOME/.local/bin/cmux-dev` to that CLI - Implementation: `vendor/bonsplit/Sources/Bonsplit/Public/DebugEventLog.swift` - Free function `dlog("message")` — logs with timestamp and appends to file in real time diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 8831f409..c6495bbb 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,5 +1,12 @@ import Foundation +import CryptoKit import Darwin +#if canImport(LocalAuthentication) +import LocalAuthentication +#endif +#if canImport(Security) +import Security +#endif #if canImport(Sentry) import Sentry #endif @@ -521,18 +528,23 @@ enum CLIIDFormat: String { } } -private enum SocketPasswordResolver { +enum SocketPasswordResolver { + private static let service = "com.cmuxterm.app.socket-control" + private static let account = "local-socket-password" private static let directoryName = "cmux" private static let fileName = "socket-control-password" - static func resolve(explicit: String?) -> String? { + static func resolve(explicit: String?, socketPath: String) -> String? { if let explicit = normalized(explicit) { return explicit } if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]) { return env } - return loadFromFile() + if let filePassword = loadFromFile() { + return filePassword + } + return loadFromKeychain(socketPath: socketPath) } private static func normalized(_ value: String?) -> String? { @@ -556,6 +568,89 @@ private enum SocketPasswordResolver { } return normalized(value) } + + static func keychainServices( + socketPath: String, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> [String] { + guard let scope = keychainScope(socketPath: socketPath, environment: environment) else { + return [service] + } + return ["\(service).\(scope)", service] + } + + private static func keychainScope( + socketPath: String, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String? { + if let tag = normalized(environment["CMUX_TAG"]) { + let scoped = sanitizeScope(tag) + if !scoped.isEmpty { + return scoped + } + } + + let candidate = URL(fileURLWithPath: socketPath).lastPathComponent + let prefixes = ["cmux-debug-", "cmux-"] + for prefix in prefixes { + guard candidate.hasPrefix(prefix), candidate.hasSuffix(".sock") else { continue } + let start = candidate.index(candidate.startIndex, offsetBy: prefix.count) + let end = candidate.index(candidate.endIndex, offsetBy: -".sock".count) + guard start < end else { continue } + let rawScope = String(candidate[start.. String { + let lowered = raw.lowercased() + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-")) + let mappedScalars = lowered.unicodeScalars.map { scalar -> Character in + allowed.contains(scalar) ? Character(scalar) : "." + } + var normalizedScope = String(mappedScalars) + normalizedScope = normalizedScope.replacingOccurrences( + of: "\\.+", + with: ".", + options: .regularExpression + ) + normalizedScope = normalizedScope.trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return normalizedScope + } + + private static func loadFromKeychain(socketPath: String) -> String? { + for service in keychainServices(socketPath: socketPath) { + let authContext = LAContext() + authContext.interactionNotAllowed = true + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + // Never trigger keychain UI from CLI commands; fail fast instead. + kSecUseAuthenticationContext as String: authContext, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound || status == errSecInteractionNotAllowed || status == errSecAuthFailed { + continue + } + guard status == errSecSuccess else { + continue + } + guard let data = result as? Data, + let password = String(data: data, encoding: .utf8) else { + continue + } + return password + } + return nil + } } private enum CLISocketPathSource { @@ -747,15 +842,8 @@ private enum CLISocketPathResolver { final class SocketClient { private let path: String private var socketFD: Int32 = -1 - private static let connectRetryWindowSeconds: TimeInterval = 2.0 - private static let connectRetryIntervalSeconds: TimeInterval = 0.1 - private static let retriableConnectErrnos: Set = [ - ENOENT, - ECONNREFUSED, - EAGAIN, - EINTR - ] private static let defaultResponseTimeoutSeconds: TimeInterval = 15.0 + private static let multilineResponseIdleTimeoutSeconds: TimeInterval = 0.12 private static let responseTimeoutSeconds: TimeInterval = { let env = ProcessInfo.processInfo.environment if let raw = env["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"], @@ -770,71 +858,13 @@ final class SocketClient { self.path = path } + var socketPath: String { + path + } + func connect() throws { if socketFD >= 0 { return } - - let deadline = Date().addingTimeInterval(Self.connectRetryWindowSeconds) - var lastError: CLIError? - - while true { - // Verify socket is owned by the current user to prevent fake-socket attacks. - var st = stat() - guard stat(path, &st) == 0 else { - let error = CLIError(message: "Socket not found at \(path)") - lastError = error - if errno == ENOENT, Date() < deadline { - Thread.sleep(forTimeInterval: Self.connectRetryIntervalSeconds) - continue - } - throw error - } - guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { - throw CLIError(message: "Path exists at \(path) but is not a Unix socket") - } - guard st.st_uid == getuid() else { - throw CLIError(message: "Socket at \(path) is not owned by the current user — refusing to connect") - } - - socketFD = socket(AF_UNIX, SOCK_STREAM, 0) - if socketFD < 0 { - throw CLIError(message: "Failed to create socket") - } - - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - let maxLength = MemoryLayout.size(ofValue: addr.sun_path) - path.withCString { ptr in - withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in - let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) - strncpy(buf, ptr, maxLength - 1) - } - } - - let result = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout.size)) - } - } - if result == 0 { - return - } - - let connectErrno = errno - Darwin.close(socketFD) - socketFD = -1 - - let error = CLIError( - message: "Failed to connect to socket at \(path) (\(String(cString: strerror(connectErrno))), errno \(connectErrno))" - ) - lastError = error - if Self.retriableConnectErrnos.contains(connectErrno), Date() < deadline { - Thread.sleep(forTimeInterval: Self.connectRetryIntervalSeconds) - continue - } - throw error - } - - throw lastError ?? CLIError(message: "Failed to connect to socket at \(path)") + try connectOnce() } func close() { @@ -856,27 +886,27 @@ final class SocketClient { var data = Data() var sawNewline = false - let start = Date() while true { - var pollFD = pollfd(fd: socketFD, events: Int16(POLLIN), revents: 0) - let ready = poll(&pollFD, 1, 100) - if ready < 0 { - throw CLIError(message: "Socket read error") - } - if ready == 0 { - if sawNewline { - break - } - if Date().timeIntervalSince(start) > Self.responseTimeoutSeconds { - throw CLIError(message: "Command timed out") - } - continue - } + try configureReceiveTimeout( + sawNewline ? Self.multilineResponseIdleTimeoutSeconds : Self.responseTimeoutSeconds + ) var buffer = [UInt8](repeating: 0, count: 8192) let count = Darwin.read(socketFD, &buffer, buffer.count) - if count <= 0 { + if count < 0 { + if errno == EINTR { + continue + } + if errno == EAGAIN || errno == EWOULDBLOCK { + if sawNewline { + break + } + throw CLIError(message: "Command timed out") + } + throw CLIError(message: "Socket read error") + } + if count == 0 { break } data.append(buffer, count: count) @@ -894,6 +924,189 @@ final class SocketClient { return response } + private func connectOnce() throws { + // Verify socket is owned by the current user to prevent fake-socket attacks. + var st = stat() + guard stat(path, &st) == 0 else { + throw CLIError(message: "Socket not found at \(path)") + } + guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { + throw CLIError(message: "Path exists at \(path) but is not a Unix socket") + } + guard st.st_uid == getuid() else { + throw CLIError(message: "Socket at \(path) is not owned by the current user — refusing to connect") + } + + socketFD = socket(AF_UNIX, SOCK_STREAM, 0) + if socketFD < 0 { + throw CLIError(message: "Failed to create socket") + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxLength = MemoryLayout.size(ofValue: addr.sun_path) + path.withCString { ptr in + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + strncpy(buf, ptr, maxLength - 1) + } + } + + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + if result == 0 { + return + } + + let connectErrno = errno + Darwin.close(socketFD) + socketFD = -1 + throw CLIError( + message: "Failed to connect to socket at \(path) (\(String(cString: strerror(connectErrno))), errno \(connectErrno))" + ) + } + + private func configureReceiveTimeout(_ timeout: TimeInterval) throws { + var interval = timeval( + tv_sec: Int(timeout.rounded(.down)), + tv_usec: __darwin_suseconds_t((timeout - floor(timeout)) * 1_000_000) + ) + let result = withUnsafePointer(to: &interval) { ptr in + setsockopt( + socketFD, + SOL_SOCKET, + SO_RCVTIMEO, + ptr, + socklen_t(MemoryLayout.size) + ) + } + guard result == 0 else { + throw CLIError(message: "Failed to configure socket receive timeout") + } + } + + static func waitForConnectableSocket(path: String, timeout: TimeInterval) throws -> SocketClient { + let client = SocketClient(path: path) + if (try? client.connect()) != nil { + return client + } + + guard let watchDirectory = existingWatchDirectory(forPath: path) else { + throw CLIError(message: "cmux app did not start in time (socket not found at \(path))") + } + let watchFD = open(watchDirectory, O_EVTONLY) + guard watchFD >= 0 else { + throw CLIError(message: "cmux app did not start in time (socket not found at \(path))") + } + + let queue = DispatchQueue(label: "com.cmux.cli.socket-watch.\(UUID().uuidString)") + let semaphore = DispatchSemaphore(value: 0) + var connected = false + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: watchFD, + eventMask: [.write, .rename, .delete, .attrib, .extend, .link], + queue: queue + ) + + func attemptConnect() { + guard !connected else { return } + if (try? client.connect()) != nil { + connected = true + semaphore.signal() + } + } + + source.setEventHandler { + attemptConnect() + } + source.setCancelHandler { + Darwin.close(watchFD) + } + source.resume() + queue.async { + attemptConnect() + } + + guard semaphore.wait(timeout: .now() + timeout) == .success else { + source.cancel() + client.close() + throw CLIError(message: "cmux app did not start in time (socket not found at \(path))") + } + + source.cancel() + return client + } + + static func waitForFilesystemPath(_ path: String, timeout: TimeInterval) throws { + if FileManager.default.fileExists(atPath: path) { + return + } + + guard let watchDirectory = existingWatchDirectory(forPath: path) else { + throw CLIError(message: "Timed out waiting for \(path)") + } + let watchFD = open(watchDirectory, O_EVTONLY) + guard watchFD >= 0 else { + throw CLIError(message: "Timed out waiting for \(path)") + } + + let queue = DispatchQueue(label: "com.cmux.cli.path-watch.\(UUID().uuidString)") + let semaphore = DispatchSemaphore(value: 0) + var found = false + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: watchFD, + eventMask: [.write, .rename, .delete, .attrib, .extend, .link], + queue: queue + ) + + func checkPath() { + guard !found else { return } + if FileManager.default.fileExists(atPath: path) { + found = true + semaphore.signal() + } + } + + source.setEventHandler { + checkPath() + } + source.setCancelHandler { + Darwin.close(watchFD) + } + source.resume() + queue.async { + checkPath() + } + + guard semaphore.wait(timeout: .now() + timeout) == .success else { + source.cancel() + throw CLIError(message: "Timed out waiting for \(path)") + } + + source.cancel() + } + + private static func existingWatchDirectory(forPath path: String) -> String? { + let fileManager = FileManager.default + var candidate = URL(fileURLWithPath: (path as NSString).deletingLastPathComponent, isDirectory: true) + + while !candidate.path.isEmpty { + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: candidate.path, isDirectory: &isDirectory), isDirectory.boolValue { + return candidate.path + } + let parent = candidate.deletingLastPathComponent() + if parent.path == candidate.path { + break + } + candidate = parent + } + return nil + } + func sendV2(method: String, params: [String: Any] = [:]) throws -> [String: Any] { let request: [String: Any] = [ "id": UUID().uuidString, @@ -939,9 +1152,152 @@ final class SocketClient { } } +struct CLIProcessResult { + let status: Int32 + let stdout: String + let stderr: String + let timedOut: Bool +} + +enum CLIProcessRunner { + static func runProcess( + executablePath: String, + arguments: [String], + stdinText: String? = nil, + timeout: TimeInterval? = nil + ) -> CLIProcessResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let stdinPipe: Pipe? + if stdinText != nil { + let pipe = Pipe() + process.standardInput = pipe + stdinPipe = pipe + } else { + stdinPipe = nil + } + + let finished = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in + finished.signal() + } + + do { + try process.run() + } catch { + return CLIProcessResult(status: 1, stdout: "", stderr: String(describing: error), timedOut: false) + } + + if let stdinText, let stdinPipe { + if let data = stdinText.data(using: .utf8) { + stdinPipe.fileHandleForWriting.write(data) + } + stdinPipe.fileHandleForWriting.closeFile() + } + + let timedOut: Bool + if let timeout { + switch finished.wait(timeout: .now() + timeout) { + case .success: + timedOut = false + case .timedOut: + timedOut = true + terminate(process: process, finished: finished) + } + } else { + finished.wait() + timedOut = false + } + + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + var stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + if timedOut { + let timeoutMessage = "process timed out" + if stderr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + stderr = timeoutMessage + } else if !stderr.contains(timeoutMessage) { + stderr += "\n\(timeoutMessage)" + } + } + + return CLIProcessResult( + status: timedOut ? 124 : process.terminationStatus, + stdout: stdout, + stderr: stderr, + timedOut: timedOut + ) + } + + private static func terminate(process: Process, finished: DispatchSemaphore) { + guard process.isRunning else { return } + process.terminate() + if finished.wait(timeout: .now() + 0.5) == .success { + return + } + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + } + _ = finished.wait(timeout: .now() + 0.5) + } +} + struct CMUXCLI { let args: [String] + private static let debugLastSocketHintPath = "/tmp/cmux-last-socket-path" + + private static func normalizedEnvValue(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private static func pathIsSocket(_ path: String) -> Bool { + var st = stat() + guard lstat(path, &st) == 0 else { return false } + return (st.st_mode & S_IFMT) == S_IFSOCK + } + + private static func debugSocketPathFromHintFile() -> String? { +#if DEBUG + guard let raw = try? String(contentsOfFile: debugLastSocketHintPath, encoding: .utf8) else { + return nil + } + guard let hinted = normalizedEnvValue(raw), + hinted.hasPrefix("/tmp/cmux-debug"), + hinted.hasSuffix(".sock"), + pathIsSocket(hinted) else { + return nil + } + return hinted +#else + return nil +#endif + } + + private static func defaultSocketPath(environment: [String: String]) -> String { + if let explicit = normalizedEnvValue(environment["CMUX_SOCKET_PATH"]) { + return explicit + } +#if DEBUG + if let hinted = debugSocketPathFromHintFile() { + return hinted + } + return "/tmp/cmux-debug.sock" +#else + return "/tmp/cmux.sock" +#endif + } + func run() throws { let processEnv = ProcessInfo.processInfo.environment let envSocketPath: String? = { @@ -1042,6 +1398,11 @@ struct CMUXCLI { return } + if command == "remote-daemon-status" { + try runRemoteDaemonStatus(commandArgs: commandArgs, jsonOutput: jsonOutput) + return + } + // If the argument looks like a path (not a known command), open a workspace there. if looksLikePath(command) { try openPath(command, socketPath: resolvedSocketPath) @@ -1129,7 +1490,11 @@ struct CMUXCLI { } defer { client.close() } - try authenticateClientIfNeeded(client, explicitPassword: socketPasswordArg) + try authenticateClientIfNeeded( + client, + explicitPassword: socketPasswordArg, + socketPath: resolvedSocketPath + ) let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg) @@ -1274,14 +1639,27 @@ struct CMUXCLI { let selected = (ws["selected"] as? Bool) == true let handle = textHandle(ws, idFormat: idFormat) let title = (ws["title"] as? String) ?? "" + let remoteTag: String = { + guard let remote = ws["remote"] as? [String: Any], + (remote["enabled"] as? Bool) == true else { + return "" + } + let state = (remote["state"] as? String) ?? "unknown" + return " [ssh:\(state)]" + }() let prefix = selected ? "* " : " " let selTag = selected ? " [selected]" : "" let titlePart = title.isEmpty ? "" : " \(title)" - print("\(prefix)\(handle)\(titlePart)\(selTag)") + print("\(prefix)\(handle)\(titlePart)\(remoteTag)\(selTag)") } } } + case "ssh": + try runSSH(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) + case "ssh-session-end": + try runSSHSessionEnd(commandArgs: commandArgs, client: client) + case "new-workspace": let (commandOpt, rem0) = parseOption(commandArgs, name: "--command") let (cwdOpt, remaining) = parseOption(rem0, name: "--cwd") @@ -1297,8 +1675,6 @@ struct CMUXCLI { let wsId = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? "" print("OK \(wsId)") if let commandText = commandOpt, !wsId.isEmpty { - // Wait for shell to initialize - Thread.sleep(forTimeInterval: 0.5) let text = unescapeSendText(commandText + "\\n") let sendParams: [String: Any] = ["text": text, "workspace_id": wsId] _ = try client.sendV2(method: "surface.send_text", params: sendParams) @@ -1473,6 +1849,18 @@ struct CMUXCLI { } } + case "debug-terminals": + let unexpected = commandArgs.filter { $0 != "--" } + if let extra = unexpected.first { + throw CLIError(message: "debug-terminals: unexpected argument '\(extra)'") + } + let payload = try client.sendV2(method: "debug.terminals") + if jsonOutput { + print(jsonString(formatIDs(payload, mode: idFormat))) + } else { + print(formatDebugTerminalsPayload(payload, idFormat: idFormat)) + } + case "trigger-flash": let tfWsFlag = optionValue(commandArgs, name: "--workspace") let workspaceArg = tfWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) @@ -1719,6 +2107,87 @@ struct CMUXCLI { let response = try sendV1Command(socketCmd, client: client) print(response) + case "set-status": + let response = try forwardSidebarMetadataCommand( + "set_status", + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + print(response) + + case "clear-status": + let response = try forwardSidebarMetadataCommand( + "clear_status", + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + print(response) + + case "list-status": + let response = try forwardSidebarMetadataCommand( + "list_status", + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + print(response) + + case "set-progress": + let response = try forwardSidebarMetadataCommand( + "set_progress", + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + print(response) + + case "clear-progress": + let response = try forwardSidebarMetadataCommand( + "clear_progress", + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + print(response) + + case "log": + let response = try forwardSidebarMetadataCommand( + "log", + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + print(response) + + case "clear-log": + let response = try forwardSidebarMetadataCommand( + "clear_log", + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + print(response) + + case "list-log": + let response = try forwardSidebarMetadataCommand( + "list_log", + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + print(response) + + case "sidebar-state": + let response = try forwardSidebarMetadataCommand( + "sidebar_state", + commandArgs: commandArgs, + client: client, + windowOverride: windowId + ) + print(response) + case "claude-hook": cliTelemetry.breadcrumb("claude-hook.dispatch") do { @@ -1730,109 +2199,6 @@ struct CMUXCLI { throw error } - case "set-status": - let (icon, r1) = parseOption(commandArgs, name: "--icon") - let (color, r2) = parseOption(r1, name: "--color") - let (wsFlag, r3) = parseOption(r2, name: "--workspace") - guard r3.count >= 2 else { - throw CLIError(message: "set-status requires and ") - } - let key = r3[0] - let value = r3.dropFirst().joined(separator: " ") - guard !value.isEmpty else { - throw CLIError(message: "set-status requires a non-empty value") - } - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - var socketCmd = "set_status \(key) \(socketQuote(value))" - if let icon { socketCmd += " --icon=\(socketQuote(icon))" } - if let color { socketCmd += " --color=\(socketQuote(color))" } - socketCmd += " --tab=\(wsId)" - let response = try sendV1Command(socketCmd, client: client) - print(response) - - case "clear-status": - let (wsFlag, csRemaining) = parseOption(commandArgs, name: "--workspace") - guard let key = csRemaining.first else { - throw CLIError(message: "clear-status requires a ") - } - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("clear_status \(key) --tab=\(wsId)", client: client) - print(response) - - case "list-status": - let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("list_status --tab=\(wsId)", client: client) - print(response) - - case "set-progress": - let (label, spR1) = parseOption(commandArgs, name: "--label") - let (wsFlag, spR2) = parseOption(spR1, name: "--workspace") - guard let valueStr = spR2.first else { - throw CLIError(message: "set-progress requires a progress value (0.0-1.0)") - } - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - var socketCmd = "set_progress \(valueStr)" - if let label { socketCmd += " --label=\(socketQuote(label))" } - socketCmd += " --tab=\(wsId)" - let response = try sendV1Command(socketCmd, client: client) - print(response) - - case "clear-progress": - let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("clear_progress --tab=\(wsId)", client: client) - print(response) - - case "log": - let (level, r1) = parseOption(commandArgs, name: "--level") - let (source, r2) = parseOption(r1, name: "--source") - let (wsFlag, r3) = parseOption(r2, name: "--workspace") - // Strip leading "--" separator if present - let positional = r3.first == "--" ? Array(r3.dropFirst()) : r3 - let message = positional.joined(separator: " ") - guard !message.isEmpty else { - throw CLIError(message: "log requires a message") - } - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - var socketCmd = "log" - if let level { socketCmd += " --level=\(level)" } - if let source { socketCmd += " --source=\(socketQuote(source))" } - socketCmd += " --tab=\(wsId) -- \(socketQuote(message))" - let response = try sendV1Command(socketCmd, client: client) - print(response) - - case "clear-log": - let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("clear_log --tab=\(wsId)", client: client) - print(response) - - case "list-log": - let (limitStr, r1) = parseOption(commandArgs, name: "--limit") - let (wsFlag, _) = parseOption(r1, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - var socketCmd = "list_log" - if let limitStr { socketCmd += " --limit=\(limitStr)" } - socketCmd += " --tab=\(wsId)" - let response = try sendV1Command(socketCmd, client: client) - print(response) - - case "sidebar-state": - let (wsFlag, _) = parseOption(commandArgs, name: "--workspace") - let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) - let wsId = try resolveWorkspaceId(workspaceArg, client: client) - let response = try sendV1Command("sidebar_state --tab=\(wsId)", client: client) - print(response) - case "set-app-focus": guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") } let response = try sendV1Command("set_app_focus \(value)", client: client) @@ -2098,24 +2464,10 @@ struct CMUXCLI { if (try? client.connect()) == nil { client.close() try launchApp() - // Poll until socket accepts connections (up to 10 seconds) - let pollClient = SocketClient(path: socketPath) - var connected = false - for _ in 0..<100 { - if (try? pollClient.connect()) != nil { - connected = true - break - } - pollClient.close() - Thread.sleep(forTimeInterval: 0.1) - } - guard connected else { - throw CLIError(message: "cmux app did not start in time (socket not found at \(socketPath))") - } - // Use pollClient since it's connected - defer { pollClient.close() } + let launchedClient = try SocketClient.waitForConnectableSocket(path: socketPath, timeout: 10) + defer { launchedClient.close() } let params: [String: Any] = ["cwd": directory] - let response = try pollClient.sendV2(method: "workspace.create", params: params) + let response = try launchedClient.sendV2(method: "workspace.create", params: params) let wsRef = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? "" if !wsRef.isEmpty { print("OK \(wsRef)") @@ -2236,31 +2588,33 @@ struct CMUXCLI { if launchIfNeeded && (try? client.connect()) == nil { client.close() try launchApp() - - let pollClient = SocketClient(path: socketPath) - var connected = false - for _ in 0..<100 { - if (try? pollClient.connect()) != nil { - connected = true - break - } - pollClient.close() - Thread.sleep(forTimeInterval: 0.1) - } - guard connected else { - throw CLIError(message: "cmux app did not start in time (socket not found at \(socketPath))") - } - try authenticateClientIfNeeded(pollClient, explicitPassword: explicitPassword) - return pollClient + let launchedClient = try SocketClient.waitForConnectableSocket(path: socketPath, timeout: 10) + try authenticateClientIfNeeded( + launchedClient, + explicitPassword: explicitPassword, + socketPath: socketPath + ) + return launchedClient } try client.connect() - try authenticateClientIfNeeded(client, explicitPassword: explicitPassword) + try authenticateClientIfNeeded( + client, + explicitPassword: explicitPassword, + socketPath: socketPath + ) return client } - private func authenticateClientIfNeeded(_ client: SocketClient, explicitPassword: String?) throws { - if let socketPassword = SocketPasswordResolver.resolve(explicit: explicitPassword) { + private func authenticateClientIfNeeded( + _ client: SocketClient, + explicitPassword: String?, + socketPath: String + ) throws { + if let socketPassword = SocketPasswordResolver.resolve( + explicit: explicitPassword, + socketPath: socketPath + ) { let authResponse = try client.send(command: "auth \(socketPassword)") if authResponse.hasPrefix("ERROR:"), !authResponse.contains("Unknown command 'auth'") { @@ -2285,14 +2639,6 @@ struct CMUXCLI { process.waitUntilExit() } - private func sendV1Command(_ command: String, client: SocketClient) throws -> String { - let response = try client.send(command: command) - if response.hasPrefix("ERROR:") { - throw CLIError(message: response) - } - return response - } - private func resolvedIDFormat(jsonOutput: Bool, raw: String?) throws -> CLIIDFormat { _ = jsonOutput if let parsed = try CLIIDFormat.parse(raw) { @@ -2301,6 +2647,14 @@ struct CMUXCLI { return .refs } + private func sendV1Command(_ command: String, client: SocketClient) throws -> String { + let response = try client.send(command: command) + if response.hasPrefix("ERROR:") { + throw CLIError(message: response) + } + return response + } + private func formatIDs(_ object: Any, mode: CLIIDFormat) -> Any { switch object { case let dict as [String: Any]: @@ -2364,6 +2718,14 @@ struct CMUXCLI { return nil } + private func doubleFromAny(_ value: Any?) -> Double? { + if let d = value as? Double { return d } + if let f = value as? Float { return Double(f) } + if let n = value as? NSNumber { return n.doubleValue } + if let s = value as? String { return Double(s) } + return nil + } + private func parseBoolString(_ raw: String) -> Bool? { switch raw.lowercased() { case "1", "true", "yes", "on": @@ -2626,6 +2988,160 @@ struct CMUXCLI { } } + private func debugString(_ value: Any?) -> String? { + guard let value, !(value is NSNull) else { return nil } + if let string = value as? String { + return string + } + if let number = value as? NSNumber { + return number.stringValue + } + return String(describing: value) + } + + private func debugBool(_ value: Any?) -> Bool? { + if let bool = value as? Bool { + return bool + } + if let number = value as? NSNumber { + return number.boolValue + } + if let string = value as? String { + return parseBoolString(string) + } + return nil + } + + private func debugFlag(_ value: Any?) -> String { + guard let bool = debugBool(value) else { return "nil" } + return bool ? "1" : "0" + } + + private func formatDebugRect(_ value: Any?) -> String? { + guard let rect = value as? [String: Any], + let x = doubleFromAny(rect["x"]), + let y = doubleFromAny(rect["y"]), + let width = doubleFromAny(rect["width"]), + let height = doubleFromAny(rect["height"]) else { + return nil + } + return String(format: "{%.1f,%.1f %.1fx%.1f}", x, y, width, height) + } + + private func formatDebugPorts(_ value: Any?) -> String { + guard let array = value as? [Any], !array.isEmpty else { return "[]" } + let ports = array + .compactMap { intFromAny($0) } + .map(String.init) + return ports.isEmpty ? "[]" : ports.joined(separator: ",") + } + + private func formatDebugList(_ value: Any?) -> String? { + guard let array = value as? [Any], !array.isEmpty else { return nil } + let items = array.compactMap { item -> String? in + if let string = item as? String { + return string + } + return debugString(item) + } + guard !items.isEmpty else { return nil } + return items.joined(separator: ">") + } + + private func formatDebugAge(_ value: Any?) -> String? { + guard let seconds = doubleFromAny(value) else { return nil } + return String(format: "%.3fs", seconds) + } + + private func formatDebugTerminalsPayload(_ payload: [String: Any], idFormat: CLIIDFormat) -> String { + let terminals = payload["terminals"] as? [[String: Any]] ?? [] + guard !terminals.isEmpty else { return "No terminal surfaces" } + + return terminals.map { item in + let index = intFromAny(item["index"]) ?? 0 + let surface = formatHandle(item, kind: "surface", idFormat: idFormat) ?? "?" + let window = formatHandle(item, kind: "window", idFormat: idFormat) ?? "nil" + let workspace = formatHandle(item, kind: "workspace", idFormat: idFormat) ?? "nil" + let pane = formatHandle(item, kind: "pane", idFormat: idFormat) ?? "nil" + let bonsplitTab = debugString(item["bonsplit_tab_id"]) ?? "nil" + let lastKnownWorkspace = debugString(item["last_known_workspace_ref"]) ?? debugString(item["last_known_workspace_id"]) ?? "nil" + let titleSuffix: String = { + guard let title = debugString(item["surface_title"]), !title.isEmpty else { return "" } + let escaped = title.replacingOccurrences(of: "\"", with: "\\\"") + return " \"\(escaped)\"" + }() + let branchLabel: String = { + guard let branch = debugString(item["git_branch"]), !branch.isEmpty else { return "nil" } + return debugBool(item["git_dirty"]) == true ? "\(branch)*" : branch + }() + let teardownLabel: String = { + guard debugBool(item["teardown_requested"]) == true else { return "nil" } + let reason = debugString(item["teardown_requested_reason"]) ?? "requested" + let age = formatDebugAge(item["teardown_requested_age_seconds"]) ?? "unknown" + return "\(reason)@\(age)" + }() + let portalHostLabel: String = { + let hostId = debugString(item["portal_host_id"]) ?? "nil" + let area = doubleFromAny(item["portal_host_area"]).map { String(format: "%.1f", $0) } ?? "nil" + let inWindow = debugFlag(item["portal_host_in_window"]) + return "\(hostId)/win=\(inWindow)/area=\(area)" + }() + let windowMetaLabel: String = { + let title = debugString(item["window_title"]) ?? "nil" + let windowClass = debugString(item["window_class"]) ?? "nil" + let controllerClass = debugString(item["window_controller_class"]) ?? "nil" + let delegateClass = debugString(item["window_delegate_class"]) ?? "nil" + return "title=\(title) class=\(windowClass) controller=\(controllerClass) delegate=\(delegateClass)" + }() + + let line1 = + "[\(index)] \(surface)\(titleSuffix) " + + "mapped=\(debugFlag(item["mapped"])) tree=\(debugFlag(item["tree_visible"])) " + + "window=\(window) workspace=\(workspace) pane=\(pane) bonsplitTab=\(bonsplitTab) " + + "ctx=\(debugString(item["surface_context"]) ?? "nil")" + + let line2 = + " runtime=\(debugFlag(item["runtime_surface_ready"])) " + + "focused=\(debugFlag(item["surface_focused"])) " + + "selected=\(debugFlag(item["surface_selected_in_pane"])) " + + "pinned=\(debugFlag(item["surface_pinned"])) " + + "terminal=\(debugString(item["terminal_object_ptr"]) ?? "nil") " + + "hosted=\(debugString(item["hosted_view_ptr"]) ?? "nil") " + + "ghostty=\(debugString(item["ghostty_surface_ptr"]) ?? "nil") " + + "portal=\(debugString(item["portal_binding_state"]) ?? "nil")#\(debugString(item["portal_binding_generation"]) ?? "nil") " + + "teardown=\(teardownLabel)" + + let line3 = + " tty=\(debugString(item["tty"]) ?? "nil") " + + "cwd=\(debugString(item["current_directory"]) ?? debugString(item["requested_working_directory"]) ?? "nil") " + + "branch=\(branchLabel) " + + "ports=\(formatDebugPorts(item["listening_ports"])) " + + "visible=\(debugFlag(item["hosted_view_visible_in_ui"])) " + + "inWindow=\(debugFlag(item["hosted_view_in_window"])) " + + "superview=\(debugFlag(item["hosted_view_has_superview"])) " + + "hidden=\(debugFlag(item["hosted_view_hidden"])) " + + "ancestorHidden=\(debugFlag(item["hosted_view_hidden_or_ancestor_hidden"])) " + + "firstResponder=\(debugFlag(item["surface_view_first_responder"])) " + + "windowNum=\(debugString(item["window_number"]) ?? "nil") " + + "windowKey=\(debugFlag(item["window_key"])) " + + "frame=\(formatDebugRect(item["hosted_view_frame_in_window"]) ?? "nil")" + + let line4 = + " created=\(formatDebugAge(item["surface_age_seconds"]) ?? "nil") " + + "runtimeCreated=\(formatDebugAge(item["runtime_surface_age_seconds"]) ?? "nil") " + + "lastWorkspace=\(lastKnownWorkspace) " + + "initialCommand=\(debugString(item["initial_command"]) ?? "nil") " + + "portalHost=\(portalHostLabel)" + + let line5 = + " window=\(windowMetaLabel) " + + "chain=\(formatDebugList(item["hosted_view_superview_chain"]) ?? "nil")" + + return [line1, line2, line3, line4, line5].joined(separator: "\n") + } + .joined(separator: "\n") + } + private func runMoveSurface( commandArgs: [String], client: SocketClient, @@ -2947,6 +3463,952 @@ struct CMUXCLI { windowOverride: windowOverride ) } + struct SSHCommandOptions { + let destination: String + let port: Int? + let identityFile: String? + let workspaceName: String? + let sshOptions: [String] + let extraArguments: [String] + let localSocketPath: String + let remoteRelayPort: Int + } + + private struct RemoteDaemonManifest: Decodable { + struct Entry: Decodable { + let goOS: String + let goArch: String + let assetName: String + let downloadURL: String + let sha256: String + } + + let schemaVersion: Int + let appVersion: String + let releaseTag: String + let releaseURL: String + let checksumsAssetName: String + let checksumsURL: String + let entries: [Entry] + + func entry(goOS: String, goArch: String) -> Entry? { + entries.first { $0.goOS == goOS && $0.goArch == goArch } + } + } + + private func generateRemoteRelayPort() -> Int { + // Random port in the ephemeral range (49152-65535) + Int.random(in: 49152...65535) + } + + private func randomHex(byteCount: Int) throws -> String { + var bytes = [UInt8](repeating: 0, count: byteCount) + let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + guard status == errSecSuccess else { + throw CLIError(message: "failed to generate SSH relay credential") + } + return bytes.map { String(format: "%02x", $0) }.joined() + } + + private func runSSH( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat + ) throws { + let sshStartedAt = Date() + // Use the socket path from this invocation (supports --socket overrides). + let localSocketPath = client.socketPath + let remoteRelayPort = generateRemoteRelayPort() + let relayID = UUID().uuidString.lowercased() + let relayToken = try randomHex(byteCount: 32) + let sshOptions = try parseSSHCommandOptions(commandArgs, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort) + func logSSHTiming(_ stage: String, extra: String = "") { + let elapsedMs = Int(Date().timeIntervalSince(sshStartedAt) * 1000) + let suffix = extra.isEmpty ? "" : " \(extra)" + cliDebugLog( + "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + + "stage=\(stage) elapsedMs=\(elapsedMs)\(suffix)" + ) + } + + logSSHTiming("parsed") + let terminfoSource = localXtermGhosttyTerminfoSource() + cliDebugLog( + "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + + "stage=terminfo elapsedMs=0 mode=deferred term=xterm-256color " + + "source=\(terminfoSource == nil ? 0 : 1)" + ) + let shellFeaturesValue = scopedGhosttyShellFeaturesValue() + let initialSSHCommand = buildSSHCommandText(sshOptions) + let remoteTerminalBootstrapScript = sshOptions.extraArguments.isEmpty + ? buildInteractiveRemoteShellScript( + remoteRelayPort: sshOptions.remoteRelayPort, + shellFeatures: shellFeaturesValue, + terminfoSource: terminfoSource + ) + : nil + let remoteTerminalSSHCommand = buildSSHCommandText( + sshOptions, + remoteBootstrapScript: remoteTerminalBootstrapScript + ) + let initialSSHStartupCommand = try buildSSHStartupCommand( + sshCommand: initialSSHCommand, + shellFeatures: "", + remoteRelayPort: sshOptions.remoteRelayPort + ) + let remoteTerminalSSHStartupCommand = try buildSSHStartupCommand( + sshCommand: remoteTerminalSSHCommand, + shellFeatures: shellFeaturesValue, + remoteRelayPort: sshOptions.remoteRelayPort + ) + let remoteSSHOptions = effectiveSSHOptions( + sshOptions.sshOptions, + remoteRelayPort: sshOptions.remoteRelayPort + ) + + cliDebugLog( + "cli.ssh.start target=\(sshOptions.destination) port=\(sshOptions.port.map(String.init) ?? "nil") " + + "relayPort=\(sshOptions.remoteRelayPort) localSocket=\(sshOptions.localSocketPath) " + + "controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " + + "workspaceName=\(sshOptions.workspaceName?.replacingOccurrences(of: " ", with: "_") ?? "nil") " + + "extraArgs=\(sshOptions.extraArguments.count)" + ) + + let workspaceCreateParams: [String: Any] = [ + "initial_command": initialSSHStartupCommand, + ] + + let workspaceCreateStartedAt = Date() + let workspaceCreate = try client.sendV2(method: "workspace.create", params: workspaceCreateParams) + guard let workspaceId = workspaceCreate["workspace_id"] as? String, !workspaceId.isEmpty else { + throw CLIError(message: "workspace.create did not return workspace_id") + } + let workspaceWindowId = (workspaceCreate["window_id"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + cliDebugLog( + "cli.ssh.workspace.created workspace=\(String(workspaceId.prefix(8))) " + + "window=\(workspaceWindowId.map { String($0.prefix(8)) } ?? "nil")" + ) + cliDebugLog( + "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + + "workspace=\(String(workspaceId.prefix(8))) stage=workspace.create elapsedMs=\(Int(Date().timeIntervalSince(workspaceCreateStartedAt) * 1000))" + ) + let configuredPayload: [String: Any] + do { + if let workspaceName = sshOptions.workspaceName?.trimmingCharacters(in: .whitespacesAndNewlines), + !workspaceName.isEmpty { + _ = try client.sendV2(method: "workspace.rename", params: [ + "workspace_id": workspaceId, + "title": workspaceName, + ]) + } + + var configureParams: [String: Any] = [ + "workspace_id": workspaceId, + "destination": sshOptions.destination, + "auto_connect": true, + ] + if let port = sshOptions.port { + configureParams["port"] = port + } + if let identityFile = normalizedSSHIdentityPath(sshOptions.identityFile) { + configureParams["identity_file"] = identityFile + } + if !remoteSSHOptions.isEmpty { + configureParams["ssh_options"] = remoteSSHOptions + } + if sshOptions.remoteRelayPort > 0 { + configureParams["relay_port"] = sshOptions.remoteRelayPort + configureParams["relay_id"] = relayID + configureParams["relay_token"] = relayToken + configureParams["local_socket_path"] = sshOptions.localSocketPath + } + configureParams["terminal_startup_command"] = remoteTerminalSSHStartupCommand + + cliDebugLog( + "cli.ssh.remote.configure workspace=\(String(workspaceId.prefix(8))) " + + "target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + + "controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " + + "sshOptions=\(remoteSSHOptions.joined(separator: "|"))" + ) + let configureStartedAt = Date() + configuredPayload = try client.sendV2(method: "workspace.remote.configure", params: configureParams) + var selectParams: [String: Any] = ["workspace_id": workspaceId] + if let workspaceWindowId, !workspaceWindowId.isEmpty { + selectParams["window_id"] = workspaceWindowId + } + _ = try client.sendV2(method: "workspace.select", params: selectParams) + let remoteState = ((configuredPayload["remote"] as? [String: Any])?["state"] as? String) ?? "unknown" + cliDebugLog( + "cli.ssh.remote.configure.ok workspace=\(String(workspaceId.prefix(8))) state=\(remoteState)" + ) + cliDebugLog( + "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + + "workspace=\(String(workspaceId.prefix(8))) stage=workspace.remote.configure elapsedMs=\(Int(Date().timeIntervalSince(configureStartedAt) * 1000))" + ) + } catch { + cliDebugLog( + "cli.ssh.remote.configure.error workspace=\(String(workspaceId.prefix(8))) error=\(String(describing: error))" + ) + do { + _ = try client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId]) + } catch { + let warning = "Warning: failed to rollback workspace \(workspaceId): \(error)\n" + FileHandle.standardError.write(Data(warning.utf8)) + } + throw error + } + + var payload = configuredPayload + + payload["ssh_command"] = initialSSHCommand + payload["ssh_startup_command"] = initialSSHStartupCommand + payload["ssh_terminal_command"] = remoteTerminalSSHCommand + payload["ssh_terminal_startup_command"] = remoteTerminalSSHStartupCommand + payload["ssh_env_overrides"] = [ + "GHOSTTY_SHELL_FEATURES": shellFeaturesValue, + ] + payload["remote_relay_port"] = remoteRelayPort + logSSHTiming("complete", extra: "workspace=\(String(workspaceId.prefix(8)))") + if jsonOutput { + print(jsonString(formatIDs(payload, mode: idFormat))) + } else { + let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) ?? workspaceId + let remote = payload["remote"] as? [String: Any] + let state = (remote?["state"] as? String) ?? "unknown" + print("OK workspace=\(workspaceHandle) target=\(sshOptions.destination) state=\(state)") + } + } + + private func parseSSHCommandOptions(_ commandArgs: [String], localSocketPath: String = "", remoteRelayPort: Int = 0) throws -> SSHCommandOptions { + var destination: String? + var port: Int? + var identityFile: String? + var workspaceName: String? + var sshOptions: [String] = [] + var extraArguments: [String] = [] + + var passthrough = false + var index = 0 + while index < commandArgs.count { + let arg = commandArgs[index] + if passthrough { + extraArguments.append(arg) + index += 1 + continue + } + + switch arg { + case "--": + passthrough = true + index += 1 + case "--port": + guard index + 1 < commandArgs.count else { + throw CLIError(message: "ssh: --port requires a value") + } + guard let parsed = Int(commandArgs[index + 1]), parsed > 0, parsed <= 65535 else { + throw CLIError(message: "ssh: --port must be 1-65535") + } + port = parsed + index += 2 + case "--identity": + guard index + 1 < commandArgs.count else { + throw CLIError(message: "ssh: --identity requires a path") + } + identityFile = commandArgs[index + 1] + index += 2 + case "--name": + guard index + 1 < commandArgs.count else { + throw CLIError(message: "ssh: --name requires a workspace title") + } + workspaceName = commandArgs[index + 1] + index += 2 + case "--ssh-option": + guard index + 1 < commandArgs.count else { + throw CLIError(message: "ssh: --ssh-option requires a value") + } + let value = commandArgs[index + 1].trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty { + sshOptions.append(value) + } + index += 2 + default: + if arg.hasPrefix("--") { + throw CLIError(message: "ssh: unknown flag '\(arg)'") + } + if destination == nil { + if arg.hasPrefix("-") { + throw CLIError( + message: "ssh: destination must be . Use --port/--identity/--ssh-option for SSH flags and `--` for remote command args." + ) + } + destination = arg + } else { + extraArguments.append(arg) + } + index += 1 + } + } + + guard let destination else { + throw CLIError(message: "ssh requires a destination (example: cmux ssh user@host)") + } + return SSHCommandOptions( + destination: destination, + port: port, + identityFile: identityFile, + workspaceName: workspaceName, + sshOptions: sshOptions, + extraArguments: extraArguments, + localSocketPath: localSocketPath, + remoteRelayPort: remoteRelayPort + ) + } + + func buildSSHCommandText( + _ options: SSHCommandOptions, + remoteBootstrapScript: String? = nil + ) -> String { + var parts = baseSSHArguments(options) + let trimmedRemoteBootstrap = remoteBootstrapScript? + .trimmingCharacters(in: .whitespacesAndNewlines) + + if options.extraArguments.isEmpty { + if let trimmedRemoteBootstrap, !trimmedRemoteBootstrap.isEmpty { + let remoteCommand = sshPercentEscapedRemoteCommand( + encodedRemoteBootstrapCommand(trimmedRemoteBootstrap) + ) + parts += ["-o", "RemoteCommand=\(remoteCommand)"] + } + if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") { + parts.append("-tt") + } + parts.append(options.destination) + } else { + parts.append(options.destination) + parts.append(contentsOf: options.extraArguments) + } + return parts.map(shellQuote).joined(separator: " ") + } + + private func effectiveSSHOptions(_ options: [String], remoteRelayPort: Int? = nil) -> [String] { + var merged = sshOptionsWithControlSocketDefaults(options, remoteRelayPort: remoteRelayPort) + if !hasSSHOptionKey(merged, key: "StrictHostKeyChecking") { + merged.append("StrictHostKeyChecking=accept-new") + } + return merged + } + + func buildInteractiveRemoteShellScript( + remoteRelayPort: Int, + shellFeatures: String, + terminfoSource: String? = nil + ) -> String { + let remoteTerminalLines = interactiveRemoteTerminalSetupLines(terminfoSource: terminfoSource) + let remoteEnvExportLines = interactiveRemoteShellExportLines(shellFeatures: shellFeatures) + let relaySocket = remoteRelayPort > 0 ? "127.0.0.1:\(remoteRelayPort)" : nil + let shellStateDir = "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell" + let commonShellLines = remoteTerminalLines + + remoteEnvExportLines + + ["export PATH=\"$HOME/.cmux/bin:$PATH\""] + + (relaySocket.map { ["export CMUX_SOCKET_PATH=\($0)"] } ?? []) + + [ + "hash -r >/dev/null 2>&1 || true", + "rehash >/dev/null 2>&1 || true", + ] + let zshEnvLines = [ + "[ -f \"$CMUX_REAL_ZDOTDIR/.zshenv\" ] && source \"$CMUX_REAL_ZDOTDIR/.zshenv\"", + "if [ -n \"${ZDOTDIR:-}\" ] && [ \"$ZDOTDIR\" != \"\(shellStateDir)\" ]; then export CMUX_REAL_ZDOTDIR=\"$ZDOTDIR\"; fi", + "export ZDOTDIR=\"\(shellStateDir)\"", + ] + let zshProfileLines = [ + "[ -f \"$CMUX_REAL_ZDOTDIR/.zprofile\" ] && source \"$CMUX_REAL_ZDOTDIR/.zprofile\"", + ] + let zshRCLines = [ + "[ -f \"$CMUX_REAL_ZDOTDIR/.zshrc\" ] && source \"$CMUX_REAL_ZDOTDIR/.zshrc\"", + ] + commonShellLines + let zshLoginLines = [ + "[ -f \"$CMUX_REAL_ZDOTDIR/.zlogin\" ] && source \"$CMUX_REAL_ZDOTDIR/.zlogin\"", + ] + let bashRCLines = [ + "if [ -f \"$HOME/.bash_profile\" ]; then . \"$HOME/.bash_profile\"; elif [ -f \"$HOME/.bash_login\" ]; then . \"$HOME/.bash_login\"; elif [ -f \"$HOME/.profile\" ]; then . \"$HOME/.profile\"; fi", + "[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"", + ] + commonShellLines + let relayWarmupLines = interactiveRemoteRelayWarmupLines(remoteRelayPort: remoteRelayPort) + + var outerLines: [String] = [ + "CMUX_LOGIN_SHELL=\"${SHELL:-/bin/zsh}\"", + "case \"${CMUX_LOGIN_SHELL##*/}\" in", + " zsh)", + " mkdir -p \"$HOME/.cmux/relay\"", + " cmux_shell_dir=\"\(shellStateDir)\"", + " mkdir -p \"$cmux_shell_dir\"", + " cat > \"$cmux_shell_dir/.zshenv\" <<'CMUXZSHENV'", + ] + outerLines.append(contentsOf: zshEnvLines) + outerLines += [ + "CMUXZSHENV", + " cat > \"$cmux_shell_dir/.zprofile\" <<'CMUXZSHPROFILE'", + ] + outerLines.append(contentsOf: zshProfileLines) + outerLines += [ + "CMUXZSHPROFILE", + " cat > \"$cmux_shell_dir/.zshrc\" <<'CMUXZSHRC'", + ] + outerLines.append(contentsOf: zshRCLines) + outerLines += [ + "CMUXZSHRC", + " cat > \"$cmux_shell_dir/.zlogin\" <<'CMUXZSHLOGIN'", + ] + outerLines.append(contentsOf: zshLoginLines) + outerLines += [ + "CMUXZSHLOGIN", + " chmod 600 \"$cmux_shell_dir/.zshenv\" \"$cmux_shell_dir/.zprofile\" \"$cmux_shell_dir/.zshrc\" \"$cmux_shell_dir/.zlogin\" >/dev/null 2>&1 || true", + ] + outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) + outerLines += [ + " export CMUX_REAL_ZDOTDIR=\"${ZDOTDIR:-$HOME}\"", + " export ZDOTDIR=\"$cmux_shell_dir\"", + " exec \"$CMUX_LOGIN_SHELL\" -il", + " ;;", + " bash)", + " mkdir -p \"$HOME/.cmux/relay\"", + " cmux_shell_dir=\"\(shellStateDir)\"", + " mkdir -p \"$cmux_shell_dir\"", + " cat > \"$cmux_shell_dir/.bashrc\" <<'CMUXBASHRC'", + ] + outerLines.append(contentsOf: bashRCLines) + outerLines += [ + "CMUXBASHRC", + " chmod 600 \"$cmux_shell_dir/.bashrc\" >/dev/null 2>&1 || true", + ] + outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) + outerLines += [ + " exec \"$CMUX_LOGIN_SHELL\" --rcfile \"$cmux_shell_dir/.bashrc\" -i", + " ;;", + " *)", + ] + outerLines.append(contentsOf: commonShellLines) + outerLines.append(contentsOf: relayWarmupLines) + outerLines += [ + "exec \"$CMUX_LOGIN_SHELL\" -i", + ";;", + "esac", + ] + + return outerLines.joined(separator: "\n") + } + + func buildInteractiveRemoteShellCommand( + remoteRelayPort: Int, + shellFeatures: String, + terminfoSource: String? = nil + ) -> String { + let script = buildInteractiveRemoteShellScript( + remoteRelayPort: remoteRelayPort, + shellFeatures: shellFeatures, + terminfoSource: terminfoSource + ) + return "/bin/sh -c \(shellQuote(script))" + } + + private func interactiveRemoteTerminalSetupLines(terminfoSource: String?) -> [String] { + var lines: [String] = [ + "cmux_term='xterm-256color'", + "if command -v infocmp >/dev/null 2>&1 && infocmp xterm-ghostty >/dev/null 2>&1; then", + " cmux_term='xterm-ghostty'", + "fi", + "export TERM=\"$cmux_term\"", + ] + guard let terminfoSource else { return lines } + let trimmedTerminfoSource = terminfoSource.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedTerminfoSource.isEmpty else { return lines } + lines += [ + "if [ \"$cmux_term\" != 'xterm-ghostty' ]; then", + " (", + " command -v tic >/dev/null 2>&1 || exit 0", + " mkdir -p \"$HOME/.terminfo\" 2>/dev/null || exit 0", + " cat <<'CMUXTERMINFO' | tic -x - >/dev/null 2>&1", + trimmedTerminfoSource, + "CMUXTERMINFO", + " ) >/dev/null 2>&1 &", + "fi", + ] + return lines + } + + private func interactiveRemoteShellExportLines(shellFeatures: String) -> [String] { + let environment = ProcessInfo.processInfo.environment + let colorTerm = Self.normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor" + let termProgram = Self.normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty" + let termProgramVersion = Self.normalizedEnvValue(environment["TERM_PROGRAM_VERSION"]) + ?? (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) + ?? "" + let trimmedShellFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) + + var exports: [String] = [ + "export COLORTERM=\(shellQuote(colorTerm))", + "export TERM_PROGRAM=\(shellQuote(termProgram))", + ] + if !termProgramVersion.isEmpty { + exports.append("export TERM_PROGRAM_VERSION=\(shellQuote(termProgramVersion))") + } + if !trimmedShellFeatures.isEmpty { + exports.append("export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedShellFeatures))") + } + return exports + } + + private func interactiveRemoteRelayWarmupLines(remoteRelayPort: Int) -> [String] { + guard remoteRelayPort > 0 else { return [] } + return [] + } + + private func baseSSHArguments(_ options: SSHCommandOptions) -> [String] { + let effectiveSSHOptions = effectiveSSHOptions( + options.sshOptions, + remoteRelayPort: options.remoteRelayPort + ) + var parts: [String] = ["ssh"] + if !hasSSHOptionKey(effectiveSSHOptions, key: "SetEnv") { + parts += ["-o", "SetEnv COLORTERM=truecolor"] + } + if !hasSSHOptionKey(effectiveSSHOptions, key: "SendEnv") { + parts += ["-o", "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"] + } + if let port = options.port { + parts += ["-p", String(port)] + } + if let identityFile = normalizedSSHIdentityPath(options.identityFile) { + parts += ["-i", identityFile] + } + for option in effectiveSSHOptions { + parts += ["-o", option] + } + return parts + } + + private func localXtermGhosttyTerminfoSource() -> String? { + let result = runProcess( + executablePath: "/usr/bin/infocmp", + arguments: ["-0", "-x", "xterm-ghostty"] + ) + guard result.status == 0 else { return nil } + let output = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + return output.isEmpty ? nil : output + } + + private func sshOptionsWithControlSocketDefaults( + _ options: [String], + remoteRelayPort: Int? = nil + ) -> [String] { + var merged: [String] = [] + for option in options { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + merged.append(trimmed) + } + if !hasSSHOptionKey(merged, key: "ControlMaster") { + merged.append("ControlMaster=auto") + } + if !hasSSHOptionKey(merged, key: "ControlPersist") { + merged.append("ControlPersist=600") + } + if !hasSSHOptionKey(merged, key: "ControlPath") { + merged.append("ControlPath=\(defaultSSHControlPathTemplate(remoteRelayPort: remoteRelayPort))") + } + return merged + } + + private func scopedGhosttyShellFeaturesValue() -> String { + let rawExisting = ProcessInfo.processInfo.environment["GHOSTTY_SHELL_FEATURES"] ?? "" + var seen: Set = [] + var merged: [String] = [] + + for token in rawExisting.split(separator: ",") { + let feature = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !feature.isEmpty else { continue } + if seen.insert(feature).inserted { + merged.append(feature) + } + } + + for required in ["ssh-env", "ssh-terminfo"] { + if seen.insert(required).inserted { + merged.append(required) + } + } + + return merged.joined(separator: ",") + } + + func encodedRemoteBootstrapCommand(_ remoteBootstrapScript: String) -> String { + let encodedScript = Data(remoteBootstrapScript.utf8).base64EncodedString() + let encodedLiteral = shellQuote(encodedScript) + return [ + "cmux_tmp=$(mktemp \"${TMPDIR:-/tmp}/cmux-ssh-bootstrap.XXXXXX\") || exit 1", + "(printf %s \(encodedLiteral) | base64 -d 2>/dev/null || printf %s \(encodedLiteral) | base64 -D 2>/dev/null) > \"$cmux_tmp\" || { rm -f \"$cmux_tmp\"; exit 1; }", + "chmod 700 \"$cmux_tmp\" >/dev/null 2>&1 || true", + "/bin/sh \"$cmux_tmp\"", + "cmux_status=$?", + "rm -f \"$cmux_tmp\"", + "exit $cmux_status", + ].joined(separator: "; ") + } + + func sshPercentEscapedRemoteCommand(_ remoteCommand: String) -> String { + remoteCommand.replacingOccurrences(of: "%", with: "%%") + } + + func buildSSHStartupCommand( + sshCommand: String, + shellFeatures: String, + remoteRelayPort: Int + ) throws -> String { + let trimmedFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) + let shellFeaturesBootstrap: String = trimmedFeatures.isEmpty + ? "" + : "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))" + let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort) + var scriptLines: [String] = [] + if !shellFeaturesBootstrap.isEmpty { + scriptLines.append(shellFeaturesBootstrap) + } + scriptLines += [ + "CMUX_SSH_SESSION_ENDED=0", + "cmux_ssh_session_end() { if [ \"${CMUX_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; CMUX_SSH_SESSION_ENDED=1; \(lifecycleCleanup); }", + "trap 'cmux_ssh_session_end' EXIT HUP INT TERM", + ] + scriptLines.append("command \(sshCommand)") + scriptLines += [ + "cmux_ssh_status=$?", + "trap - EXIT HUP INT TERM", + "cmux_ssh_session_end", + "exit $cmux_ssh_status", + ] + let script = scriptLines.joined(separator: "\n") + return try writeSSHStartupScript(script, remoteRelayPort: remoteRelayPort) + } + + private func writeSSHStartupScript(_ scriptBody: String, remoteRelayPort: Int) throws -> String { + let tempDir = FileManager.default.temporaryDirectory + let scriptURL = tempDir.appendingPathComponent( + "cmux-ssh-startup-\(remoteRelayPort)-\(UUID().uuidString.lowercased()).sh" + ) + let script = "#!/bin/sh\n\(scriptBody)\n" + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: scriptURL.path) + return shellQuote(scriptURL.path) + } + + private func buildSSHSessionEndShellCommand(remoteRelayPort: Int) -> String { + [ + "if [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ]", + "&& [ -x \"${CMUX_BUNDLED_CLI_PATH}\" ]", + "&& [ -n \"${CMUX_SOCKET_PATH:-}\" ]", + "&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]", + "&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then", + "\"${CMUX_BUNDLED_CLI_PATH}\" --socket \"${CMUX_SOCKET_PATH}\" ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" >/dev/null 2>&1 || true;", + "elif command -v cmux >/dev/null 2>&1", + "&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]", + "&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then", + "cmux ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" >/dev/null 2>&1 || true;", + "fi", + ].joined(separator: " ") + } + + private func runSSHSessionEnd(commandArgs: [String], client: SocketClient) throws { + guard let relayPortRaw = optionValue(commandArgs, name: "--relay-port"), + let relayPort = Int(relayPortRaw), + relayPort > 0 else { + throw CLIError(message: "ssh-session-end requires --relay-port ") + } + let workspaceRaw = optionValue(commandArgs, name: "--workspace") ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] + let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] + guard let workspaceRaw, + let workspaceId = try normalizeWorkspaceHandle(workspaceRaw, client: client), + !workspaceId.isEmpty else { + throw CLIError(message: "ssh-session-end requires --workspace or CMUX_WORKSPACE_ID") + } + guard let surfaceRaw, + let surfaceId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceId), + !surfaceId.isEmpty else { + throw CLIError(message: "ssh-session-end requires --surface or CMUX_SURFACE_ID") + } + _ = try client.sendV2(method: "workspace.remote.terminal_session_end", params: [ + "workspace_id": workspaceId, + "surface_id": surfaceId, + "relay_port": relayPort, + ]) + } + + private func runRemoteDaemonStatus(commandArgs: [String], jsonOutput: Bool) throws { + let requestedOS = optionValue(commandArgs, name: "--os")?.trimmingCharacters(in: .whitespacesAndNewlines) + let requestedArch = optionValue(commandArgs, name: "--arch")?.trimmingCharacters(in: .whitespacesAndNewlines) + let info = resolvedVersionInfo() + let manifest = remoteDaemonManifest() + let platform = defaultRemoteDaemonPlatform(requestedOS: requestedOS, requestedArch: requestedArch) + let cacheURL = remoteDaemonCacheURL(version: manifest?.appVersion ?? remoteDaemonVersionString(from: info), goOS: platform.goOS, goArch: platform.goArch) + let cacheExists = FileManager.default.fileExists(atPath: cacheURL.path) + let cacheSHA = cacheExists ? try? sha256Hex(forFile: cacheURL) : nil + let entry = manifest?.entry(goOS: platform.goOS, goArch: platform.goArch) + let cacheVerified = (entry != nil && cacheSHA?.lowercased() == entry?.sha256.lowercased()) + let releaseTag = manifest?.releaseTag ?? "unknown" + let assetName = entry?.assetName ?? "unknown" + let downloadURL = entry?.downloadURL ?? "unknown" + let checksumsAssetName = manifest?.checksumsAssetName ?? "unknown" + let checksumsURL = manifest?.checksumsURL ?? "unknown" + let downloadCommand = "gh release download \(releaseTag) --repo manaflow-ai/cmux --pattern \(assetName)" + let downloadChecksumsCommand = "gh release download \(releaseTag) --repo manaflow-ai/cmux --pattern \(checksumsAssetName)" + let checksumVerifyCommand = "shasum -a 256 -c \(checksumsAssetName) --ignore-missing" + let signerWorkflow = releaseTag == "nightly" + ? "manaflow-ai/cmux/.github/workflows/nightly.yml" + : "manaflow-ai/cmux/.github/workflows/release.yml" + let verifyCommand = "gh attestation verify ./\(assetName) --repo manaflow-ai/cmux --signer-workflow \(signerWorkflow)" + + let payload: [String: Any] = [ + "app_version": remoteDaemonVersionString(from: info), + "build": info["CFBundleVersion"] ?? NSNull(), + "commit": info["CMUXCommit"] ?? NSNull(), + "manifest_present": manifest != nil, + "release_tag": releaseTag, + "release_url": manifest?.releaseURL ?? NSNull(), + "target_goos": platform.goOS, + "target_goarch": platform.goArch, + "asset_name": assetName, + "download_url": downloadURL, + "checksums_asset_name": checksumsAssetName, + "checksums_url": checksumsURL, + "expected_sha256": entry?.sha256 ?? NSNull(), + "cache_path": cacheURL.path, + "cache_exists": cacheExists, + "cache_sha256": cacheSHA ?? NSNull(), + "cache_verified": cacheVerified, + "dev_local_build_fallback": ProcessInfo.processInfo.environment["CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD"] == "1", + "download_command": downloadCommand, + "download_checksums_command": downloadChecksumsCommand, + "checksum_verify_command": checksumVerifyCommand, + "attestation_verify_command": verifyCommand, + ] + + if jsonOutput { + print(jsonString(payload)) + return + } + + print("app version: \(payload["app_version"] as? String ?? "unknown")") + if let build = payload["build"] as? String { + print("build: \(build)") + } + if let commit = payload["commit"] as? String { + print("commit: \(commit)") + } + print("manifest: \(manifest != nil ? "present" : "missing")") + print("platform: \(platform.goOS)/\(platform.goArch)") + print("release: \(releaseTag)") + print("asset: \(assetName)") + print("download url: \(downloadURL)") + print("checksums asset: \(checksumsAssetName)") + print("checksums: \(checksumsURL)") + if let expectedSHA = entry?.sha256 { + print("expected sha256: \(expectedSHA)") + } + print("cache: \(cacheURL.path)") + print("cache exists: \(cacheExists ? "yes" : "no")") + if let cacheSHA { + print("cache sha256: \(cacheSHA)") + } + print("cache verified: \(cacheVerified ? "yes" : "no")") + print("download command: \(downloadCommand)") + print("download checksums: \(downloadChecksumsCommand)") + print("verify checksum: \(checksumVerifyCommand)") + print("attestation verify: \(verifyCommand)") + if manifest == nil { + print("note: this build has no embedded remote daemon manifest. Set CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 only for dev builds.") + } + } + + private func defaultRemoteDaemonPlatform(requestedOS: String?, requestedArch: String?) -> (goOS: String, goArch: String) { + let normalizedOS = requestedOS? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let normalizedArch = requestedArch? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let goOS = (normalizedOS?.isEmpty == false ? normalizedOS! : hostGoOS()) + let goArch = (normalizedArch?.isEmpty == false ? normalizedArch! : hostGoArch()) + return (goOS, goArch) + } + + private func hostGoOS() -> String { +#if os(macOS) + return "darwin" +#elseif os(Linux) + return "linux" +#else + return "unknown" +#endif + } + + private func hostGoArch() -> String { +#if arch(arm64) + return "arm64" +#elseif arch(x86_64) + return "amd64" +#else + return "unknown" +#endif + } + + private func remoteDaemonManifest() -> RemoteDaemonManifest? { + for plistURL in candidateInfoPlistURLs() { + guard let raw = NSDictionary(contentsOf: plistURL) as? [String: Any], + let rawManifest = raw["CMUXRemoteDaemonManifestJSON"] as? String, + let data = rawManifest.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8), + let manifest = try? JSONDecoder().decode(RemoteDaemonManifest.self, from: data) else { + continue + } + return manifest + } + return nil + } + + private func remoteDaemonVersionString(from info: [String: String]) -> String { + info["CFBundleShortVersionString"] ?? "dev" + } + + private func remoteDaemonCacheURL(version: String, goOS: String, goArch: String) -> URL { + let root: URL + do { + root = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + } catch { + return URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("cmux-remote-daemons", isDirectory: true) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("cmuxd-remote", isDirectory: false) + } + return root + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("remote-daemons", isDirectory: true) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("cmuxd-remote", isDirectory: false) + } + + private func sha256Hex(forFile url: URL) throws -> String { + let data = try Data(contentsOf: url) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private func hasSSHOptionKey(_ options: [String], key: String) -> Bool { + let loweredKey = key.lowercased() + for option in options { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let token = trimmed.split(whereSeparator: { $0 == "=" || $0.isWhitespace }).first.map(String.init)?.lowercased() + if token == loweredKey { + return true + } + } + return false + } + + private func defaultSSHControlPathTemplate(remoteRelayPort: Int? = nil) -> String { + if let remoteRelayPort, remoteRelayPort > 0 { + return "/tmp/cmux-ssh-\(getuid())-\(remoteRelayPort)-%C" + } + return "/tmp/cmux-ssh-\(getuid())-%C" + } + + private func normalizedSSHIdentityPath(_ rawPath: String?) -> String? { + guard let rawPath else { return nil } + let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.hasPrefix("~") { + let expanded = (trimmed as NSString).expandingTildeInPath + if !expanded.isEmpty { + return expanded + } + } + return trimmed + } + + private func shellQuote(_ value: String) -> String { + let safePattern = "^[A-Za-z0-9_@%+=:,./-]+$" + if value.range(of: safePattern, options: .regularExpression) != nil { + return value + } + return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + private func sshOptionValue(named key: String, in options: [String]) -> String? { + let loweredKey = key.lowercased() + for option in options { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let parts = trimmed.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + if parts.count == 2, + parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == loweredKey { + return parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + } + } + return nil + } + + private func cliDebugLog(_ message: @autoclosure () -> String) { +#if DEBUG + let trimmedExplicit = ProcessInfo.processInfo.environment["CMUX_DEBUG_LOG"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + let path: String? = { + if let trimmedExplicit, !trimmedExplicit.isEmpty { + return trimmedExplicit + } + guard let marker = try? String(contentsOfFile: "/tmp/cmux-last-debug-log-path", encoding: .utf8) else { + return nil + } + let trimmedMarker = marker.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedMarker.isEmpty ? nil : trimmedMarker + }() + guard let path else { return } + let timestamp = ISO8601DateFormatter().string(from: Date()) + let line = "\(timestamp) [cmux-cli] \(message())\n" + guard let data = line.data(using: .utf8) else { return } + if !FileManager.default.fileExists(atPath: path) { + FileManager.default.createFile(atPath: path, contents: nil) + } + guard let handle = FileHandle(forWritingAtPath: path) else { return } + defer { try? handle.close() } + do { + try handle.seekToEnd() + try handle.write(contentsOf: data) + } catch { + return + } +#endif + } + + private func runProcess( + executablePath: String, + arguments: [String], + stdinText: String? = nil, + timeout: TimeInterval? = nil + ) -> (status: Int32, stdout: String, stderr: String) { + let result = CLIProcessRunner.runProcess( + executablePath: executablePath, + arguments: arguments, + stdinText: stdinText, + timeout: timeout + ) + return (result.status, result.stdout, result.stderr) + } private func runBrowserCommand( commandArgs: [String], @@ -3100,7 +4562,6 @@ struct CMUXCLI { return lines.joined(separator: "\n") } - func nonFlagArgs(_ values: [String]) -> [String] { values.filter { !$0.hasPrefix("-") } } @@ -4024,8 +5485,11 @@ struct CMUXCLI { throw CLIError(message: "Unsupported browser console subcommand: \(consoleVerb)") } let payload = try client.sendV2(method: method, params: ["surface_id": sid]) - let fallback = displayBrowserLogItems(payload["entries"]) ?? "OK" - output(payload, fallback: fallback) + if effectiveJSONOutput || consoleVerb == "clear" { + output(payload, fallback: "OK") + } else { + print(displayBrowserLogItems(payload["entries"]) ?? "No console entries") + } return } @@ -4039,8 +5503,11 @@ struct CMUXCLI { throw CLIError(message: "Unsupported browser errors subcommand: \(errorsVerb)") } let payload = try client.sendV2(method: "browser.errors.list", params: params) - let fallback = displayBrowserLogItems(payload["errors"]) ?? "OK" - output(payload, fallback: fallback) + if effectiveJSONOutput || errorsVerb == "clear" { + output(payload, fallback: "OK") + } else { + print(displayBrowserLogItems(payload["errors"]) ?? "No browser errors") + } return } @@ -4645,7 +6112,7 @@ struct CMUXCLI { new-terminal-right | new-browser-right reload | duplicate pin | unpin - mark-read | mark-unread + mark-unread Flags: --action Action name (required if not positional) @@ -4664,18 +6131,21 @@ struct CMUXCLI { return """ Usage: cmux rename-tab [--workspace ] [--tab ] [--surface ] [--] - Rename a tab (surface). Defaults to the focused tab, using: - 1) explicit --tab/--surface - 2) $CMUX_TAB_ID / $CMUX_SURFACE_ID - 3) focused tab in the resolved workspace context + Compatibility alias for tab-action rename. + + Resolution order for target tab: + 1) --tab + 2) --surface + 3) $CMUX_TAB_ID / $CMUX_SURFACE_ID + 4) currently focused tab (optionally within --workspace) Flags: --workspace <id|ref> Workspace context (default: current/$CMUX_WORKSPACE_ID) - --tab <id|ref> Target tab (accepts tab:<n> or surface:<n>) + --tab <id|ref> Tab target (supports tab:<n> or surface:<n>) --surface <id|ref> Alias for --tab - --title <text> New title (or pass trailing title) + --title <text> Explicit title (or use trailing positional title) - Example: + Examples: cmux rename-tab "build logs" cmux rename-tab --tab tab:3 "staging server" cmux rename-tab --workspace workspace:2 --surface surface:5 --title "agent run" @@ -4704,6 +6174,35 @@ struct CMUXCLI { Example: cmux list-workspaces """ + case "ssh": + return """ + Usage: cmux ssh <destination> [flags] [-- <remote-command-args>] + + Create a new workspace, mark it as remote-SSH, and start an SSH session in that workspace. + cmux will also establish a local SSH proxy endpoint so browser traffic can egress from the remote host. + + Flags: + --name <title> Optional workspace title + --port <n> SSH port + --identity <path> SSH identity file path + --ssh-option <opt> Extra SSH -o option (repeatable) + + Example: + cmux ssh dev@my-host + cmux ssh dev@my-host --name "gpu-box" --port 2222 --identity ~/.ssh/id_ed25519 + cmux ssh dev@my-host --ssh-option UserKnownHostsFile=/dev/null --ssh-option StrictHostKeyChecking=no + """ + case "remote-daemon-status": + return """ + Usage: cmux remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>] + + Show the embedded cmuxd-remote release manifest, local cache status, checksum verification state, + and the GitHub attestation verification command for a target platform. + + Example: + cmux remote-daemon-status + cmux remote-daemon-status --os linux --arch arm64 + """ case "new-split": return """ Usage: cmux new-split <left|right|up|down> [flags] @@ -4867,6 +6366,13 @@ struct CMUXCLI { cmux surface-health cmux surface-health --workspace workspace:2 """ + case "debug-terminals": + return """ + Usage: cmux debug-terminals + + Print live Ghostty terminal runtime metadata across all windows and workspaces. + Intended for debugging stray or detached terminal views. + """ case "trigger-flash": return """ Usage: cmux trigger-flash [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>] @@ -6247,7 +7753,6 @@ struct CMUXCLI { .replacingOccurrences(of: "\r", with: "\\r") return "\"\(escaped)\"" } - private func parseOption(_ args: [String], name: String) -> (String?, [String]) { var remaining: [String] = [] var value: String? @@ -6327,6 +7832,57 @@ struct CMUXCLI { return ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] } + private func forwardSidebarMetadataCommand( + _ socketCommand: String, + commandArgs: [String], + client: SocketClient, + windowOverride: String? + ) throws -> String { + func insertArgumentBeforeSeparator(_ value: String, into args: inout [String]) { + if let separatorIndex = args.firstIndex(of: "--") { + args.insert(value, at: separatorIndex) + } else { + args.append(value) + } + } + + var forwardedArgs: [String] = [] + var resolvedExplicitWorkspace = false + var index = 0 + + while index < commandArgs.count { + let arg = commandArgs[index] + if arg == "--workspace", index + 1 < commandArgs.count { + let workspaceId = try resolveWorkspaceId(commandArgs[index + 1], client: client) + forwardedArgs.append("--tab=\(workspaceId)") + resolvedExplicitWorkspace = true + index += 2 + continue + } + if arg.hasPrefix("--workspace=") { + let rawWorkspace = String(arg.dropFirst("--workspace=".count)) + let workspaceId = try resolveWorkspaceId(rawWorkspace, client: client) + forwardedArgs.append("--tab=\(workspaceId)") + resolvedExplicitWorkspace = true + index += 1 + continue + } + forwardedArgs.append(arg) + index += 1 + } + + if !resolvedExplicitWorkspace, + let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) { + let workspaceId = try resolveWorkspaceId(workspaceArg, client: client) + insertArgumentBeforeSeparator("--tab=\(workspaceId)", into: &forwardedArgs) + } + + let command = ([socketCommand] + forwardedArgs) + .map(shellQuote) + .joined(separator: " ") + return try sendV1Command(command, client: client) + } + /// Pick the display handle for an item dict based on --id-format. private func textHandle(_ item: [String: Any], idFormat: CLIIDFormat) -> String { let ref = item["ref"] as? String @@ -7484,7 +9040,11 @@ struct CMUXCLI { do { try client.connect() - try authenticateClientIfNeeded(client, explicitPassword: explicitPassword) + try authenticateClientIfNeeded( + client, + explicitPassword: explicitPassword, + socketPath: socketPath + ) defer { client.close() } let payload = try client.sendV2(method: "system.identify") @@ -7716,7 +9276,6 @@ struct CMUXCLI { ]) } if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { - Thread.sleep(forTimeInterval: 0.3) let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) _ = try client.sendV2(method: "surface.send_text", params: [ "workspace_id": workspaceId, @@ -7754,7 +9313,6 @@ struct CMUXCLI { ]) } if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { - Thread.sleep(forTimeInterval: 0.3) let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) _ = try client.sendV2(method: "surface.send_text", params: [ "workspace_id": workspaceId, @@ -7791,7 +9349,6 @@ struct CMUXCLI { let paneId = created["pane_id"] as? String // Keep the leader pane focused while Claude starts teammates beside it. if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { - Thread.sleep(forTimeInterval: 0.3) _ = try client.sendV2(method: "surface.send_text", params: [ "workspace_id": target.workspaceId, "surface_id": surfaceId, @@ -8195,13 +9752,17 @@ struct CMUXCLI { return } let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + do { + try SocketClient.waitForFilesystemPath(signalURL.path, timeout: max(0, deadline.timeIntervalSinceNow)) + try? FileManager.default.removeItem(at: signalURL) + print("OK") + return + } catch { if FileManager.default.fileExists(atPath: signalURL.path) { try? FileManager.default.removeItem(at: signalURL) print("OK") return } - Thread.sleep(forTimeInterval: 0.05) } throw CLIError(message: "wait-for timed out waiting for '\(name)'") @@ -8625,7 +10186,7 @@ struct CMUXCLI { ) } - let response = try sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) + let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)") _ = try? setClaudeStatus( client: client, workspaceId: workspaceId, @@ -9023,8 +10584,7 @@ struct CMUXCLI { ] let session = firstString(in: object, keys: ["session_id", "sessionId"]) let message = messageCandidates.compactMap { $0 }.first ?? "Claude needs your input" - let dedupedMessage = dedupeBranchContextLines(message) - let normalizedMessage = normalizedSingleLine(dedupedMessage) + let normalizedMessage = normalizedSingleLine(message) let signal = signalParts.compactMap { $0 }.joined(separator: " ") var classified = classifyClaudeNotification(signal: signal, message: normalizedMessage) @@ -9057,42 +10617,6 @@ struct CMUXCLI { return ("Attention", "Claude needs your attention") } - private func dedupeBranchContextLines(_ value: String) -> String { - let lines = value.components(separatedBy: .newlines) - guard lines.count > 1 else { return value } - - var lastIndexByPath: [String: Int] = [:] - for (index, line) in lines.enumerated() { - guard let path = branchContextPath(from: line) else { continue } - lastIndexByPath[path] = index - } - guard !lastIndexByPath.isEmpty else { return value } - - let deduped = lines.enumerated().compactMap { index, line -> String? in - guard let path = branchContextPath(from: line) else { return line } - return lastIndexByPath[path] == index ? line : nil - } - return deduped.joined(separator: "\n") - } - - private func branchContextPath(from line: String) -> String? { - let parts = line.split(separator: "•", maxSplits: 1, omittingEmptySubsequences: false) - guard parts.count == 2 else { return nil } - - let branch = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) - let path = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - guard !branch.isEmpty, !path.isEmpty else { return nil } - - let looksLikePath = path.hasPrefix("/") || path.hasPrefix("~") || path.hasPrefix(".") || path.contains("/") - guard looksLikePath else { return nil } - - let trimmedQuotes = path.trimmingCharacters(in: CharacterSet(charactersIn: "`'\"")) - let expanded = NSString(string: trimmedQuotes).expandingTildeInPath - let standardized = NSString(string: expanded).standardizingPath - let normalized = standardized.trimmingCharacters(in: .whitespacesAndNewlines) - return normalized.isEmpty ? nil : normalized - } - private func firstString(in object: [String: Any], keys: [String]) -> String? { for key in keys { guard let value = object[key] else { continue } @@ -9412,8 +10936,6 @@ struct CMUXCLI { appendIfExisting(current.appendingPathComponent("Info.plist")) } - // Local dev fallback: resolve version from the repo's app Info.plist - // when running a standalone cmux-cli binary from build/Debug. let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj") let repoInfo = current.appendingPathComponent("Resources/Info.plist") if fileManager.fileExists(atPath: projectMarker.path), @@ -9501,13 +11023,13 @@ struct CMUXCLI { --password takes precedence, then CMUX_SOCKET_PASSWORD env var, then password saved in Settings. Commands: - version welcome shortcuts feedback [--email <email> --body <text> [--image <path> ...]] themes [list|set|clear] claude-teams [claude-args...] ping + version capabilities identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller] list-windows @@ -9520,6 +11042,8 @@ struct CMUXCLI { workspace-action --action <name> [--workspace <id|ref|index>] [--title <text>] list-workspaces new-workspace [--cwd <path>] [--command <text>] + ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [--ssh-option <opt>] [-- <remote-command-args>] + remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>] new-split <left|right|up|down> [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>] list-panes [--workspace <id|ref>] list-pane-surfaces [--workspace <id|ref>] [--pane <id|ref>] @@ -9552,18 +11076,6 @@ struct CMUXCLI { list-notifications clear-notifications claude-hook <session-start|stop|notification> [--workspace <id|ref>] [--surface <id|ref>] - - # sidebar metadata commands - set-status <key> <value> [--icon <name>] [--color <#hex>] [--workspace <id|ref>] - clear-status <key> [--workspace <id|ref>] - list-status [--workspace <id|ref>] - set-progress <0.0-1.0> [--label <text>] [--workspace <id|ref>] - clear-progress [--workspace <id|ref>] - log [--level <level>] [--source <name>] [--workspace <id|ref>] [--] <message> - clear-log [--workspace <id|ref>] - list-log [--limit <n>] [--workspace <id|ref>] - sidebar-state [--workspace <id|ref>] - set-app-focus <active|inactive|clear> simulate-app-active @@ -9634,6 +11146,19 @@ struct CMUXCLI { to ~/Library/Application Support/cmux/cmux.sock and auto-discovers tagged/debug sockets. """ } + +#if DEBUG + func debugUsageTextForTesting() -> String { + usage() + } + + func debugFormatDebugTerminalsPayloadForTesting( + _ payload: [String: Any], + idFormat: CLIIDFormat = .refs + ) -> String { + formatDebugTerminalsPayload(payload, idFormat: idFormat) + } +#endif } @main diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index b0de0d8c..1571043e 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -93,6 +93,7 @@ F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; }; FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; }; F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; }; + F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */; }; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; }; @@ -242,13 +243,14 @@ F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = "<group>"; }; FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = "<group>"; }; F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; }; + F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceRemoteConnectionTests.swift; sourceTree = "<group>"; }; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; }; F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = "<group>"; }; FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = "<group>"; }; A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; }; A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; }; - DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; }; + DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; }; A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = "<group>"; }; /* End PBXFileReference section */ @@ -480,6 +482,7 @@ F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */, FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */, F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */, + F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */, F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */, @@ -723,6 +726,7 @@ F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */, FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */, F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, + F6100000A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift in Sources */, F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */, diff --git a/Resources/Info.plist b/Resources/Info.plist index 708488ce..c96a632f 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -135,9 +135,27 @@ <dict> <key>NSAllowsArbitraryLoadsInWebContent</key> <true/> + <key>NSExceptionDomains</key> + <dict> + <key>cmux-loopback.localtest.me</key> + <dict> + <key>NSExceptionAllowsInsecureHTTPLoads</key> + <true/> + <key>NSIncludesSubdomains</key> + <true/> + </dict> + </dict> </dict> + <key>SUAutomaticallyUpdate</key> + <false/> + <key>SUEnableAutomaticChecks</key> + <true/> <key>SUFeedURL</key> <string>https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml</string> + <key>SUScheduledCheckInterval</key> + <integer>86400</integer> + <key>SUSendProfileInfo</key> + <false/> <key>SUPublicEDKey</key> <string>$(SPARKLE_PUBLIC_KEY)</string> </dict> diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 2a7b1e4a..9cf30d6c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -27220,6 +27220,91 @@ } } }, + "contextMenu.copyError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラーをコピー" + } + } + } + }, + "contextMenu.copyErrors": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Errors" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラーをコピー" + } + } + } + }, + "clipboard.sshError.item": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%lld. %@ (%@): %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld. %@ (%@): %@" + } + } + } + }, + "clipboard.sshError.single": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH error (%@): %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH エラー (%@): %@" + } + } + } + }, + "contextMenu.copySshError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy SSH Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSHエラーをコピー" + } + } + } + }, "contextMenu.moveDown": { "extractionState": "manual", "localizations": { @@ -29367,6 +29452,23 @@ } } }, + "dialog.closeTab.cancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + } + } + }, "dialog.closeTab.message": { "extractionState": "manual", "localizations": { @@ -44979,6 +45081,40 @@ } } }, + "settings.app.showSSH": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show SSH in Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーにSSHを表示" + } + } + } + }, + "settings.app.showSSH.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Display the SSH target for remote workspaces in its own row." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートワークスペースのSSHターゲットを専用の行に表示します。" + } + } + } + }, "settings.app.showPorts.subtitle": { "extractionState": "manual", "localizations": { @@ -63744,6 +63880,261 @@ } } }, + "sidebar.remote.badge": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + } + } + }, + "remote.status.connected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connected" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続済み" + } + } + } + }, + "remote.status.connecting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connecting" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続中" + } + } + } + }, + "remote.status.disconnected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Disconnected" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "切断済み" + } + } + } + }, + "remote.status.error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラー" + } + } + } + }, + "sidebar.remote.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH • %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH • %@" + } + } + } + }, + "sidebar.remote.subtitleFallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH ワークスペース" + } + } + } + }, + "sidebar.remote.help.connected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH connected to %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH は %@ に接続済み" + } + } + } + }, + "sidebar.remote.help.connecting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH connecting to %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH は %@ に接続中" + } + } + } + }, + "sidebar.remote.help.error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH error for %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の SSH エラー" + } + } + } + }, + "sidebar.remote.help.errorWithDetail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH error for %@: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の SSH エラー: %@" + } + } + } + }, + "sidebar.remote.help.disconnected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH disconnected from %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH は %@ から切断済み" + } + } + } + }, + "sidebar.remote.help.targetFallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "remote host" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートホスト" + } + } + } + }, + "sidebar.activeTabIndicator.leftRail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Left Rail" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "左レール" + } + } + } + }, + "sidebar.activeTabIndicator.solidFill": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Solid Fill" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "塗りつぶし" + } + } + } + }, "sidebar.workspace.moveDownAction": { "extractionState": "manual", "localizations": { diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 92af16d9..af518b52 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -58,6 +58,100 @@ typeset -g _CMUX_CMD_START=0 typeset -g _CMUX_SHELL_ACTIVITY_LAST="" typeset -g _CMUX_TTY_NAME="" typeset -g _CMUX_TTY_REPORTED=0 +typeset -g _CMUX_GHOSTTY_SEMANTIC_PATCHED=0 +typeset -g _CMUX_WINCH_GUARD_INSTALLED=0 + +_cmux_ensure_ghostty_preexec_strips_both_marks() { + local fn_name="$1" + (( $+functions[$fn_name] )) || return 0 + + local old_strip new_strip updated + old_strip=$'PS1=${PS1//$\'%{\\e]133;A;cl=line\\a%}\'}' + new_strip=$'PS1=${PS1//$\'%{\\e]133;A;redraw=last;cl=line\\a%}\'}' + updated="${functions[$fn_name]}" + + if [[ "$updated" == *"$new_strip"* && "$updated" != *"$old_strip"* ]]; then + updated="${updated/$new_strip/$old_strip + $new_strip}" + functions[$fn_name]="$updated" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + return 0 + fi + if [[ "$updated" == *"$old_strip"* && "$updated" != *"$new_strip"* ]]; then + updated="${updated/$old_strip/$old_strip + $new_strip}" + functions[$fn_name]="$updated" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + fi +} + +_cmux_patch_ghostty_semantic_redraw() { + local old_frag new_frag + old_frag='133;A;cl=line' + new_frag='133;A;redraw=last;cl=line' + + # Patch both deferred and live hook definitions, depending on init timing. + if (( $+functions[_ghostty_deferred_init] )); then + functions[_ghostty_deferred_init]="${functions[_ghostty_deferred_init]//$old_frag/$new_frag}" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + fi + if (( $+functions[_ghostty_precmd] )); then + functions[_ghostty_precmd]="${functions[_ghostty_precmd]//$old_frag/$new_frag}" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + fi + if (( $+functions[_ghostty_preexec] )); then + functions[_ghostty_preexec]="${functions[_ghostty_preexec]//$old_frag/$new_frag}" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + fi + + # Keep legacy + redraw-aware strip lines so prompts created before patching + # are still cleared by preexec. + _cmux_ensure_ghostty_preexec_strips_both_marks _ghostty_deferred_init + _cmux_ensure_ghostty_preexec_strips_both_marks _ghostty_preexec +} +_cmux_patch_ghostty_semantic_redraw + +_cmux_prompt_wrap_guard() { + local cmd_start="$1" + local pwd="$2" + [[ -n "$cmd_start" && "$cmd_start" != 0 ]] || return 0 + + local cols="${COLUMNS:-0}" + (( cols > 0 )) || return 0 + + local budget=$(( cols - 24 )) + (( budget < 20 )) && budget=20 + (( ${#pwd} >= budget )) || return 0 + + # Keep a spacer line between command output and a wrapped prompt so + # resize-driven prompt redraw cannot overwrite the command tail. + builtin print -r -- "" +} + +_cmux_install_winch_guard() { + (( _CMUX_WINCH_GUARD_INSTALLED )) && return 0 + + # Respect user-defined WINCH handlers (function-based or trap-based). + local existing_winch_trap="" + existing_winch_trap="$(trap -p WINCH 2>/dev/null || true)" + if (( $+functions[TRAPWINCH] )) || [[ -n "$existing_winch_trap" ]]; then + _CMUX_WINCH_GUARD_INSTALLED=1 + return 0 + fi + + TRAPWINCH() { + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + + # Keep a spacer line so prompt redraw during resize cannot clobber the + # tail of command output that was rendered immediately above the prompt. + builtin print -r -- "" + return 0 + } + + _CMUX_WINCH_GUARD_INSTALLED=1 +} +_cmux_install_winch_guard _cmux_git_resolve_head_path() { # Resolve the HEAD file path without invoking git (fast; works for worktrees). @@ -478,6 +572,9 @@ _cmux_precmd() { [[ -n "$CMUX_PANEL_ID" ]] || return 0 _cmux_report_shell_activity_state prompt + # Handle cases where Ghostty integration initializes after this file. + _cmux_patch_ghostty_semantic_redraw + if [[ -z "$_CMUX_TTY_NAME" ]]; then local t t="$(tty 2>/dev/null || true)" @@ -492,6 +589,8 @@ _cmux_precmd() { local cmd_start="$_CMUX_CMD_START" _CMUX_CMD_START=0 + _cmux_prompt_wrap_guard "$cmd_start" "$pwd" + # Post-wake socket writes can occasionally leave a probe process wedged. # If one probe is stale, clear the guard so fresh async probes can resume. if [[ -n "$_CMUX_GIT_JOB_PID" ]]; then diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 7b5abd04..cb6a838d 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2075,11 +2075,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var sessionAutosaveTimer: DispatchSourceTimer? private var sessionAutosaveTickInFlight = false private var sessionAutosaveDeferredRetryPending = false - private var socketListenerHealthTimer: DispatchSourceTimer? - private var socketListenerHealthCheckInFlight = false - private static let socketListenerHealthCheckInterval: DispatchTimeInterval = .seconds(2) - private var lastSocketListenerUnhealthyCaptureAt: Date = .distantPast - private static let socketListenerUnhealthyCaptureCooldown: TimeInterval = 60 private let sessionPersistenceQueue = DispatchQueue( label: "com.cmuxterm.app.sessionPersistence", qos: .utility @@ -2420,7 +2415,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent isTerminatingApp = true _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) stopSessionAutosaveTimer() - stopSocketListenerHealthMonitor() TerminalController.shared.stop() VSCodeServeWebController.shared.stop() BrowserProfileStore.shared.flushPendingSaves() @@ -2449,7 +2443,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installLifecycleSnapshotObserversIfNeeded() prepareStartupSessionSnapshotIfNeeded() startSessionAutosaveTimerIfNeeded() - startSocketListenerHealthMonitorIfNeeded() #if DEBUG setupJumpUnreadUITestIfNeeded() setupGotoSplitUITestIfNeeded() @@ -3042,91 +3035,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent TerminalController.shared.start(tabManager: tabManager, socketPath: restartPath, accessMode: config.mode) } - private func startSocketListenerHealthMonitorIfNeeded() { - guard socketListenerHealthTimer == nil else { return } - let timer = DispatchSource.makeTimerSource(queue: .main) - timer.schedule( - deadline: .now() + Self.socketListenerHealthCheckInterval, - repeating: Self.socketListenerHealthCheckInterval - ) - timer.setEventHandler { [weak self] in - Task { @MainActor [weak self] in - self?.restartSocketListenerIfNeededForHealthCheck(source: "health.timer") - } - } - timer.resume() - socketListenerHealthTimer = timer - } - - private func stopSocketListenerHealthMonitor() { - socketListenerHealthTimer?.cancel() - socketListenerHealthTimer = nil - socketListenerHealthCheckInFlight = false - } - - private func restartSocketListenerIfNeededForHealthCheck(source: String) { - guard !socketListenerHealthCheckInFlight, - let config = socketListenerConfigurationIfEnabled() else { return } - let terminalController = TerminalController.shared - let expectedSocketPath = terminalController.activeSocketPath(preferredPath: config.path) - socketListenerHealthCheckInFlight = true - Thread.detachNewThread { [weak self, expectedSocketPath, source, terminalController] in - let health = terminalController.socketListenerHealth(expectedSocketPath: expectedSocketPath) - Task { @MainActor [weak self, health] in - guard let self else { return } - self.socketListenerHealthCheckInFlight = false - self.handleSocketListenerHealthCheckResult( - health, - source: source, - expectedSocketPath: expectedSocketPath - ) - } - } - } - - private func handleSocketListenerHealthCheckResult( - _ health: TerminalController.SocketListenerHealth, - source: String, - expectedSocketPath: String - ) { - guard let config = socketListenerConfigurationIfEnabled() else { return } - let currentExpectedSocketPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) - guard currentExpectedSocketPath == expectedSocketPath else { return } - guard !health.isHealthy else { - lastSocketListenerUnhealthyCaptureAt = .distantPast - return - } - let failureSignals = health.failureSignals - var data: [String: Any] = [ - "source": source, - "path": currentExpectedSocketPath, - "isRunning": health.isRunning ? 1 : 0, - "acceptLoopAlive": health.acceptLoopAlive ? 1 : 0, - "socketPathMatches": health.socketPathMatches ? 1 : 0, - "socketPathExists": health.socketPathExists ? 1 : 0, - "socketProbePerformed": health.socketProbePerformed ? 1 : 0, - "failureSignals": failureSignals - ] - if let socketConnectable = health.socketConnectable { - data["socketConnectable"] = socketConnectable ? 1 : 0 - } - if let socketConnectErrno = health.socketConnectErrno { - data["socketConnectErrno"] = Int(socketConnectErrno) - } - sentryBreadcrumb("socket.listener.unhealthy", category: "socket", data: data) - let now = Date() - if now.timeIntervalSince(lastSocketListenerUnhealthyCaptureAt) >= Self.socketListenerUnhealthyCaptureCooldown { - lastSocketListenerUnhealthyCaptureAt = now - sentryCaptureWarning( - "socket.listener.unhealthy", - category: "socket", - data: data, - contextKey: "socket_listener_health" - ) - } - restartSocketListenerIfEnabled(source: source) - } - private func disableSuddenTerminationIfNeeded() { guard !didDisableSuddenTermination else { return } ProcessInfo.processInfo.disableSuddenTermination() @@ -3520,6 +3428,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } #endif + private func notifyMainWindowContextsDidChange() { + NotificationCenter.default.post(name: .mainWindowContextsDidChange, object: self) + } + /// Register a terminal window with the AppDelegate so menu commands and socket control /// can target whichever window is currently active. func registerMainWindow( @@ -3566,6 +3478,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "mainWindow.register windowId=\(String(windowId.uuidString.prefix(8))) window={\(debugWindowToken(window))} manager=\(debugManagerToken(tabManager)) priorActiveMgr=\(priorManagerToken) \(debugShortcutRouteSnapshot())" ) #endif + notifyMainWindowContextsDidChange() if window.isKeyWindow { setActiveMainWindow(window) } @@ -4725,6 +4638,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent mainWindowContexts[desiredKey] = context context.window = window + notifyMainWindowContextsDidChange() } private func contextForMainTerminalWindow(_ window: NSWindow, reindex: Bool = true) -> MainWindowContext? { @@ -4770,6 +4684,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent for key in removedKeys { mainWindowContexts.removeValue(forKey: key) } + notifyMainWindowContextsDidChange() return removed } @@ -4780,6 +4695,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent for key in contextKeys { mainWindowContexts.removeValue(forKey: key) } + notifyMainWindowContextsDidChange() commandPaletteVisibilityByWindowId.removeValue(forKey: context.windowId) commandPalettePendingOpenByWindowId.removeValue(forKey: context.windowId) @@ -5034,6 +4950,46 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return context.tabManager } + private struct FocusedTerminalShortcutContext { + let tabManager: TabManager + let workspaceId: UUID + let panelId: UUID + } + + private func resolveShortcutTabManager(for tabId: UUID, preferredWindow: NSWindow? = nil) -> TabManager? { + if let manager = tabManagerFor(tabId: tabId) { + return manager + } + if let preferredWindow, + let context = contextForMainWindow(preferredWindow), + context.tabManager.tabs.contains(where: { $0.id == tabId }) { + return context.tabManager + } + if let activeManager = tabManager, + activeManager.tabs.contains(where: { $0.id == tabId }) { + return activeManager + } + return nil + } + + private func focusedTerminalShortcutContext(preferredWindow: NSWindow? = nil) -> FocusedTerminalShortcutContext? { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let responder = targetWindow?.firstResponder + ?? NSApp.keyWindow?.firstResponder + ?? NSApp.mainWindow?.firstResponder + guard let ghosttyView = cmuxOwningGhosttyView(for: responder), + let workspaceId = ghosttyView.tabId, + let panelId = ghosttyView.terminalSurface?.id, + let manager = resolveShortcutTabManager(for: workspaceId, preferredWindow: targetWindow) else { + return nil + } + return FocusedTerminalShortcutContext( + tabManager: manager, + workspaceId: workspaceId, + panelId: panelId + ) + } + private func preferredMainWindowContextForShortcuts(event: NSEvent) -> MainWindowContext? { if let context = contextForMainWindow(event.window) { return context @@ -5894,19 +5850,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent pasteboard.setString(payload, forType: .string) } - private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0, beforeSend: (() -> Void)? = nil) { - let maxAttempts = 60 + private func sendTextWhenReady(_ text: String, to tab: Tab, beforeSend: (() -> Void)? = nil) { if let terminalPanel = tab.focusedTerminalPanel, terminalPanel.surface.surface != nil { beforeSend?() terminalPanel.sendText(text) return } - guard attempt < maxAttempts else { - NSLog("Command send: surface not ready after \(maxAttempts) attempts") - return + + var resolved = false + var readyObserver: NSObjectProtocol? + var panelsCancellable: AnyCancellable? + + func finishIfReady() { + guard !resolved, + let terminalPanel = tab.focusedTerminalPanel, + terminalPanel.surface.surface != nil else { return } + resolved = true + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + panelsCancellable?.cancel() + beforeSend?() + terminalPanel.sendText(text) } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.sendTextWhenReady(text, to: tab, attempt: attempt + 1, beforeSend: beforeSend) + + panelsCancellable = tab.$panels + .map { _ in () } + .sink { _ in finishIfReady() } + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { note in + guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, + workspaceId == tab.id else { return } + finishIfReady() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + if !resolved { + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + panelsCancellable?.cancel() + NSLog("Command send: surface not ready after 3.0s") + } } } @@ -5920,7 +5907,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private let debugStressTabsPerPane = 4 private let debugStressYieldInterval = 4 private let debugStressSurfaceLoadTimeoutSeconds: TimeInterval = 10.0 - private let debugStressSurfaceLoadPollNanoseconds: UInt64 = 25_000_000 @objc func openDebugScrollbackTab(_ sender: Any?) { guard let tabManager else { return } @@ -6147,6 +6133,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let panelId: UUID } + private func waitForDebugStressCondition( + timeout: TimeInterval, + installObservers: (@escaping () -> Void) -> [NSObjectProtocol], + evaluate: @escaping () -> Bool + ) async -> Bool { + await withCheckedContinuation { continuation in + var observers: [NSObjectProtocol] = [] + var timeoutWorkItem: DispatchWorkItem? + var finished = false + + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + timeoutWorkItem?.cancel() + timeoutWorkItem = nil + } + + func finish(_ result: Bool) { + guard !finished else { return } + finished = true + cleanup() + continuation.resume(returning: result) + } + + let trigger = { + if evaluate() { + finish(true) + } + } + + observers = installObservers { + DispatchQueue.main.async { + trigger() + } + } + let workItem = DispatchWorkItem { + finish(evaluate()) + } + timeoutWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: workItem) + trigger() + } + } + private func loadAllDebugStressWorkspacesForTerminalSurfaceReadiness( _ workspaces: [Workspace], tabManager: TabManager @@ -6230,8 +6260,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent var mountedWorkspaceCount = 0 let selectedWorkspaceId = tabManager?.selectedTabId - for _ in 0..<4 { - forceDebugStressVisibleLayout() + let updateMountedCount = { [self] in + self.forceDebugStressVisibleLayout() mountedWorkspaceCount = 0 for workspace in workspaces { if workspace.id == selectedWorkspaceId { @@ -6246,12 +6276,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent mountedWorkspaceCount += 1 } } - if mountedWorkspaceCount == workspaces.count { - break - } - await Task.yield() - try? await Task.sleep(nanoseconds: debugStressSurfaceLoadPollNanoseconds) } + let _ = await waitForDebugStressCondition( + timeout: 0.25, + installObservers: { trigger in + [ + NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { _ in + trigger() + }, + NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { _ in + trigger() + }, + NotificationCenter.default.addObserver( + forName: NSWindow.didUpdateNotification, + object: nil, + queue: .main + ) { _ in + trigger() + } + ] + }, + evaluate: { + updateMountedCount() + return mountedWorkspaceCount == workspaces.count + } + ) dlog("stress.setup.mount mounted=\(mountedWorkspaceCount)/\(workspaces.count)") return mountedWorkspaceCount @@ -6268,17 +6325,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let selectedWorkspaceId = tabManager?.selectedTabId var pendingTargets = targets var attempts = 0 - var pass = 0 - - while !pendingTargets.isEmpty, Date() < deadline { - pass += 1 - forceDebugStressVisibleLayout() + var eventCount = 0 + func refreshPendingTargets() { + self.forceDebugStressVisibleLayout() var nextPending: [DebugStressTerminalLoadTarget] = [] nextPending.reserveCapacity(pendingTargets.count) - var restartedThisPass = 0 + var startedThisPass = 0 - for (targetIndex, target) in pendingTargets.enumerated() { + for target in pendingTargets { guard let terminalPanel = target.workspace.panel(for: target.tabId) as? TerminalPanel else { nextPending.append(target) continue @@ -6295,37 +6350,59 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if shouldReconcileVisibleSelection { target.workspace.scheduleDebugStressTerminalGeometryReconcile() - if pass == 1 || (pass % 4) == 0 { - if target.workspace.preloadTerminalPanelForDebugStress( - tabId: target.tabId, - inPane: target.paneId - ) != nil { - restartedThisPass += 1 - attempts += 1 - } - } else { - terminalPanel.requestViewReattach() - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() - } - } else { - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + terminalPanel.requestViewReattach() } + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + startedThisPass += 1 nextPending.append(target) - - if ((targetIndex + 1) % 16) == 0 { - await Task.yield() - } } - if nextPending.count != pendingTargets.count || restartedThisPass > 0 || pass == 1 || (pass % 8) == 0 { + eventCount += 1 + if nextPending.count != pendingTargets.count || startedThisPass > 0 || eventCount == 1 { dlog( - "stress.setup.await pass=\(pass) pending=\(nextPending.count) " + - "restarted=\(restartedThisPass)" + "stress.setup.await event=\(eventCount) pending=\(nextPending.count) " + + "started=\(startedThisPass)" ) } - try? await Task.sleep(nanoseconds: debugStressSurfaceLoadPollNanoseconds) + attempts += startedThisPass pendingTargets = nextPending } + refreshPendingTargets() + let remaining = deadline.timeIntervalSinceNow + if remaining > 0, !pendingTargets.isEmpty { + let _ = await waitForDebugStressCondition( + timeout: remaining, + installObservers: { trigger in + [ + NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { _ in + trigger() + }, + NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { _ in + trigger() + }, + NotificationCenter.default.addObserver( + forName: NSWindow.didUpdateNotification, + object: nil, + queue: .main + ) { _ in + trigger() + } + ] + }, + evaluate: { + refreshPendingTargets() + return pendingTargets.isEmpty + } + ) + } return (pendingTargets: pendingTargets, attempts: attempts) } @@ -6684,16 +6761,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return updates } - private func focusWebViewForGotoSplitUITest(tab: Workspace, browserPanelId: UUID, attempt: Int = 0) { - let maxAttempts = 120 - guard attempt < maxAttempts else { - writeGotoSplitTestData([ - "webViewFocused": "false", - "setupError": "Timed out waiting for WKWebView focus" - ]) - return - } - + private func focusWebViewForGotoSplitUITest(tab: Workspace, browserPanelId: UUID) { guard let browserPanel = tab.browserPanel(for: browserPanelId) else { writeGotoSplitTestData([ "webViewFocused": "false", @@ -6702,14 +6770,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return } - // Select the browser surface and try to focus the WKWebView. - tab.focusPanel(browserPanelId) + var resolved = false + var observers: [NSObjectProtocol] = [] + var panelsCancellable: AnyCancellable? - if isWebViewFocused(browserPanel), - let (browserPaneId, terminalPaneId) = paneIdsForGotoSplitUITest( - tab: tab, - browserPanelId: browserPanelId - ) { + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + panelsCancellable?.cancel() + } + + func recordFocusedState() { + guard !resolved else { return } + guard let panel = tab.browserPanel(for: browserPanelId) else { + resolved = true + cleanup() + writeGotoSplitTestData([ + "webViewFocused": "false", + "setupError": "Browser panel missing" + ]) + return + } + + tab.focusPanel(browserPanelId) + + guard isWebViewFocused(panel), + let (browserPaneId, terminalPaneId) = paneIdsForGotoSplitUITest( + tab: tab, + browserPanelId: browserPanelId + ) else { + return + } + + resolved = true + cleanup() writeGotoSplitTestData([ "browserPanelId": browserPanelId.uuidString, "browserPaneId": browserPaneId.description, @@ -6723,14 +6817,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "webViewFocused": "true" ]) if ProcessInfo.processInfo.environment["CMUX_UI_TEST_GOTO_SPLIT_INPUT_SETUP"] == "1" { - setupFocusedInputForGotoSplitUITest(panel: browserPanel) + setupFocusedInputForGotoSplitUITest(panel: panel) } - return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.focusWebViewForGotoSplitUITest(tab: tab, browserPanelId: browserPanelId, attempt: attempt + 1) + observers.append(NotificationCenter.default.addObserver( + forName: .browserDidBecomeFirstResponderWebView, + object: nil, + queue: .main + ) { _ in + recordFocusedState() + }) + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { note in + guard let surfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + surfaceId == browserPanelId else { return } + recordFocusedState() + }) + panelsCancellable = tab.$panels + .map { _ in () } + .sink { _ in recordFocusedState() } + DispatchQueue.main.asyncAfter(deadline: .now() + 6.0) { [weak self] in + guard let self else { return } + if !resolved { + cleanup() + self.writeGotoSplitTestData([ + "webViewFocused": "false", + "setupError": "Timed out waiting for WKWebView focus" + ]) + } } + + recordFocusedState() } private func isWebViewFocused(_ panel: BrowserPanel) -> Bool { @@ -6786,61 +6907,125 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func recordGotoSplitUITestWebViewFocus(panelId: UUID, key: String) { - // Give the responder chain time to settle, retrying for slow environments (e.g. VM). - recordGotoSplitUITestWebViewFocusRetry(panelId: panelId, key: key, attempt: 0) - } + guard let tabManager, + let tab = tabManager.selectedWorkspace, + let panel = tab.browserPanel(for: panelId) else { + return + } - private func recordGotoSplitUITestWebViewFocusRetry(panelId: UUID, key: String, attempt: Int) { - let delays: [Double] = [0.05, 0.1, 0.25, 0.5] - let delay = attempt < delays.count ? delays[attempt] : delays.last! - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self, let tabManager, let tab = tabManager.selectedWorkspace, - let panel = tab.browserPanel(for: panelId) else { return } - let focused = self.isWebViewFocused(panel) - // If focus hasn't settled yet and we have retries left, try again. - if !focused && key.contains("Exit") && attempt < delays.count - 1 { - self.recordGotoSplitUITestWebViewFocusRetry(panelId: panelId, key: key, attempt: attempt + 1) - return + guard key.contains("Exit") else { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.writeGotoSplitTestData([ + key: self.isWebViewFocused(panel) ? "true" : "false", + "\(key)PanelId": panelId.uuidString + ]) } + return + } + + var resolved = false + var observers: [NSObjectProtocol] = [] + var panelsCancellable: AnyCancellable? + + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + panelsCancellable?.cancel() + panelsCancellable = nil + } + + @MainActor + func finish(with focused: Bool) { + guard !resolved else { return } + resolved = true + cleanup() self.writeGotoSplitTestData([ key: focused ? "true" : "false", "\(key)PanelId": panelId.uuidString ]) } - } - private func setupFocusedInputForGotoSplitUITest(panel: BrowserPanel, attempt: Int = 0) { - let maxAttempts = 80 - guard attempt < maxAttempts else { - writeGotoSplitTestData([ - "webInputFocusSeeded": "false", - "setupError": "Timed out focusing page input for omnibar restore test" - ]) - return + @MainActor + func evaluate() { + guard !resolved, + let currentTabManager = self.tabManager, + let currentTab = currentTabManager.selectedWorkspace, + let currentPanel = currentTab.browserPanel(for: panelId) else { + return + } + guard self.isWebViewFocused(currentPanel) else { return } + finish(with: true) } + observers.append(NotificationCenter.default.addObserver( + forName: .browserDidBecomeFirstResponderWebView, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + guard notification.object as? WKWebView === panel.webView else { return } + Task { @MainActor in evaluate() } + }) + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self else { return } + guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + surfaceId == panelId else { return } + Task { @MainActor in evaluate() } + }) + panelsCancellable = tab.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in evaluate() } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + guard let self else { return } + Task { @MainActor in + guard !resolved else { return } + let focused = (self.tabManager?.selectedWorkspace?.browserPanel(for: panelId)).map(self.isWebViewFocused) ?? false + finish(with: focused) + } + } + Task { @MainActor in evaluate() } + } + + private func javaScriptLiteral(_ value: String?) -> String { + guard let value else { return "null" } + guard let data = try? JSONSerialization.data(withJSONObject: [value]), + let arrayLiteral = String(data: data, encoding: .utf8), + arrayLiteral.count >= 2 else { + return "null" + } + return String(arrayLiteral.dropFirst().dropLast()) + } + + private func setupFocusedInputForGotoSplitUITest(panel: BrowserPanel) { let script = """ (() => { - try { - const trackerInstalled = window.__cmuxAddressBarFocusTrackerInstalled === true; - const readyState = String(document.readyState || ""); - if (!trackerInstalled || readyState !== "complete") { - const active = document.activeElement; - return { - focused: false, - id: "", - activeId: active && typeof active.id === "string" ? active.id : "", - activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", - trackerInstalled, - trackedStateId: - window.__cmuxAddressBarFocusState && - typeof window.__cmuxAddressBarFocusState.id === "string" - ? window.__cmuxAddressBarFocusState.id - : "", - readyState - }; - } - + const snapshot = () => { + const active = document.activeElement; + return { + focused: false, + id: "", + secondaryId: "", + secondaryCenterX: -1, + secondaryCenterY: -1, + activeId: active && typeof active.id === "string" ? active.id : "", + activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", + trackerInstalled: window.__cmuxAddressBarFocusTrackerInstalled === true, + trackedStateId: + window.__cmuxAddressBarFocusState && + typeof window.__cmuxAddressBarFocusState.id === "string" + ? window.__cmuxAddressBarFocusState.id + : "", + readyState: String(document.readyState || "") + }; + }; + const seed = () => { const ensureInput = (id, value) => { const existing = document.getElementById(id); const input = (existing && existing.tagName && existing.tagName.toLowerCase() === "input") @@ -6938,28 +7123,69 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent secondaryCenterY, activeId: active && typeof active.id === "string" ? active.id : "", activeTag: active && active.tagName ? active.tagName.toLowerCase() : "", - trackerInstalled, + trackerInstalled: window.__cmuxAddressBarFocusTrackerInstalled === true, trackedStateId: window.__cmuxAddressBarFocusState && typeof window.__cmuxAddressBarFocusState.id === "string" ? window.__cmuxAddressBarFocusState.id : "", - readyState - }; - } catch (_) { - return { - focused: false, - id: "", - secondaryId: "", - secondaryCenterX: -1, - secondaryCenterY: -1, - activeId: "", - activeTag: "", - trackerInstalled: false, - trackedStateId: "", - readyState: "" + readyState: String(document.readyState || "") }; + }; + const ready = () => + window.__cmuxAddressBarFocusTrackerInstalled === true && + String(document.readyState || "") === "complete"; + + if (ready()) { + try { + return seed(); + } catch (_) { + return snapshot(); + } } + + return new Promise((resolve) => { + let finished = false; + let observer = null; + const cleanups = []; + const finish = (value) => { + if (finished) return; + finished = true; + if (observer) observer.disconnect(); + for (const cleanup of cleanups) { + try { cleanup(); } catch (_) {} + } + resolve(value); + }; + const maybeFinish = () => { + if (!ready()) return; + try { + finish(seed()); + } catch (_) { + finish(snapshot()); + } + }; + const addListener = (target, eventName, options) => { + if (!target || typeof target.addEventListener !== "function") return; + const handler = () => maybeFinish(); + target.addEventListener(eventName, handler, options); + cleanups.push(() => target.removeEventListener(eventName, handler, options)); + }; + try { + observer = new MutationObserver(() => maybeFinish()); + observer.observe(document.documentElement || document, { + childList: true, + subtree: true, + attributes: true, + characterData: true + }); + } catch (_) {} + addListener(document, "readystatechange", true); + addListener(window, "load", true); + const timeoutId = window.setTimeout(() => finish(snapshot()), 4000); + cleanups.push(() => window.clearTimeout(timeoutId)); + maybeFinish(); + }); })(); """ @@ -7023,43 +7249,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ]) return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.setupFocusedInputForGotoSplitUITest(panel: panel, attempt: attempt + 1) - } + self.writeGotoSplitTestData([ + "webInputFocusSeeded": "false", + "setupError": "Timed out focusing page input for omnibar restore test" + ]) } } private func recordGotoSplitUITestActiveElement(panelId: UUID, keyPrefix: String) { - recordGotoSplitUITestActiveElementRetry(panelId: panelId, keyPrefix: keyPrefix, attempt: 0) - } - - private func recordGotoSplitUITestActiveElementRetry(panelId: UUID, keyPrefix: String, attempt: Int) { - let delays: [Double] = [0.05, 0.1, 0.25, 0.5] - let delay = attempt < delays.count ? delays[attempt] : delays.last! - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self, - let tabManager, - let tab = tabManager.selectedWorkspace, - let panel = tab.browserPanel(for: panelId) else { return } - - self.evaluateGotoSplitUITestActiveElement(panel: panel) { snapshot in - let activeId = snapshot["id"] ?? "" - let expectedInputId = self.gotoSplitUITestExpectedInputId() ?? "" - if keyPrefix == "addressBarExit", - !expectedInputId.isEmpty, - activeId != expectedInputId, - attempt < delays.count - 1 { - self.recordGotoSplitUITestActiveElementRetry( - panelId: panelId, - keyPrefix: keyPrefix, - attempt: attempt + 1 - ) - return - } + guard let tabManager, + let tab = tabManager.selectedWorkspace, + let panel = tab.browserPanel(for: panelId) else { + return + } + let expectedInputId = keyPrefix == "addressBarExit" ? gotoSplitUITestExpectedInputId() : nil + let capture: @MainActor @Sendable () -> Void = { [weak self] in + guard let self else { return } + self.evaluateGotoSplitUITestActiveElement( + panel: panel, + awaitingInputId: expectedInputId + ) { snapshot in self.writeGotoSplitTestData([ "\(keyPrefix)PanelId": panelId.uuidString, - "\(keyPrefix)ActiveElementId": activeId, + "\(keyPrefix)ActiveElementId": snapshot["id"] ?? "", "\(keyPrefix)ActiveElementTag": snapshot["tag"] ?? "", "\(keyPrefix)ActiveElementType": snapshot["type"] ?? "", "\(keyPrefix)ActiveElementEditable": snapshot["editable"] ?? "false", @@ -7068,48 +7281,119 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ]) } } + + if expectedInputId == nil { + DispatchQueue.main.async { + Task { @MainActor in capture() } + } + } else { + Task { @MainActor in capture() } + } } private func evaluateGotoSplitUITestActiveElement( panel: BrowserPanel, + awaitingInputId: String? = nil, completion: @escaping ([String: String]) -> Void ) { + let expectedInputIdLiteral = javaScriptLiteral(awaitingInputId) let script = """ (() => { - try { - const active = document.activeElement; - if (!active) { - return { id: "", tag: "", type: "", editable: "false" }; + const expectedInputId = \(expectedInputIdLiteral); + const snapshot = () => { + try { + const active = document.activeElement; + if (!active) { + return { + id: "", + tag: "", + type: "", + editable: "false", + trackedFocusStateId: "", + focusTrackerInstalled: window.__cmuxAddressBarFocusTrackerInstalled === true ? "true" : "false" + }; + } + const tag = (active.tagName || "").toLowerCase(); + const type = (active.type || "").toLowerCase(); + const editable = + !!active.isContentEditable || + tag === "textarea" || + (tag === "input" && type !== "hidden"); + return { + id: typeof active.id === "string" ? active.id : "", + tag, + type, + editable: editable ? "true" : "false", + trackedFocusStateId: + window.__cmuxAddressBarFocusState && + typeof window.__cmuxAddressBarFocusState.id === "string" + ? window.__cmuxAddressBarFocusState.id + : "", + focusTrackerInstalled: + window.__cmuxAddressBarFocusTrackerInstalled === true ? "true" : "false" + }; + } catch (_) { + return { + id: "", + tag: "", + type: "", + editable: "false", + trackedFocusStateId: "", + focusTrackerInstalled: "false" + }; } - const tag = (active.tagName || "").toLowerCase(); - const type = (active.type || "").toLowerCase(); - const editable = - !!active.isContentEditable || - tag === "textarea" || - (tag === "input" && type !== "hidden"); - return { - id: typeof active.id === "string" ? active.id : "", - tag, - type, - editable: editable ? "true" : "false", - trackedFocusStateId: - window.__cmuxAddressBarFocusState && - typeof window.__cmuxAddressBarFocusState.id === "string" - ? window.__cmuxAddressBarFocusState.id - : "", - focusTrackerInstalled: - window.__cmuxAddressBarFocusTrackerInstalled === true ? "true" : "false" - }; - } catch (_) { - return { - id: "", - tag: "", - type: "", - editable: "false", - trackedFocusStateId: "", - focusTrackerInstalled: "false" - }; + }; + const matchesExpectation = (state) => + !expectedInputId || (typeof expectedInputId === "string" && state.id === expectedInputId); + + const initial = snapshot(); + if (matchesExpectation(initial)) { + return initial; } + + return new Promise((resolve) => { + let finished = false; + let observer = null; + const cleanups = []; + const finish = (value) => { + if (finished) return; + finished = true; + if (observer) observer.disconnect(); + for (const cleanup of cleanups) { + try { cleanup(); } catch (_) {} + } + resolve(value); + }; + const maybeFinish = () => { + const state = snapshot(); + if (matchesExpectation(state)) { + finish(state); + } + }; + const addListener = (target, eventName, options) => { + if (!target || typeof target.addEventListener !== "function") return; + const handler = () => maybeFinish(); + target.addEventListener(eventName, handler, options); + cleanups.push(() => target.removeEventListener(eventName, handler, options)); + }; + try { + observer = new MutationObserver(() => maybeFinish()); + observer.observe(document.documentElement || document, { + childList: true, + subtree: true, + attributes: true, + characterData: true + }); + } catch (_) {} + addListener(document, "focusin", true); + addListener(document, "focusout", true); + addListener(document, "selectionchange", true); + addListener(document, "readystatechange", true); + addListener(window, "load", true); + const timeoutId = window.setTimeout(() => finish(snapshot()), 1500); + cleanups.push(() => window.clearTimeout(timeoutId)); + maybeFinish(); + }); })(); """ @@ -7177,17 +7461,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func recordGotoSplitZoomIfNeeded() { guard isGotoSplitUITestRecordingEnabled() else { return } - recordGotoSplitZoomRetry(attempt: 0) - } - - private func recordGotoSplitZoomRetry(attempt: Int) { - let delays: [Double] = [0.05, 0.1, 0.2, 0.35, 0.5] - let delay = attempt < delays.count ? delays[attempt] : delays.last! - - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self, - let workspace = self.tabManager?.selectedWorkspace else { return } + guard let workspace = tabManager?.selectedWorkspace else { return } + func snapshot(for workspace: Workspace) -> ([String: String], Bool) { let browserPanel = workspace.panels.values.compactMap { $0 as? BrowserPanel }.first let otherTerminal = workspace.panels.values.compactMap { $0 as? TerminalPanel }.first let browserSnapshot = browserPanel.flatMap { @@ -7240,13 +7516,70 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return browserRestored && terminalRestored }() - if !settled && attempt < delays.count - 1 { - self.recordGotoSplitZoomRetry(attempt: attempt + 1) - return - } + return (updates, settled) + } + var resolved = false + var observers: [NSObjectProtocol] = [] + var panelsCancellable: AnyCancellable? + + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + panelsCancellable?.cancel() + panelsCancellable = nil + } + + @MainActor + func finish(with updates: [String: String]) { + guard !resolved else { return } + resolved = true + cleanup() self.writeGotoSplitTestData(updates) } + + @MainActor + func evaluate() { + guard !resolved, let currentWorkspace = self.tabManager?.selectedWorkspace else { return } + let (updates, settled) = snapshot(for: currentWorkspace) + guard settled else { return } + finish(with: updates) + } + + observers.append(NotificationCenter.default.addObserver( + forName: NSWindow.didUpdateNotification, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in evaluate() } + }) + observers.append(NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in evaluate() } + }) + observers.append(NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in evaluate() } + }) + panelsCancellable = workspace.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in evaluate() } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + guard let self else { return } + Task { @MainActor in + guard !resolved, let currentWorkspace = self.tabManager?.selectedWorkspace else { return } + finish(with: snapshot(for: currentWorkspace).0) + } + } + Task { @MainActor in evaluate() } } private func writeGotoSplitTestData(_ updates: [String: String]) { @@ -7277,16 +7610,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent try? FileManager.default.removeItem(atPath: path) - let contextDeadline = Date().addingTimeInterval(8.0) func waitForContexts(minCount: Int, _ completion: @escaping () -> Void) { - if mainWindowContexts.count >= minCount, - mainWindowContexts.values.allSatisfy({ $0.window != nil }) { + let isReady = { + self.mainWindowContexts.count >= minCount && + self.mainWindowContexts.values.allSatisfy { $0.window != nil } + } + guard !isReady() else { completion() return } - guard Date() < contextDeadline else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - waitForContexts(minCount: minCount, completion) + + var resolved = false + var observer: NSObjectProtocol? + let finish = { + guard !resolved else { return } + resolved = true + if let observer { + NotificationCenter.default.removeObserver(observer) + } + completion() + } + observer = NotificationCenter.default.addObserver( + forName: .mainWindowContextsDidChange, + object: self, + queue: .main + ) { _ in + if isReady() { + finish() + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 8.0) { + if isReady() { + finish() + } else if let observer, !resolved { + NotificationCenter.default.removeObserver(observer) + } } } @@ -7296,8 +7654,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent timeout: TimeInterval = 8.0, _ completion: @escaping (UUID) -> Void ) { - let deadline = Date().addingTimeInterval(timeout) - func resolvedSurfaceId() -> UUID? { if let surfaceId = tabManager.focusedPanelId(for: tabId) { return surfaceId @@ -7321,18 +7677,73 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent .first } - func poll() { - if let surfaceId = resolvedSurfaceId() { - completion(surfaceId) - return + if let surfaceId = resolvedSurfaceId() { + completion(surfaceId) + return + } + + var resolved = false + var focusObserver: NSObjectProtocol? + var surfaceReadyObserver: NSObjectProtocol? + var tabsCancellable: AnyCancellable? + var panelsCancellable: AnyCancellable? + var observedWorkspaceId: UUID? + + func cleanup() { + if let focusObserver { + NotificationCenter.default.removeObserver(focusObserver) } - guard Date() < deadline else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - poll() + if let surfaceReadyObserver { + NotificationCenter.default.removeObserver(surfaceReadyObserver) + } + tabsCancellable?.cancel() + panelsCancellable?.cancel() + } + + func attemptResolve() { + guard !resolved else { return } + if let workspace = tabManager.tabs.first(where: { $0.id == tabId }), + observedWorkspaceId != workspace.id { + observedWorkspaceId = workspace.id + panelsCancellable?.cancel() + panelsCancellable = workspace.$panels + .map { _ in () } + .sink { _ in attemptResolve() } + } + if let surfaceId = resolvedSurfaceId() { + resolved = true + cleanup() + completion(surfaceId) } } - poll() + tabsCancellable = tabManager.$tabs + .map { _ in () } + .sink { _ in attemptResolve() } + focusObserver = NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { note in + guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + candidateTabId == tabId else { return } + attemptResolve() + } + surfaceReadyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { note in + guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, + workspaceId == tabId else { return } + attemptResolve() + } + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { + if !resolved { + cleanup() + } + } + attemptResolve() } waitForContexts(minCount: 1) { [weak self] in @@ -7421,12 +7832,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ], at: path) } - func poll() { + var resolved = false + var observers: [NSObjectProtocol] = [] + var selectedTabCancellable: AnyCancellable? + var panelsCancellable: AnyCancellable? + + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + selectedTabCancellable?.cancel() + panelsCancellable?.cancel() + } + + func attemptFocus() { + guard !resolved else { return } guard let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { + resolved = true + cleanup() publish(ready: false, failure: "workspace_missing") return } + panelsCancellable?.cancel() + panelsCancellable = workspace.$panels + .map { _ in () } + .sink { _ in attemptFocus() } guard let terminalPanel = workspace.terminalPanel(for: surfaceId) else { + resolved = true + cleanup() publish(ready: false, failure: "terminal_missing") return } @@ -7436,11 +7868,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return NSApp.keyWindow === window || NSApp.mainWindow === window }() if isWindowFrontmost && terminalPanel.hostedView.isSurfaceViewFirstResponder() { + resolved = true + cleanup() publish(ready: true) return } guard Date() < deadline else { + resolved = true + cleanup() publish( ready: false, failure: isWindowFrontmost ? "terminal_not_first_responder" : "window_not_frontmost" @@ -7453,13 +7889,57 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent tabManager.selectTab(tab) tabManager.focusSurface(tabId: tabId, surfaceId: surfaceId) } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - poll() - } } - poll() + observers.append(NotificationCenter.default.addObserver( + forName: .mainWindowContextsDidChange, + object: self, + queue: .main + ) { _ in + attemptFocus() + }) + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidBecomeFirstResponderSurface, + object: nil, + queue: .main + ) { note in + guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + candidateTabId == tabId, + candidateSurfaceId == surfaceId else { return } + attemptFocus() + }) + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { note in + guard let candidateTabId = note.userInfo?[GhosttyNotificationKey.tabId] as? UUID, + let candidateSurfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + candidateTabId == tabId, + candidateSurfaceId == surfaceId else { return } + attemptFocus() + }) + observers.append(NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { note in + guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, + let readySurfaceId = note.userInfo?["surfaceId"] as? UUID, + workspaceId == tabId, + readySurfaceId == surfaceId else { return } + attemptFocus() + }) + selectedTabCancellable = tabManager.$selectedTabId + .map { _ in () } + .sink { _ in attemptFocus() } + DispatchQueue.main.asyncAfter(deadline: .now() + 8.0) { + if !resolved { + attemptFocus() + } + } + attemptFocus() } private func publishMultiWindowNotificationSocketStateIfNeeded(at path: String) { @@ -7488,16 +7968,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "socketPingResponse": "", ], at: path) - restartSocketListenerIfEnabled(source: "uiTest.multiWindowNotifications.setup") + let socketPath = config.path + let socketMode = config.mode.rawValue + var observer: NSObjectProtocol? + var timeoutWorkItem: DispatchWorkItem? - let deadline = Date().addingTimeInterval(20.0) - func publish() { - let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: config.path) - let isTimedOut = Date() >= deadline - let socketPath = config.path - let socketMode = config.mode.rawValue + func publishCurrentState(isTimedOut: Bool) { + let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: socketPath) let dataPath = path - DispatchQueue.global(qos: .utility).async { [weak self] in let pingResponse = health.isHealthy ? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) @@ -7524,15 +8002,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent "socketPathExists": health.socketPathExists ? "1" : "0", "socketFailureSignals": failureSignals, ], at: dataPath) - guard !isTimedOut, !isReady else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - publish() + guard isReady || isTimedOut else { return } + timeoutWorkItem?.cancel() + if let observer { + NotificationCenter.default.removeObserver(observer) } } } } - publish() + observer = NotificationCenter.default.addObserver( + forName: .socketListenerDidStart, + object: TerminalController.shared, + queue: .main + ) { notification in + let startedPath = notification.userInfo?["path"] as? String + guard startedPath == socketPath else { return } + publishCurrentState(isTimedOut: false) + } + + let timeout = DispatchWorkItem { + publishCurrentState(isTimedOut: true) + } + timeoutWorkItem = timeout + DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: timeout) + + restartSocketListenerIfEnabled(source: "uiTest.multiWindowNotifications.setup") + publishCurrentState(isTimedOut: false) } private func writeMultiWindowNotificationTestData(_ updates: [String: String], at path: String) { @@ -8434,13 +8930,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent targetWindow.identifier?.rawValue == "cmux.settings" { targetWindow.performClose(nil) } else { - let responder = event.window?.firstResponder - ?? NSApp.keyWindow?.firstResponder - ?? NSApp.mainWindow?.firstResponder - if let ghosttyView = cmuxOwningGhosttyView(for: responder), - let workspaceId = ghosttyView.tabId, - let manager = tabManagerFor(tabId: workspaceId) ?? tabManager { - manager.closeOtherTabsInFocusedPaneWithConfirmation() + let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + if let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) { + terminalContext.tabManager.closeOtherTabsInFocusedPaneWithConfirmation() } else { tabManager?.closeOtherTabsInFocusedPaneWithConfirmation() } @@ -8469,20 +8961,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent cmuxWindowShouldOwnCloseShortcut(targetWindow) { targetWindow.performClose(nil) } else { - let responder = event.window?.firstResponder - ?? NSApp.keyWindow?.firstResponder - ?? NSApp.mainWindow?.firstResponder - if let ghosttyView = cmuxOwningGhosttyView(for: responder), - let workspaceId = ghosttyView.tabId, - let panelId = ghosttyView.terminalSurface?.id, - let manager = tabManagerFor(tabId: workspaceId) ?? tabManager { + let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + if let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) { #if DEBUG dlog( - "shortcut.cmdW route=ghostty workspace=\(workspaceId.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) selected=\(manager.selectedTabId?.uuidString.prefix(5) ?? "nil")" + "shortcut.cmdW route=ghostty workspace=\(terminalContext.workspaceId.uuidString.prefix(5)) " + + "panel=\(terminalContext.panelId.uuidString.prefix(5)) selected=\(terminalContext.tabManager.selectedTabId?.uuidString.prefix(5) ?? "nil")" ) #endif - manager.closePanelWithConfirmation(tabId: workspaceId, surfaceId: panelId) + terminalContext.tabManager.closePanelWithConfirmation( + tabId: terminalContext.workspaceId, + surfaceId: terminalContext.panelId + ) } else { #if DEBUG dlog("shortcut.cmdW route=focusedPanelFallback") @@ -8609,7 +9099,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .right) { return true } - _ = performSplitShortcut(direction: .right) + _ = performSplitShortcut( + direction: .right, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) return true } @@ -8620,7 +9113,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if shouldSuppressSplitShortcutForTransientTerminalFocusState(direction: .down) { return true } - _ = performSplitShortcut(direction: .down) + _ = performSplitShortcut( + direction: .down, + preferredWindow: event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) return true } @@ -9274,8 +9770,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } @discardableResult - func performSplitShortcut(direction: SplitDirection) -> Bool { - _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) + func performSplitShortcut(direction: SplitDirection, preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let terminalContext = focusedTerminalShortcutContext(preferredWindow: targetWindow) + _ = synchronizeActiveMainWindowContext(preferredWindow: targetWindow) let directionLabel: String switch direction { @@ -9310,7 +9808,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif prepareFocusedBrowserDevToolsForSplit(directionLabel: directionLabel) - tabManager?.createSplit(direction: direction) + let didCreateSplit: Bool = { + if let terminalContext { + return terminalContext.tabManager.createSplit( + tabId: terminalContext.workspaceId, + surfaceId: terminalContext.panelId, + direction: direction + ) != nil + } + return tabManager?.createSplit(direction: direction) != nil + }() #if DEBUG DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in let keyWindow = NSApp.keyWindow @@ -9337,7 +9844,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } recordGotoSplitSplitIfNeeded(direction: direction) #endif - return true + return didCreateSplit } @discardableResult @@ -10229,8 +10736,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func recordJumpUnreadFocusFromModelIfNeeded( tabManager: TabManager, tabId: UUID, - expectedSurfaceId: UUID?, - attempt: Int = 0 + expectedSurfaceId: UUID? ) { let env = ProcessInfo.processInfo.environment guard env["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" else { return } @@ -10239,24 +10745,61 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Ensure the expectation is armed even if the view doesn't become first responder. armJumpUnreadFocusRecord(tabId: tabId, surfaceId: expectedSurfaceId) - let maxAttempts = 40 - guard attempt < maxAttempts else { return } - - let isSelected = tabManager.selectedTabId == tabId - let focused = tabManager.focusedSurfaceId(for: tabId) - if isSelected, focused == expectedSurfaceId { + if tabManager.selectedTabId == tabId, + tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId { recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.recordJumpUnreadFocusFromModelIfNeeded( - tabManager: tabManager, - tabId: tabId, - expectedSurfaceId: expectedSurfaceId, - attempt: attempt + 1 - ) + var resolved = false + var observers: [NSObjectProtocol] = [] + var cancellables: [AnyCancellable] = [] + + func cleanup() { + observers.forEach { NotificationCenter.default.removeObserver($0) } + observers.removeAll() + cancellables.forEach { $0.cancel() } + cancellables.removeAll() } + + @MainActor + func finishIfFocused() { + guard !resolved else { return } + guard tabManager.selectedTabId == tabId, + tabManager.focusedSurfaceId(for: tabId) == expectedSurfaceId else { + return + } + resolved = true + cleanup() + self.recordJumpUnreadFocusIfExpected(tabId: tabId, surfaceId: expectedSurfaceId) + } + + observers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidFocusSurface, + object: nil, + queue: .main + ) { note in + guard let surfaceId = note.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID, + surfaceId == expectedSurfaceId else { return } + Task { @MainActor in finishIfFocused() } + }) + cancellables.append(tabManager.$selectedTabId.sink { _ in + Task { @MainActor in finishIfFocused() } + }) + if let workspace = tabManager.tabs.first(where: { $0.id == tabId }) { + cancellables.append(workspace.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in finishIfFocused() } + }) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + Task { @MainActor in + guard !resolved else { return } + cleanup() + } + } + Task { @MainActor in finishIfFocused() } } #endif @@ -11483,12 +12026,17 @@ private extension NSWindow { let portalWebView = cmuxUniqueBrowserWebView(in: candidate) { // Portal-hosted browser chrome (for example the Cmd+F overlay) is a // sibling of the hosted WKWebView inside WindowBrowserSlotView, not a - // descendant of it. Treating every view in that slot as "web-owned" - // blocks legitimate first-responder changes to overlay text fields. + // descendant of it. Allow native text-entry controls in that slot to + // acquire first responder directly, but keep generic sibling views + // associated with the hosted web view so blocked browser focus policy + // still protects inspector/overlay chrome from stray focus changes. if view === portalWebView || view.isDescendant(of: portalWebView) { return portalWebView } - return nil + if cmuxAllowsPortalSlotTextEntryFocus(view) { + return nil + } + return portalWebView } current = candidate.superview } @@ -11496,6 +12044,20 @@ private extension NSWindow { return nil } + private static func cmuxAllowsPortalSlotTextEntryFocus(_ view: NSView) -> Bool { + var current: NSView? = view + while let candidate = current { + if let textField = candidate as? NSTextField { + return textField.isEditable || textField.acceptsFirstResponder + } + if let textView = candidate as? NSTextView { + return textView.isEditable || textView.isSelectable || textView.isFieldEditor + } + current = candidate.superview + } + return false + } + private static func cmuxUniqueBrowserWebView(in root: NSView) -> CmuxWebView? { var stack: [NSView] = [root] var found: CmuxWebView? diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 07393dbe..e68dbbdc 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -3546,6 +3546,10 @@ enum BrowserWindowPortalRegistry { private static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:] private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] + private static func postRegistryDidChange(for webView: WKWebView) { + NotificationCenter.default.post(name: .browserPortalRegistryDidChange, object: webView) + } + private static func installWindowCloseObserverIfNeeded(for window: NSWindow) { guard objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) == nil else { return } let windowId = ObjectIdentifier(window) @@ -3623,6 +3627,7 @@ enum BrowserWindowPortalRegistry { nextPortal.bind(webView: webView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority) webViewToWindowId[webViewId] = windowId pruneWebViewMappings(for: windowId, validWebViewIds: nextPortal.webViewIds()) + postRegistryDidChange(for: webView) } static func synchronizeForAnchor(_ anchorView: NSView) { @@ -3638,6 +3643,7 @@ enum BrowserWindowPortalRegistry { guard let windowId = webViewToWindowId[webViewId], let portal = portalsByWindowId[windowId] else { return } portal.updateEntryVisibility(forWebViewId: webViewId, visibleInUI: visibleInUI, zPriority: zPriority) + postRegistryDidChange(for: webView) } static func isWebView(_ webView: WKWebView, boundTo anchorView: NSView) -> Bool { @@ -3654,6 +3660,7 @@ enum BrowserWindowPortalRegistry { guard let windowId = webViewToWindowId[webViewId], let portal = portalsByWindowId[windowId] else { return } portal.hideWebView(withId: webViewId, source: source) + postRegistryDidChange(for: webView) } static func updateDropZoneOverlay(for webView: WKWebView, zone: DropZone?) { @@ -3704,6 +3711,7 @@ enum BrowserWindowPortalRegistry { let webViewId = ObjectIdentifier(webView) guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return } portalsByWindowId[windowId]?.detachWebView(withId: webViewId) + postRegistryDidChange(for: webView) } static func webViewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> WKWebView? { @@ -3717,6 +3725,7 @@ enum BrowserWindowPortalRegistry { guard let windowId = webViewToWindowId[webViewId], let portal = portalsByWindowId[windowId] else { return } portal.forceRefreshWebView(withId: webViewId, reason: reason) + postRegistryDidChange(for: webView) } static func debugSnapshot(for webView: WKWebView) -> DebugSnapshot? { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a50f275c..8146541b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1,5 +1,6 @@ import AppKit import Bonsplit +import Combine import ImageIO import SwiftUI import ObjectiveC @@ -74,6 +75,43 @@ func cmuxAccentColor() -> Color { Color(nsColor: cmuxAccentNSColor()) } +struct SidebarRemoteErrorCopyEntry: Equatable { + let workspaceTitle: String + let target: String + let detail: String +} + +enum SidebarRemoteErrorCopySupport { + static func menuLabel(for entries: [SidebarRemoteErrorCopyEntry]) -> String? { + guard !entries.isEmpty else { return nil } + if entries.count == 1 { + return String(localized: "contextMenu.copyError", defaultValue: "Copy Error") + } + return String(localized: "contextMenu.copyErrors", defaultValue: "Copy Errors") + } + + static func clipboardText(for entries: [SidebarRemoteErrorCopyEntry]) -> String? { + guard !entries.isEmpty else { return nil } + if entries.count == 1, let entry = entries.first { + return String.localizedStringWithFormat( + String(localized: "clipboard.sshError.single", defaultValue: "SSH error (%@): %@"), + entry.target, + entry.detail + ) + } + + return entries.enumerated().map { index, entry in + String.localizedStringWithFormat( + String(localized: "clipboard.sshError.item", defaultValue: "%lld. %@ (%@): %@"), + Int64(index + 1), + entry.workspaceTitle, + entry.target, + entry.detail + ) + }.joined(separator: "\n") + } +} + func sidebarSelectedWorkspaceBackgroundNSColor(for colorScheme: ColorScheme) -> NSColor { cmuxAccentNSColor(for: colorScheme) } @@ -1331,7 +1369,6 @@ struct ContentView: View { @State private var workspaceHandoffGeneration: UInt64 = 0 @State private var workspaceHandoffFallbackTask: Task<Void, Never>? @State private var didApplyUITestSidebarSelection = false - @State private var workspaceHandoffReadyCheckTask: Task<Void, Never>? @State private var titlebarThemeGeneration: UInt64 = 0 @State private var sidebarDraggedTabId: UUID? @State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) @@ -1359,6 +1396,9 @@ struct ContentView: View { @State private var commandPaletteVisibleResultsFingerprint: Int? @State private var cachedCommandPaletteScope: CommandPaletteListScope? @State private var cachedCommandPaletteFingerprint: Int? + @State private var commandPalettePendingDismissFocusTarget: CommandPaletteRestoreFocusTarget? + @State private var commandPaletteRestoreTimeoutWorkItem: DispatchWorkItem? + @State private var commandPalettePendingTextSelectionBehavior: CommandPaletteTextSelectionBehavior? @State private var commandPaletteSearchTask: Task<Void, Never>? @State private var commandPaletteSearchRequestID: UInt64 = 0 @State private var commandPaletteResolvedSearchRequestID: UInt64 = 0 @@ -1946,6 +1986,7 @@ struct ContentView: View { lastSidebarSelectionIndex: $lastSidebarSelectionIndex ) .frame(width: sidebarWidth) + .frame(maxHeight: .infinity, alignment: .topLeading) } /// Space at top of content area for the titlebar. This must be at least the actual titlebar @@ -1964,16 +2005,26 @@ struct ContentView: View { let isSelectedWorkspace = selectedWorkspaceId == tab.id let isRetiringWorkspace = retiringWorkspaceId == tab.id let shouldPrimeInBackground = tabManager.pendingBackgroundWorkspaceLoadIds.contains(tab.id) + let isRenderedVisible = isSelectedWorkspace || isRetiringWorkspace + let isWorkspaceVisibleToPanels = isRenderedVisible || shouldPrimeInBackground + let workspaceRenderOpacity: Double = { + if isRenderedVisible { + return 1 + } + if shouldPrimeInBackground { + return 0.001 + } + return 0 + }() // Keep the retiring workspace visible during handoff, but never input-active. // Allowing both selected+retiring workspaces to be input-active lets the // old workspace steal first responder (notably with WKWebView), which can // delay handoff completion and make browser returns feel laggy. let isInputActive = isSelectedWorkspace - let isVisible = isSelectedWorkspace || isRetiringWorkspace let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0) WorkspaceContentView( workspace: tab, - isWorkspaceVisible: isVisible, + isWorkspaceVisible: isWorkspaceVisibleToPanels, isWorkspaceInputActive: isInputActive, workspacePortalPriority: portalPriority, onThemeRefreshRequest: { reason, eventId, source, payloadHex in @@ -1986,9 +2037,9 @@ struct ContentView: View { ) } ) - .opacity(isVisible ? 1 : 0) + .opacity(workspaceRenderOpacity) .allowsHitTesting(isSelectedWorkspace) - .accessibilityHidden(!isVisible) + .accessibilityHidden(!isRenderedVisible) .zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)) .task(id: shouldPrimeInBackground ? tab.id : nil) { await primeBackgroundWorkspaceIfNeeded(workspaceId: tab.id) @@ -2369,6 +2420,7 @@ struct ContentView: View { guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "focus") + attemptCommandPaletteFocusRestoreIfNeeded() scheduleTitlebarTextRefresh() }) @@ -2383,6 +2435,7 @@ struct ContentView: View { guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID, tabId == tabManager.selectedTabId else { return } completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "first_responder") + attemptCommandPaletteFocusRestoreIfNeeded() }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidBecomeFirstResponderWebView)) { notification in @@ -2393,6 +2446,7 @@ struct ContentView: View { let focusedBrowser = selectedWorkspace.browserPanel(for: focusedPanelId), focusedBrowser.webView === webView else { return } completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_first_responder") + attemptCommandPaletteFocusRestoreIfNeeded() }) view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidFocusAddressBar)) { notification in @@ -2402,6 +2456,36 @@ struct ContentView: View { selectedWorkspace.focusedPanelId == panelId, selectedWorkspace.browserPanel(for: panelId) != nil else { return } completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_address_bar") + attemptCommandPaletteFocusRestoreIfNeeded() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher( + for: NSWindow.didBecomeKeyNotification, + object: observedWindow + )) { _ in + attemptCommandPaletteFocusRestoreIfNeeded() + attemptCommandPaletteTextSelectionIfNeeded() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: NSText.didBeginEditingNotification)) { notification in + guard commandPalettePendingTextSelectionBehavior != nil else { return } + guard let editor = notification.object as? NSTextView, + editor.isFieldEditor else { return } + guard let observedWindow else { return } + guard editor.window === observedWindow else { return } + attemptCommandPaletteTextSelectionIfNeeded() + }) + + view = AnyView(view.onChange(of: isCommandPaletteSearchFocused) { _, focused in + if focused { + attemptCommandPaletteTextSelectionIfNeeded() + } + }) + + view = AnyView(view.onChange(of: isCommandPaletteRenameFocused) { _, focused in + if focused { + attemptCommandPaletteTextSelectionIfNeeded() + } }) view = AnyView(view.onReceive(tabManager.$tabs) { tabs in @@ -2788,7 +2872,6 @@ struct ContentView: View { private enum BackgroundWorkspacePrimePolicy { static let timeoutSeconds: TimeInterval = 2.0 - static let pollIntervalNanoseconds: UInt64 = 50_000_000 } private func primeBackgroundWorkspaceIfNeeded(workspaceId: UUID) async { @@ -2802,39 +2885,26 @@ struct ContentView: View { dlog("workspace.backgroundPrime.start workspace=\(workspaceId.uuidString.prefix(5))") #endif - let timeout = Date().addingTimeInterval(BackgroundWorkspacePrimePolicy.timeoutSeconds) - while !Task.isCancelled { - let state = await MainActor.run { - stepBackgroundWorkspacePrime(workspaceId: workspaceId) - } - switch state { - case .pending: - if Date() < timeout { - try? await Task.sleep(nanoseconds: BackgroundWorkspacePrimePolicy.pollIntervalNanoseconds) - continue - } - await MainActor.run { - tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) - } -#if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000 - dlog( - "workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " + - "reason=timeout ms=\(String(format: "%.2f", elapsedMs))" - ) -#endif - return - case .completed(let reason): -#if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000 - dlog( - "workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " + - "reason=\(reason) ms=\(String(format: "%.2f", elapsedMs))" - ) -#endif - return - } + let initialState = await MainActor.run { + stepBackgroundWorkspacePrime(workspaceId: workspaceId) } + let completionReason: String + switch initialState { + case .completed(let reason): + completionReason = reason + case .pending: + completionReason = await waitForBackgroundWorkspacePrimeCompletion( + workspaceId: workspaceId, + timeoutSeconds: BackgroundWorkspacePrimePolicy.timeoutSeconds + ) + } +#if DEBUG + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000 + dlog( + "workspace.backgroundPrime.finish workspace=\(workspaceId.uuidString.prefix(5)) " + + "reason=\(completionReason) ms=\(String(format: "%.2f", elapsedMs))" + ) +#endif } @MainActor @@ -2856,6 +2926,114 @@ struct ContentView: View { return .completed(reason: "surface_ready") } + @MainActor + private func waitForBackgroundWorkspacePrimeCompletion( + workspaceId: UUID, + timeoutSeconds: TimeInterval + ) async -> String { + await withCheckedContinuation { (continuation: CheckedContinuation<String, Never>) in + var resolved = false + var workspacePanelsCancellable: AnyCancellable? + var pendingLoadsCancellable: AnyCancellable? + var tabsCancellable: AnyCancellable? + var readyObserver: NSObjectProtocol? + var hostedViewObserver: NSObjectProtocol? + var timeoutWorkItem: DispatchWorkItem? + + @MainActor + func finish(_ reason: String) { + guard !resolved else { return } + resolved = true + workspacePanelsCancellable?.cancel() + pendingLoadsCancellable?.cancel() + tabsCancellable?.cancel() + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + if let hostedViewObserver { + NotificationCenter.default.removeObserver(hostedViewObserver) + } + timeoutWorkItem?.cancel() + continuation.resume(returning: reason) + } + + @MainActor + func evaluate() { + switch stepBackgroundWorkspacePrime(workspaceId: workspaceId) { + case .pending: + break + case .completed(let reason): + finish(reason) + } + } + + if let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) { + workspacePanelsCancellable = workspace.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in + evaluate() + } + } + } + + pendingLoadsCancellable = tabManager.$pendingBackgroundWorkspaceLoadIds + .map { _ in () } + .sink { _ in + Task { @MainActor in + evaluate() + } + } + + tabsCancellable = tabManager.$tabs + .map { _ in () } + .sink { _ in + Task { @MainActor in + evaluate() + } + } + + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { notification in + guard let readyWorkspaceId = notification.userInfo?["workspaceId"] as? UUID, + readyWorkspaceId == workspaceId else { return } + Task { @MainActor in + evaluate() + } + } + + hostedViewObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { notification in + guard let hostedWorkspaceId = notification.userInfo?["workspaceId"] as? UUID, + hostedWorkspaceId == workspaceId else { return } + Task { @MainActor in + evaluate() + } + } + + let timeoutWork = DispatchWorkItem { + Task { @MainActor in + if tabManager.pendingBackgroundWorkspaceLoadIds.contains(workspaceId) { + tabManager.completeBackgroundWorkspaceLoad(for: workspaceId) + } + finish("timeout") + } + } + timeoutWorkItem = timeoutWork + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds, execute: timeoutWork) + + Task { @MainActor in + evaluate() + } + } + } + private func addTab() { tabManager.addTab() sidebarSelectionState.selection = .tabs @@ -2897,8 +3075,6 @@ struct ContentView: View { retiringWorkspaceId = nil workspaceHandoffFallbackTask?.cancel() workspaceHandoffFallbackTask = nil - workspaceHandoffReadyCheckTask?.cancel() - workspaceHandoffReadyCheckTask = nil return } @@ -2906,7 +3082,6 @@ struct ContentView: View { let generation = workspaceHandoffGeneration retiringWorkspaceId = oldSelectedId workspaceHandoffFallbackTask?.cancel() - workspaceHandoffReadyCheckTask?.cancel() #if DEBUG if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { @@ -2922,34 +3097,19 @@ struct ContentView: View { } #endif - workspaceHandoffReadyCheckTask = Task { [generation, newSelectedId] in - for delay in [0, 20_000_000, 40_000_000, 60_000_000] { - if delay > 0 { - do { - try await Task.sleep(nanoseconds: UInt64(delay)) - } catch { - return - } - } - let completed = await MainActor.run { () -> Bool in - guard workspaceHandoffGeneration == generation else { return false } - guard retiringWorkspaceId != nil else { return false } - guard canCompleteWorkspaceHandoffImmediately(for: newSelectedId) else { return false } + if canCompleteWorkspaceHandoffImmediately(for: newSelectedId) { #if DEBUG - if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { - let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 - dlog( - "ws.handoff.fastReady id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newSelectedId))" - ) - } else { - dlog("ws.handoff.fastReady id=none selected=\(debugShortWorkspaceId(newSelectedId))") - } -#endif - completeWorkspaceHandoff(reason: "ready") - return true - } - if completed { return } + if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.handoff.fastReady id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newSelectedId))" + ) + } else { + dlog("ws.handoff.fastReady id=none selected=\(debugShortWorkspaceId(newSelectedId))") } +#endif + completeWorkspaceHandoff(reason: "ready") + return } workspaceHandoffFallbackTask = Task { [generation] in @@ -2983,8 +3143,6 @@ struct ContentView: View { private func completeWorkspaceHandoff(reason: String) { workspaceHandoffFallbackTask?.cancel() workspaceHandoffFallbackTask = nil - workspaceHandoffReadyCheckTask?.cancel() - workspaceHandoffReadyCheckTask = nil let retiring = retiringWorkspaceId // Hide portal-hosted views for the retiring workspace BEFORE clearing @@ -6191,6 +6349,7 @@ struct ContentView: View { commandPaletteVisibleResultsFingerprint = nil cachedCommandPaletteScope = nil cachedCommandPaletteFingerprint = nil + commandPalettePendingTextSelectionBehavior = nil commandPaletteResolvedSearchRequestID = commandPaletteSearchRequestID commandPaletteResolvedSearchScope = nil commandPaletteResolvedSearchFingerprint = nil @@ -6203,7 +6362,7 @@ struct ContentView: View { syncCommandPaletteDebugStateForObservedWindow() guard restoreFocus, let focusTarget else { return } - restoreCommandPaletteFocus(target: focusTarget, attemptsRemaining: 6) + requestCommandPaletteFocusRestore(target: focusTarget) } private func handleCommandPaletteBackdropClick(atContentPoint contentPoint: CGPoint) { @@ -6338,38 +6497,42 @@ struct ContentView: View { ) } - private func restoreCommandPaletteFocus( - target: CommandPaletteRestoreFocusTarget, - attemptsRemaining: Int - ) { + private func requestCommandPaletteFocusRestore(target: CommandPaletteRestoreFocusTarget) { + commandPalettePendingDismissFocusTarget = target + commandPaletteRestoreTimeoutWorkItem?.cancel() + let timeoutWork = DispatchWorkItem { + commandPalettePendingDismissFocusTarget = nil + commandPaletteRestoreTimeoutWorkItem = nil + } + commandPaletteRestoreTimeoutWorkItem = timeoutWork + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: timeoutWork) + attemptCommandPaletteFocusRestoreIfNeeded() + } + + private func attemptCommandPaletteFocusRestoreIfNeeded() { guard !isCommandPalettePresented else { return } - guard tabManager.tabs.contains(where: { $0.id == target.workspaceId }) else { return } + guard let target = commandPalettePendingDismissFocusTarget else { return } + guard tabManager.tabs.contains(where: { $0.id == target.workspaceId }) else { + commandPalettePendingDismissFocusTarget = nil + commandPaletteRestoreTimeoutWorkItem?.cancel() + commandPaletteRestoreTimeoutWorkItem = nil + return + } if let window = observedWindow, !window.isKeyWindow { window.makeKeyAndOrderFront(nil) } tabManager.focusTab(target.workspaceId, surfaceId: target.panelId, suppressFlash: true) - if let context = focusedPanelContext, - context.workspace.id == target.workspaceId, - context.panelId == target.panelId { - if context.panel.restoreFocusIntent(target.intent) { - return - } - } - - guard attemptsRemaining > 0 else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { - guard !isCommandPalettePresented else { return } - if let context = focusedPanelContext, - context.workspace.id == target.workspaceId, - context.panelId == target.panelId { - if context.panel.restoreFocusIntent(target.intent) { - return - } - } - restoreCommandPaletteFocus(target: target, attemptsRemaining: attemptsRemaining - 1) + guard let context = focusedPanelContext, + context.workspace.id == target.workspaceId, + context.panelId == target.panelId else { + return } + guard context.panel.restoreFocusIntent(target.intent) else { return } + commandPalettePendingDismissFocusTarget = nil + commandPaletteRestoreTimeoutWorkItem?.cancel() + commandPaletteRestoreTimeoutWorkItem = nil } #if DEBUG @@ -6430,11 +6593,17 @@ struct ContentView: View { } } - private func applyCommandPaletteTextSelection( - _ behavior: CommandPaletteTextSelectionBehavior, - attemptsRemaining: Int = 20 - ) { - guard isCommandPalettePresented else { return } + private func applyCommandPaletteTextSelection(_ behavior: CommandPaletteTextSelectionBehavior) { + commandPalettePendingTextSelectionBehavior = behavior + attemptCommandPaletteTextSelectionIfNeeded() + } + + private func attemptCommandPaletteTextSelectionIfNeeded() { + guard isCommandPalettePresented else { + commandPalettePendingTextSelectionBehavior = nil + return + } + guard let behavior = commandPalettePendingTextSelectionBehavior else { return } switch behavior { case .selectAll: guard case .renameInput = commandPaletteMode else { return } @@ -6448,21 +6617,18 @@ struct ContentView: View { } guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return } - if let editor = window.firstResponder as? NSTextView, editor.isFieldEditor { - let length = (editor.string as NSString).length - switch behavior { - case .selectAll: - editor.setSelectedRange(NSRange(location: 0, length: length)) - case .caretAtEnd: - editor.setSelectedRange(NSRange(location: length, length: 0)) - } + guard let editor = window.firstResponder as? NSTextView, + editor.isFieldEditor else { return } - - guard attemptsRemaining > 0 else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { - applyCommandPaletteTextSelection(behavior, attemptsRemaining: attemptsRemaining - 1) + let length = (editor.string as NSString).length + switch behavior { + case .selectAll: + editor.setSelectedRange(NSRange(location: 0, length: length)) + case .caretAtEnd: + editor.setSelectedRange(NSRange(location: length, length: 0)) } + commandPalettePendingTextSelectionBehavior = nil } private func refreshCommandPaletteUsageHistory() { @@ -7791,6 +7957,13 @@ struct VerticalTabsSidebar: View { LazyVStack(spacing: tabRowSpacing) { ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in + let selectedContextIds: Set<UUID> = selectedTabIds.contains(tab.id) ? selectedTabIds : [tab.id] + let contextTargetIds = tabManager.tabs.compactMap { workspace in + selectedContextIds.contains(workspace.id) ? workspace.id : nil + } + let remoteContextMenuTargets = tabManager.tabs.filter { workspace in + contextTargetIds.contains(workspace.id) && workspace.isRemoteWorkspace + } TabItemView( tabManager: tabManager, notificationStore: notificationStore, @@ -7820,7 +7993,10 @@ struct VerticalTabsSidebar: View { showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed, dragAutoScrollController: dragAutoScrollController, draggedTabId: $draggedTabId, - dropIndicator: $dropIndicator + dropIndicator: $dropIndicator, + remoteContextMenuWorkspaceIds: remoteContextMenuTargets.map(\.id), + allRemoteContextMenuTargetsConnecting: !remoteContextMenuTargets.isEmpty && remoteContextMenuTargets.allSatisfy { $0.remoteConnectionState == .connecting }, + allRemoteContextMenuTargetsDisconnected: !remoteContextMenuTargets.isEmpty && remoteContextMenuTargets.allSatisfy { $0.remoteConnectionState == .disconnected } ) .equatable() } @@ -7917,6 +8093,7 @@ struct VerticalTabsSidebar: View { #endif draggedTabId = nil } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private func debugShortSidebarTabId(_ id: UUID?) -> String { @@ -8387,33 +8564,43 @@ enum SidebarOutsideDropResetPolicy { } enum SidebarDragFailsafePolicy { - static let pollInterval: TimeInterval = 0.05 static let clearDelay: TimeInterval = 0.15 static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool { isDragActive && !isLeftMouseButtonDown } + + static func shouldRequestClearWhenMonitoringStarts(isLeftMouseButtonDown: Bool) -> Bool { + shouldRequestClear( + isDragActive: true, + isLeftMouseButtonDown: isLeftMouseButtonDown + ) + } + + static func shouldRequestClear(forMouseEventType eventType: NSEvent.EventType) -> Bool { + eventType == .leftMouseUp + } } @MainActor private final class SidebarDragFailsafeMonitor: ObservableObject { private static let escapeKeyCode: UInt16 = 53 - private var timer: Timer? private var pendingClearWorkItem: DispatchWorkItem? private var appResignObserver: NSObjectProtocol? private var keyDownMonitor: Any? + private var localMouseMonitor: Any? + private var globalMouseMonitor: Any? private var onRequestClear: ((String) -> Void)? func start(onRequestClear: @escaping (String) -> Void) { self.onRequestClear = onRequestClear - if timer == nil { - let timer = Timer(timeInterval: SidebarDragFailsafePolicy.pollInterval, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - self?.tick() - } - } - self.timer = timer - RunLoop.main.add(timer, forMode: .common) + if SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts( + isLeftMouseButtonDown: CGEventSource.buttonState( + .combinedSessionState, + button: .left + ) + ) { + requestClearSoon(reason: "mouse_up_failsafe") } if appResignObserver == nil { appResignObserver = NotificationCenter.default.addObserver( @@ -8434,11 +8621,25 @@ private final class SidebarDragFailsafeMonitor: ObservableObject { return event } } + if localMouseMonitor == nil { + localMouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in + if SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) { + self?.requestClearSoon(reason: "mouse_up_failsafe") + } + return event + } + } + if globalMouseMonitor == nil { + globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .leftMouseUp) { [weak self] event in + guard SidebarDragFailsafePolicy.shouldRequestClear(forMouseEventType: event.type) else { return } + Task { @MainActor [weak self] in + self?.requestClearSoon(reason: "mouse_up_failsafe") + } + } + } } func stop() { - timer?.invalidate() - timer = nil pendingClearWorkItem?.cancel() pendingClearWorkItem = nil if let appResignObserver { @@ -8449,18 +8650,17 @@ private final class SidebarDragFailsafeMonitor: ObservableObject { NSEvent.removeMonitor(keyDownMonitor) self.keyDownMonitor = nil } + if let localMouseMonitor { + NSEvent.removeMonitor(localMouseMonitor) + self.localMouseMonitor = nil + } + if let globalMouseMonitor { + NSEvent.removeMonitor(globalMouseMonitor) + self.globalMouseMonitor = nil + } onRequestClear = nil } - private func tick() { - let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left) - guard SidebarDragFailsafePolicy.shouldRequestClear( - isDragActive: true, // Monitor only runs while drag is active. - isLeftMouseButtonDown: isLeftMouseButtonDown - ) else { return } - requestClearSoon(reason: "mouse_up_failsafe") - } - private func requestClearSoon(reason: String) { guard pendingClearWorkItem == nil else { return } #if DEBUG @@ -10102,7 +10302,10 @@ private struct TabItemView: View, Equatable { lhs.unreadCount == rhs.unreadCount && lhs.latestNotificationText == rhs.latestNotificationText && lhs.rowSpacing == rhs.rowSpacing && - lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints + lhs.showsModifierShortcutHints == rhs.showsModifierShortcutHints && + lhs.remoteContextMenuWorkspaceIds == rhs.remoteContextMenuWorkspaceIds && + lhs.allRemoteContextMenuTargetsConnecting == rhs.allRemoteContextMenuTargetsConnecting && + lhs.allRemoteContextMenuTargetsDisconnected == rhs.allRemoteContextMenuTargetsDisconnected } // Use plain references instead of @EnvironmentObject to avoid subscribing @@ -10127,6 +10330,9 @@ private struct TabItemView: View, Equatable { let dragAutoScrollController: SidebarDragAutoScrollController @Binding var draggedTabId: UUID? @Binding var dropIndicator: SidebarDropIndicator? + let remoteContextMenuWorkspaceIds: [UUID] + let allRemoteContextMenuTargetsConnecting: Bool + let allRemoteContextMenuTargetsDisconnected: Bool @State private var isHovering = false @State private var rowHeight: CGFloat = 1 @AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX @@ -10139,6 +10345,7 @@ private struct TabItemView: View, Equatable { @AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true @AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey) private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser + @AppStorage("sidebarShowSSH") private var sidebarShowSSH = true @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true @AppStorage("sidebarShowLog") private var sidebarShowLog = true @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true @@ -10239,6 +10446,85 @@ private struct TabItemView: View, Equatable { ) } + private var remoteWorkspaceSidebarText: String? { + guard tab.hasActiveRemoteTerminalSessions else { return nil } + let trimmedTarget = tab.remoteDisplayTarget?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmedTarget, !trimmedTarget.isEmpty { + return trimmedTarget + } + return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "SSH workspace") + } + + private var copyableSidebarSSHError: String? { + let fallbackTarget = tab.remoteDisplayTarget ?? String( + localized: "sidebar.remote.help.targetFallback", + defaultValue: "remote host" + ) + let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) + if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty { + let entry = SidebarRemoteErrorCopyEntry( + workspaceTitle: tab.title, + target: fallbackTarget, + detail: trimmedDetail + ) + return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) + } + if let statusValue = tab.statusEntries["remote.error"]?.value + .trimmingCharacters(in: .whitespacesAndNewlines), + !statusValue.isEmpty { + let entry = SidebarRemoteErrorCopyEntry( + workspaceTitle: tab.title, + target: fallbackTarget, + detail: statusValue + ) + return SidebarRemoteErrorCopySupport.clipboardText(for: [entry]) + } + return nil + } + + private var remoteConnectionStatusText: String { + switch tab.remoteConnectionState { + case .connected: + return String(localized: "remote.status.connected", defaultValue: "Connected") + case .connecting: + return String(localized: "remote.status.connecting", defaultValue: "Connecting") + case .error: + return String(localized: "remote.status.error", defaultValue: "Error") + case .disconnected: + return String(localized: "remote.status.disconnected", defaultValue: "Disconnected") + } + } + + @ViewBuilder + private var remoteWorkspaceSection: some View { + if sidebarShowSSH, let remoteWorkspaceSidebarText { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(remoteWorkspaceSidebarText) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(activeSecondaryColor(0.8)) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 0) + + Text(remoteConnectionStatusText) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(activeSecondaryColor(0.58)) + .lineLimit(1) + } + } + .padding(.top, latestNotificationText == nil ? 1 : 2) + .safeHelp(remoteStateHelpText) + } + } + + private func copyTextToPasteboard(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } + private var visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility { SidebarWorkspaceAuxiliaryDetailVisibility.resolved( showMetadata: sidebarShowMetadata, @@ -10257,6 +10543,7 @@ private struct TabItemView: View, Equatable { let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up") let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down") let latestNotificationSubtitle = latestNotificationText + let effectiveSubtitle = latestNotificationSubtitle let detailVisibility = visibleAuxiliaryDetails let orderedPanelIds: [UUID]? = (detailVisibility.showsBranchDirectory || detailVisibility.showsPullRequests) ? tab.sidebarOrderedPanelIds() @@ -10361,7 +10648,7 @@ private struct TabItemView: View, Equatable { .frame(width: workspaceHintSlotWidth, height: 16, alignment: .trailing) } - if let subtitle = latestNotificationSubtitle { + if let subtitle = effectiveSubtitle { Text(subtitle) .font(.system(size: 10)) .foregroundColor(activeSecondaryColor(0.8)) @@ -10370,6 +10657,8 @@ private struct TabItemView: View, Equatable { .multilineTextAlignment(.leading) } + remoteWorkspaceSection + if detailVisibility.showsMetadata { let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder() let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder() @@ -10623,12 +10912,27 @@ private struct TabItemView: View, Equatable { isMulti ? multi : single } + private func remoteContextMenuWorkspaces() -> [Workspace] { + guard !remoteContextMenuWorkspaceIds.isEmpty else { return [] } + return remoteContextMenuWorkspaceIds.compactMap { workspaceId in + tabManager.tabs.first(where: { $0.id == workspaceId }) + } + } + @ViewBuilder private var workspaceContextMenu: some View { let targetIds = contextTargetIds() let isMulti = targetIds.count > 1 let tabColorPalette = WorkspaceTabColorSettings.palette() let shouldPin = !tab.isPinned + let reconnectLabel = contextMenuLabel( + multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"), + single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"), + isMulti: isMulti) + let disconnectLabel = contextMenuLabel( + multi: String(localized: "contextMenu.disconnectWorkspaces", defaultValue: "Disconnect Workspaces"), + single: String(localized: "contextMenu.disconnectWorkspace", defaultValue: "Disconnect Workspace"), + isMulti: isMulti) let pinLabel = shouldPin ? contextMenuLabel( multi: String(localized: "contextMenu.pinWorkspaces", defaultValue: "Pin Workspaces"), @@ -10678,6 +10982,24 @@ private struct TabItemView: View, Equatable { } } + if !remoteContextMenuWorkspaceIds.isEmpty { + Divider() + + Button(reconnectLabel) { + for workspace in remoteContextMenuWorkspaces() { + workspace.reconnectRemoteConnection() + } + } + .disabled(allRemoteContextMenuTargetsConnecting) + + Button(disconnectLabel) { + for workspace in remoteContextMenuWorkspaces() { + workspace.disconnectRemoteConnection(clearConfiguration: false) + } + } + .disabled(allRemoteContextMenuTargetsDisconnected) + } + Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) { if tab.customColor != nil { Button { @@ -10710,6 +11032,12 @@ private struct TabItemView: View, Equatable { } } + if let copyableSidebarSSHError { + Button(String(localized: "contextMenu.copySshError", defaultValue: "Copy SSH Error")) { + copyTextToPasteboard(copyableSidebarSSHError) + } + } + Divider() Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) { @@ -10976,6 +11304,62 @@ private struct TabItemView: View, Equatable { } } + private var remoteStateHelpText: String { + let target = tab.remoteDisplayTarget ?? String( + localized: "sidebar.remote.help.targetFallback", + defaultValue: "remote host" + ) + let detail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines) + switch tab.remoteConnectionState { + case .connected: + return String( + format: String( + localized: "sidebar.remote.help.connected", + defaultValue: "SSH connected to %@" + ), + locale: .current, + target + ) + case .connecting: + return String( + format: String( + localized: "sidebar.remote.help.connecting", + defaultValue: "SSH connecting to %@" + ), + locale: .current, + target + ) + case .error: + if let detail, !detail.isEmpty { + return String( + format: String( + localized: "sidebar.remote.help.errorWithDetail", + defaultValue: "SSH error for %@: %@" + ), + locale: .current, + target, + detail + ) + } + return String( + format: String( + localized: "sidebar.remote.help.error", + defaultValue: "SSH error for %@" + ), + locale: .current, + target + ) + case .disconnected: + return String( + format: String( + localized: "sidebar.remote.help.disconnected", + defaultValue: "SSH disconnected from %@" + ), + locale: .current, + target + ) + } + } private func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) { guard let app = AppDelegate.shared else { return } let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil } @@ -11177,6 +11561,18 @@ private struct TabItemView: View, Equatable { } } + private func shortenPath(_ path: String, home: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return path } + if trimmed == home { + return "~" + } + if trimmed.hasPrefix(home + "/") { + return "~" + trimmed.dropFirst(home.count) + } + return trimmed + } + private struct PullRequestStatusIcon: View { let status: SidebarPullRequestStatus let color: Color diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index cc49002b..fa51c0be 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2057,8 +2057,11 @@ class GhosttyApp { return false } return performOnMain { - guard let tabManager = AppDelegate.shared?.tabManager else { return false } - return tabManager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil + guard let app = AppDelegate.shared, + let tabManager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { + return false + } + return tabManager.createSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil } case GHOSTTY_ACTION_RING_BELL: performOnMain { @@ -2486,6 +2489,30 @@ final class GhosttyMetalLayer: CAMetalLayer { } } +final class TerminalSurfaceRegistry { + static let shared = TerminalSurfaceRegistry() + + private let lock = NSLock() + private let surfaces = NSHashTable<AnyObject>.weakObjects() + + private init() {} + + func register(_ surface: TerminalSurface) { + lock.lock() + defer { lock.unlock() } + surfaces.add(surface) + } + + func allSurfaces() -> [TerminalSurface] { + lock.lock() + let objects = surfaces.allObjects.compactMap { $0 as? TerminalSurface } + lock.unlock() + return objects.sorted { lhs, rhs in + lhs.id.uuidString < rhs.id.uuidString + } + } +} + // MARK: - Terminal Surface (owns the ghostty_surface_t lifecycle) final class TerminalSurface: Identifiable, ObservableObject { @@ -2525,6 +2552,8 @@ final class TerminalSurface: Identifiable, ObservableObject { private let surfaceContext: ghostty_surface_context_e private let configTemplate: ghostty_surface_config_s? private let workingDirectory: String? + private let initialCommand: String? + private let initialEnvironmentOverrides: [String: String] var requestedWorkingDirectory: String? { workingDirectory } private var additionalEnvironment: [String: String] let hostedView: GhosttySurfaceScrollView @@ -2533,6 +2562,11 @@ final class TerminalSurface: Identifiable, ObservableObject { private var lastPixelHeight: UInt32 = 0 private var lastXScale: CGFloat = 0 private var lastYScale: CGFloat = 0 + private let debugMetadataLock = NSLock() + private let createdAt: Date = Date() + private var runtimeSurfaceCreatedAt: Date? + private var teardownRequestedAt: Date? + private var teardownRequestReason: String? private var pendingTextQueue: [Data] = [] private var pendingTextBytes: Int = 0 private let maxPendingTextBytes = 1_048_576 @@ -2597,6 +2631,8 @@ final class TerminalSurface: Identifiable, ObservableObject { context: ghostty_surface_context_e, configTemplate: ghostty_surface_config_s?, workingDirectory: String? = nil, + initialCommand: String? = nil, + initialEnvironmentOverrides: [String: String] = [:], additionalEnvironment: [String: String] = [:] ) { self.id = UUID() @@ -2604,7 +2640,10 @@ final class TerminalSurface: Identifiable, ObservableObject { self.surfaceContext = context self.configTemplate = configTemplate self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) - self.additionalEnvironment = additionalEnvironment + let trimmedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines) + self.initialCommand = (trimmedCommand?.isEmpty == false) ? trimmedCommand : nil + self.initialEnvironmentOverrides = Self.mergedNormalizedEnvironment(base: [:], overrides: initialEnvironmentOverrides) + self.additionalEnvironment = Self.mergedNormalizedEnvironment(base: [:], overrides: additionalEnvironment) // Match Ghostty's own SurfaceView: ensure a non-zero initial frame so the backing layer // has non-zero bounds and the renderer can initialize without presenting a blank/stretched // intermediate frame on the first real resize. @@ -2613,6 +2652,7 @@ final class TerminalSurface: Identifiable, ObservableObject { self.hostedView = GhosttySurfaceScrollView(surfaceView: view) // Surface is created when attached to a view hostedView.attachSurface(self) + TerminalSurfaceRegistry.shared.register(self) } @@ -2622,6 +2662,41 @@ final class TerminalSurface: Identifiable, ObservableObject { surfaceView.tabId = newTabId } + private static func mergedNormalizedEnvironment( + base: [String: String], + overrides: [String: String] + ) -> [String: String] { + var merged: [String: String] = [:] + merged.reserveCapacity(base.count + overrides.count) + for (rawKey, value) in base { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + merged[key] = value + } + for (rawKey, value) in overrides { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + merged[key] = value + } + return merged + } + + static func mergedStartupEnvironment( + base: [String: String], + protectedKeys: Set<String>, + additionalEnvironment: [String: String], + initialEnvironmentOverrides: [String: String] + ) -> [String: String] { + var merged = base + for (key, value) in additionalEnvironment where !key.isEmpty && !value.isEmpty && !protectedKeys.contains(key) { + merged[key] = value + } + for (key, value) in initialEnvironmentOverrides where !protectedKeys.contains(key) { + merged[key] = value + } + return merged + } + func isAttached(to view: GhosttyNSView) -> Bool { attachedView === view && surface != nil } @@ -2634,6 +2709,47 @@ final class TerminalSurface: Identifiable, ObservableObject { portalLifecycleState.rawValue } + private func withDebugMetadataLock<T>(_ body: () -> T) -> T { + debugMetadataLock.lock() + defer { debugMetadataLock.unlock() } + return body() + } + + func debugCreatedAt() -> Date { + withDebugMetadataLock { createdAt } + } + + func debugRuntimeSurfaceCreatedAt() -> Date? { + withDebugMetadataLock { runtimeSurfaceCreatedAt } + } + + func debugTeardownRequest() -> (requestedAt: Date?, reason: String?) { + withDebugMetadataLock { (teardownRequestedAt, teardownRequestReason) } + } + + func debugLastKnownWorkspaceId() -> UUID { + tabId + } + + func debugSurfaceContextLabel() -> String { + cmuxSurfaceContextName(surfaceContext) + } + + func debugInitialCommand() -> String? { + initialCommand + } + + func debugPortalHostLease() -> (hostId: String?, inWindow: Bool?, area: CGFloat?) { + guard let activePortalHostLease else { + return (nil, nil, nil) + } + return ( + hostId: String(describing: activePortalHostLease.hostId), + inWindow: activePortalHostLease.inWindow, + area: activePortalHostLease.area + ) + } + func canAcceptPortalBinding(expectedSurfaceId: UUID?, expectedGeneration: UInt64?) -> Bool { guard portalLifecycleState == .live else { return false } if let expectedSurfaceId, expectedSurfaceId != id { @@ -2729,9 +2845,28 @@ final class TerminalSurface: Identifiable, ObservableObject { #endif } + private func recordTeardownRequest(reason: String) { + withDebugMetadataLock { + if teardownRequestedAt == nil { + teardownRequestedAt = Date() + } + if let existing = teardownRequestReason, !existing.isEmpty { + return + } + teardownRequestReason = reason + } + } + + private func recordRuntimeSurfaceCreation() { + withDebugMetadataLock { + runtimeSurfaceCreatedAt = Date() + } + } + func beginPortalCloseLifecycle(reason: String) { guard portalLifecycleState != .closed else { return } guard portalLifecycleState != .closing else { return } + recordTeardownRequest(reason: reason) portalLifecycleState = .closing portalLifecycleGeneration &+= 1 #if DEBUG @@ -2760,6 +2895,7 @@ final class TerminalSurface: Identifiable, ObservableObject { /// before deinit; deinit will skip the free if already torn down. @MainActor func teardownSurface() { + recordTeardownRequest(reason: "surface.teardown") markPortalLifecycleClosed(reason: "teardown") let callbackContext = surfaceCallbackContext @@ -2974,27 +3110,37 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - env["CMUX_SURFACE_ID"] = id.uuidString - env["CMUX_WORKSPACE_ID"] = tabId.uuidString + var protectedStartupEnvironmentKeys: Set<String> = [] + func setManagedEnvironmentValue(_ key: String, _ value: String) { + env[key] = value + protectedStartupEnvironmentKeys.insert(key) + } + + setManagedEnvironmentValue("CMUX_SURFACE_ID", id.uuidString) + setManagedEnvironmentValue("CMUX_WORKSPACE_ID", tabId.uuidString) // Backward-compatible shell integration keys used by existing scripts/tests. - env["CMUX_PANEL_ID"] = id.uuidString - env["CMUX_TAB_ID"] = tabId.uuidString - env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath() + setManagedEnvironmentValue("CMUX_PANEL_ID", id.uuidString) + setManagedEnvironmentValue("CMUX_TAB_ID", tabId.uuidString) + setManagedEnvironmentValue("CMUX_SOCKET_PATH", SocketControlSettings.socketPath()) + if let bundledCLIURL = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux"), + FileManager.default.isExecutableFile(atPath: bundledCLIURL.path) { + setManagedEnvironmentValue("CMUX_BUNDLED_CLI_PATH", bundledCLIURL.path) + } if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty { - env["CMUX_BUNDLE_ID"] = bundleId + setManagedEnvironmentValue("CMUX_BUNDLE_ID", bundleId) } // Port range for this workspace (base/range snapshotted once per app session) do { let startPort = Self.sessionPortBase + portOrdinal * Self.sessionPortRangeSize - env["CMUX_PORT"] = String(startPort) - env["CMUX_PORT_END"] = String(startPort + Self.sessionPortRangeSize - 1) - env["CMUX_PORT_RANGE"] = String(Self.sessionPortRangeSize) + setManagedEnvironmentValue("CMUX_PORT", String(startPort)) + setManagedEnvironmentValue("CMUX_PORT_END", String(startPort + Self.sessionPortRangeSize - 1)) + setManagedEnvironmentValue("CMUX_PORT_RANGE", String(Self.sessionPortRangeSize)) } let claudeHooksEnabled = ClaudeCodeIntegrationSettings.hooksEnabled() if !claudeHooksEnabled { - env["CMUX_CLAUDE_HOOKS_DISABLED"] = "1" + setManagedEnvironmentValue("CMUX_CLAUDE_HOOKS_DISABLED", "1") } if let cliBinPath = Bundle.main.resourceURL?.appendingPathComponent("bin").path { @@ -3004,7 +3150,7 @@ final class TerminalSurface: Identifiable, ObservableObject { ?? "" if !currentPath.split(separator: ":").contains(Substring(cliBinPath)) { let separator = currentPath.isEmpty ? "" : ":" - env["PATH"] = "\(cliBinPath)\(separator)\(currentPath)" + setManagedEnvironmentValue("PATH", "\(cliBinPath)\(separator)\(currentPath)") } } @@ -3012,8 +3158,8 @@ final class TerminalSurface: Identifiable, ObservableObject { let shellIntegrationEnabled = UserDefaults.standard.object(forKey: "sidebarShellIntegration") as? Bool ?? true if shellIntegrationEnabled, let integrationDir = Bundle.main.resourceURL?.appendingPathComponent("shell-integration").path { - env["CMUX_SHELL_INTEGRATION"] = "1" - env["CMUX_SHELL_INTEGRATION_DIR"] = integrationDir + setManagedEnvironmentValue("CMUX_SHELL_INTEGRATION", "1") + setManagedEnvironmentValue("CMUX_SHELL_INTEGRATION_DIR", integrationDir) let shell = (env["SHELL"]?.isEmpty == false ? env["SHELL"] : nil) ?? getenv("SHELL").map { String(cString: $0) } @@ -3022,7 +3168,7 @@ final class TerminalSurface: Identifiable, ObservableObject { let shellName = URL(fileURLWithPath: shell).lastPathComponent if shellName == "zsh" { if GhosttyApp.shared.shellIntegrationMode() != "none" { - env["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1" + setManagedEnvironmentValue("CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION", "1") } let candidateZdotdir = (env["ZDOTDIR"]?.isEmpty == false ? env["ZDOTDIR"] : nil) ?? getenv("ZDOTDIR").map { String(cString: $0) } @@ -3039,20 +3185,20 @@ final class TerminalSurface: Identifiable, ObservableObject { isGhosttyInjected = (candidateZdotdir == ghosttyZdotdir) } if !isGhosttyInjected { - env["CMUX_ZSH_ZDOTDIR"] = candidateZdotdir + setManagedEnvironmentValue("CMUX_ZSH_ZDOTDIR", candidateZdotdir) } } - env["ZDOTDIR"] = integrationDir + setManagedEnvironmentValue("ZDOTDIR", integrationDir) } else if shellName == "bash" { if GhosttyApp.shared.shellIntegrationMode() != "none" { - env["CMUX_LOAD_GHOSTTY_BASH_INTEGRATION"] = "1" + setManagedEnvironmentValue("CMUX_LOAD_GHOSTTY_BASH_INTEGRATION", "1") } // macOS ships /bin/bash 3.2, where Ghostty's automatic bash // integration is unsupported and HOME-based wrapper startup is // not reliable. Bootstrap cmux bash integration on the first // interactive prompt instead. - env["PROMPT_COMMAND"] = """ + setManagedEnvironmentValue("PROMPT_COMMAND", """ unset PROMPT_COMMAND; \ if [[ "${CMUX_LOAD_GHOSTTY_BASH_INTEGRATION:-0}" == "1" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then \ _cmux_ghostty_bash="$GHOSTTY_RESOURCES_DIR/shell-integration/bash/ghostty.bash"; \ @@ -3064,16 +3210,15 @@ final class TerminalSurface: Identifiable, ObservableObject { fi; \ unset _cmux_ghostty_bash _cmux_bash_integration; \ if declare -F _cmux_prompt_command >/dev/null 2>&1; then _cmux_prompt_command; fi - """ - } - } - - let startupEnvironment = additionalEnvironment - if !startupEnvironment.isEmpty { - for (key, value) in startupEnvironment where !key.isEmpty && !value.isEmpty { - env[key] = value + """) } } + env = Self.mergedStartupEnvironment( + base: env, + protectedKeys: protectedStartupEnvironmentKeys, + additionalEnvironment: additionalEnvironment, + initialEnvironmentOverrides: initialEnvironmentOverrides + ) if !env.isEmpty { envVars.reserveCapacity(env.count) @@ -3098,15 +3243,31 @@ final class TerminalSurface: Identifiable, ObservableObject { } } - if let workingDirectory, !workingDirectory.isEmpty { - workingDirectory.withCString { cWorkingDir in - surfaceConfig.working_directory = cWorkingDir + let createWithCommandAndWorkingDirectory = { [self] in + if let initialCommand, !initialCommand.isEmpty { + initialCommand.withCString { cCommand in + surfaceConfig.command = cCommand + if let workingDirectory, !workingDirectory.isEmpty { + workingDirectory.withCString { cWorkingDir in + surfaceConfig.working_directory = cWorkingDir + createSurface() + } + } else { + createSurface() + } + } + } else if let workingDirectory, !workingDirectory.isEmpty { + workingDirectory.withCString { cWorkingDir in + surfaceConfig.working_directory = cWorkingDir + createSurface() + } + } else { createSurface() } - } else { - createSurface() } + createWithCommandAndWorkingDirectory() + if surface == nil { surfaceCallbackContext?.release() surfaceCallbackContext = nil @@ -3128,6 +3289,7 @@ final class TerminalSurface: Identifiable, ObservableObject { return } guard let createdSurface = surface else { return } + recordRuntimeSurfaceCreation() // Session scrollback replay must be one-shot. Reusing it on a later runtime // surface recreation would inject stale restored output into a live shell. @@ -3175,6 +3337,15 @@ final class TerminalSurface: Identifiable, ObservableObject { } } + NotificationCenter.default.post( + name: .terminalSurfaceDidBecomeReady, + object: self, + userInfo: [ + "surfaceId": id, + "workspaceId": tabId + ] + ) + flushPendingTextIfNeeded() // Kick an initial draw after creation/size setup. On some startup paths Ghostty can @@ -3260,6 +3431,7 @@ final class TerminalSurface: Identifiable, ObservableObject { dlog("forceRefresh: \(id) reason=\(reason) \(viewState)") #endif guard let view = attachedView, + let surface, view.window != nil, view.bounds.width > 0, view.bounds.height > 0 else { @@ -3791,6 +3963,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // If the surface creation was deferred while detached, create/attach it now. terminalSurface?.attachToView(self) + if let terminalSurface { + NotificationCenter.default.post( + name: .terminalSurfaceHostedViewDidMoveToWindow, + object: terminalSurface, + userInfo: [ + "surfaceId": terminalSurface.id, + "workspaceId": terminalSurface.tabId + ] + ) + } windowObserver = NotificationCenter.default.addObserver( forName: NSWindow.didChangeScreenNotification, @@ -5531,7 +5713,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { return false } - return manager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil + return manager.createSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil } @objc private func triggerFlash(_ sender: Any?) { @@ -5912,6 +6094,7 @@ final class GhosttySurfaceScrollView: NSView { private var activeDropZone: DropZone? private var pendingDropZone: DropZone? private var dropZoneOverlayAnimationGeneration: UInt64 = 0 + private var pendingAutomaticFirstResponderApply = false // Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection. /// Tracks whether keyboard focus should go to the search field or the terminal @@ -6523,7 +6706,7 @@ final class GhosttySurfaceScrollView: NSView { #if DEBUG dlog("find.window.didBecomeKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) focusTarget=\(self.searchFocusTarget) firstResponder=\(String(describing: self.window?.firstResponder))") #endif - self.applyFirstResponderIfNeeded() + self.scheduleAutomaticFirstResponderApply(reason: "didBecomeKey") }) windowObservers.append(NotificationCenter.default.addObserver( forName: NSWindow.didResignKeyNotification, @@ -6546,7 +6729,9 @@ final class GhosttySurfaceScrollView: NSView { #endif } }) - if window.isKeyWindow { applyFirstResponderIfNeeded() } + if window.isKeyWindow { + scheduleAutomaticFirstResponderApply(reason: "viewDidMoveToWindow") + } } func attachSurface(_ terminalSurface: TerminalSurface) { @@ -7068,6 +7253,16 @@ final class GhosttySurfaceScrollView: NSView { ) } #endif + if wasVisible != visible { + NotificationCenter.default.post( + name: .terminalPortalVisibilityDidChange, + object: self, + userInfo: [ + GhosttyNotificationKey.surfaceId: surfaceView.terminalSurface?.id as Any, + GhosttyNotificationKey.tabId: surfaceView.tabId as Any + ] + ) + } if !visible { // If we were focused, yield first responder. if let window, let fr = window.firstResponder as? NSView, @@ -7075,7 +7270,7 @@ final class GhosttySurfaceScrollView: NSView { window.makeFirstResponder(nil) } } else { - applyFirstResponderIfNeeded() + scheduleAutomaticFirstResponderApply(reason: "setVisibleInUI") } } @@ -7102,7 +7297,7 @@ final class GhosttySurfaceScrollView: NSView { } #endif if active { - applyFirstResponderIfNeeded() + scheduleAutomaticFirstResponderApply(reason: "setActive") } else { resignOwnedFirstResponderIfNeeded(reason: "setActive(false)") } @@ -7323,14 +7518,7 @@ final class GhosttySurfaceScrollView: NSView { } #endif - func ensureFocus(for tabId: UUID, surfaceId: UUID, attemptsRemaining: Int = 3) { - func retry() { - guard attemptsRemaining > 0 else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in - self?.ensureFocus(for: tabId, surfaceId: surfaceId, attemptsRemaining: attemptsRemaining - 1) - } - } - + func ensureFocus(for tabId: UUID, surfaceId: UUID) { let hasUsablePortalGeometry: Bool = { let size = bounds.size return size.width > 1 && size.height > 1 @@ -7343,10 +7531,10 @@ final class GhosttySurfaceScrollView: NSView { #if DEBUG dlog( "focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + - "reason=not_visible attempts=\(attemptsRemaining)" + "reason=not_visible" ) #endif - retry() + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.notVisible") return } guard !isHiddenForFocus, hasUsablePortalGeometry else { @@ -7354,17 +7542,17 @@ final class GhosttySurfaceScrollView: NSView { dlog( "focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) " + - "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) attempts=\(attemptsRemaining)" + "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))" ) #endif - retry() + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.hiddenOrTiny") return } guard let delegate = AppDelegate.shared, let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager, tabManager.selectedTabId == tabId else { - retry() + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.inactiveTab") return } @@ -7373,13 +7561,13 @@ final class GhosttySurfaceScrollView: NSView { let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface }) }) else { - retry() + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.missingPane") return } guard tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface, tab.bonsplitController.focusedPaneId == paneId else { - retry() + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.unfocusedPane") return } @@ -7389,7 +7577,7 @@ final class GhosttySurfaceScrollView: NSView { dlog( "focus.ensure.search surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " + - "attempts=\(attemptsRemaining) firstResponder=\(String(describing: window.firstResponder))" + "firstResponder=\(String(describing: window.firstResponder))" ) #endif restoreSearchFocus(window: window) @@ -7418,13 +7606,12 @@ final class GhosttySurfaceScrollView: NSView { dlog( "focus.ensure.apply surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " + - "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder)) " + - "attempts=\(attemptsRemaining)" + "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))" ) #endif if !isSurfaceViewFirstResponder() { - retry() + scheduleAutomaticFirstResponderApply(reason: "ensureFocus.afterMakeFirstResponder") } else { reassertTerminalSurfaceFocus(reason: "ensureFocus.afterMakeFirstResponder") } @@ -7464,6 +7651,20 @@ final class GhosttySurfaceScrollView: NSView { return fr === surfaceView || fr.isDescendant(of: surfaceView) } + private func scheduleAutomaticFirstResponderApply(reason: String) { + guard !pendingAutomaticFirstResponderApply else { return } + pendingAutomaticFirstResponderApply = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.pendingAutomaticFirstResponderApply = false +#if DEBUG + let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" + dlog("find.applyFirstResponder.defer surface=\(surfaceShort) reason=\(reason)") +#endif + self.applyFirstResponderIfNeeded() + } + } + private func reassertTerminalSurfaceFocus(reason: String) { guard let terminalSurface = surfaceView.terminalSurface else { return } #if DEBUG @@ -8061,35 +8262,15 @@ final class GhosttySurfaceScrollView: NSView { /// regions such as scrollbar space) when telling libghostty the terminal size. @discardableResult private func synchronizeCoreSurface() -> Bool { - let width = max(0, scrollView.contentSize.width - overlayScrollbarInsetWidth()) + // Reserving extra overlay-scroller gutter here causes AppKit and libghostty to fight + // over terminal columns during split churn. The width can flap by one scrollbar gutter, + // which redraws the shell prompt multiple times on Cmd+D. Favor stable columns. + let width = max(0, scrollView.contentSize.width) let height = surfaceView.frame.height guard width > 0, height > 0 else { return false } return surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height)) } - /// Reserve overlay scrollbar gutter so wrapped text never sits underneath a visible scroller. - private func overlayScrollbarInsetWidth() -> CGFloat { - guard scrollView.hasVerticalScroller, scrollView.scrollerStyle == .overlay else { return 0 } - - // If AppKit already reserved non-content width in `contentSize`, avoid double-subtraction. - let alreadyReserved = max(0, scrollView.bounds.width - scrollView.contentSize.width) - if alreadyReserved > 0.5 { return 0 } - - let fallback = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .overlay) - guard let verticalScroller = scrollView.verticalScroller else { return fallback } - - let measuredWidth = verticalScroller.frame.width - if measuredWidth > 0 { - return max(measuredWidth, fallback) - } - - let controlSizeWidth = NSScroller.scrollerWidth( - for: verticalScroller.controlSize, - scrollerStyle: .overlay - ) - return max(controlSizeWidth, fallback) - } - private func updateNotificationRingPath() { updateOverlayRingPath( layer: notificationRingLayer, @@ -8573,6 +8754,23 @@ struct GhosttyTerminalView: NSViewRepresentable { return !hostedViewHasSuperview } + private static func synchronizePortalGeometry( + for host: HostContainerView, + coordinator: Coordinator + ) { + let geometryRevision = host.geometryRevision + guard coordinator.lastSynchronizedHostGeometryRevision != geometryRevision else { return } + coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + if host.inLiveResize || host.window?.inLiveResize == true { + TerminalWindowPortalRegistry.synchronizeForAnchor(host) + return + } + // Avoid synchronizing the terminal portal while AppKit is still inside + // the current layout turn. Re-entrant syncs here can wedge window resize + // handling and leave the app spinning on the wait cursor. + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + } + func makeNSView(context: Context) -> NSView { let container = HostContainerView() container.wantsLayer = false @@ -8647,6 +8845,12 @@ struct GhosttyTerminalView: NSViewRepresentable { } let portalExpectedSurfaceId = terminalSurface.id let portalExpectedGeneration = terminalSurface.portalBindingGeneration() + func portalBindingStillLive() -> Bool { + terminalSurface.canAcceptPortalBinding( + expectedSurfaceId: portalExpectedSurfaceId, + expectedGeneration: portalExpectedGeneration + ) + } let forwardedDropZone = isVisibleInUI ? paneDropZone : nil #if DEBUG if coordinator.lastPaneDropZone != paneDropZone { @@ -8685,6 +8889,7 @@ struct GhosttyTerminalView: NSViewRepresentable { reason: "didMoveToWindow" ) else { return } guard host.window != nil else { return } + guard portalBindingStillLive() else { return } TerminalWindowPortalRegistry.bind( hostedView: hostedView, to: host, @@ -8708,6 +8913,7 @@ struct GhosttyTerminalView: NSViewRepresentable { bounds: host.bounds, reason: "geometryChanged" ) else { return } + guard portalBindingStillLive() else { return } let hostId = ObjectIdentifier(host) if host.window != nil, (coordinator.lastBoundHostId != hostId || @@ -8732,11 +8938,14 @@ struct GhosttyTerminalView: NSViewRepresentable { hostedView.setActive(coordinator.desiredIsActive) hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) } - TerminalWindowPortalRegistry.synchronizeForAnchor(host) - coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision + Self.synchronizePortalGeometry( + for: host, + coordinator: coordinator + ) } if host.window != nil, hostOwnsPortalNow { + let portalBindingLive = portalBindingStillLive() let hostId = ObjectIdentifier(host) let geometryRevision = host.geometryRevision let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) @@ -8747,7 +8956,7 @@ struct GhosttyTerminalView: NSViewRepresentable { previousDesiredIsVisibleInUI != isVisibleInUI || previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing || previousDesiredPortalZPriority != portalZPriority - if shouldBindNow { + if portalBindingLive && shouldBindNow { #if DEBUG if portalEntryMissing { dlog( @@ -8767,11 +8976,13 @@ struct GhosttyTerminalView: NSViewRepresentable { ) coordinator.lastBoundHostId = hostId coordinator.lastSynchronizedHostGeometryRevision = geometryRevision - } else if coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { - TerminalWindowPortalRegistry.synchronizeForAnchor(host) - coordinator.lastSynchronizedHostGeometryRevision = geometryRevision + } else if portalBindingLive && coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { + Self.synchronizePortalGeometry( + for: host, + coordinator: coordinator + ) } - } else if hostOwnsPortalNow { + } else if hostOwnsPortalNow, portalBindingStillLive() { // Bind is deferred until host moves into a window. Update the // existing portal entry's visibleInUI now so that any portal sync // that runs before the deferred bind completes won't hide the view. @@ -8801,7 +9012,7 @@ struct GhosttyTerminalView: NSViewRepresentable { isBoundToCurrentHost: isBoundToCurrentHost ) - if shouldApplyImmediateHostedState { + if portalBindingStillLive() && shouldApplyImmediateHostedState { hostedView.setVisibleInUI(isVisibleInUI) hostedView.setActive(isActive) } else { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 67d9e2d0..fefa2253 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3,6 +3,8 @@ import Combine import WebKit import AppKit import Bonsplit +import Network +import CFNetwork import SQLite3 import CryptoKit #if canImport(CommonCrypto) @@ -24,6 +26,18 @@ fileprivate func dedupedCanonicalURLs(_ urls: [URL]) -> [URL] { return result } +struct BrowserProxyEndpoint: Equatable { + let host: String + let port: Int +} + +struct BrowserRemoteWorkspaceStatus: Equatable { + let target: String + let connectionState: WorkspaceRemoteConnectionState + let heartbeatCount: Int + let lastHeartbeatAt: Date? +} + enum GhosttyBackgroundTheme { static func clampedOpacity(_ opacity: Double) -> CGFloat { CGFloat(max(0.0, min(1.0, opacity))) @@ -1695,6 +1709,14 @@ final class BrowserPortalAnchorView: NSView { @MainActor final class BrowserPanel: Panel, ObservableObject { + private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me" + private static let remoteLoopbackHosts: Set<String> = [ + "localhost", + "127.0.0.1", + "::1", + "0.0.0.0", + ] + /// Shared process pool for cookie sharing across all browser panels private static let sharedProcessPool = WKProcessPool() @@ -1845,6 +1867,7 @@ final class BrowserPanel: Panel, ObservableObject { /// The underlying web view private(set) var webView: WKWebView + private var websiteDataStore: WKWebsiteDataStore /// Monotonic identity for the current WKWebView instance. /// Incremented whenever we replace the underlying WKWebView after a process crash. @@ -2219,6 +2242,15 @@ final class BrowserPanel: Panel, ObservableObject { private var developerToolsRestoreRetryAttempt: Int = 0 private let developerToolsRestoreRetryDelay: TimeInterval = 0.05 private let developerToolsRestoreRetryMaxAttempts: Int = 40 + private var remoteProxyEndpoint: BrowserProxyEndpoint? + @Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus? + private var usesRemoteWorkspaceProxy: Bool + private struct PendingRemoteNavigation { + let request: URLRequest + let recordTypedNavigation: Bool + let preserveRestoredSessionHistory: Bool + } + private var pendingRemoteNavigation: PendingRemoteNavigation? private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35 private var developerToolsDetachedOpenGraceDeadline: Date? private var developerToolsTransitionTargetVisible: Bool? @@ -2406,11 +2438,16 @@ final class BrowserPanel: Panel, ObservableObject { false } - private static func makeWebView(profileID: UUID) -> CmuxWebView { + private static func makeWebView( + profileID: UUID, + websiteDataStore: WKWebsiteDataStore? = nil + ) -> CmuxWebView { let config = WKWebViewConfiguration() config.processPool = BrowserPanel.sharedProcessPool config.mediaTypesRequiringUserActionForPlayback = [] - config.websiteDataStore = BrowserProfileStore.shared.websiteDataStore(for: profileID) + // Ensure browser cookies/storage persist across navigations and launches. + // This reduces repeated consent/bot-challenge flows on sites like Google. + config.websiteDataStore = websiteDataStore ?? BrowserProfileStore.shared.websiteDataStore(for: profileID) // Enable developer extras (DevTools) config.preferences.setValue(true, forKey: "developerExtrasEnabled") @@ -2504,7 +2541,10 @@ final class BrowserPanel: Panel, ObservableObject { workspaceId: UUID, profileID: UUID? = nil, initialURL: URL? = nil, - bypassInsecureHTTPHostOnce: String? = nil + bypassInsecureHTTPHostOnce: String? = nil, + proxyEndpoint: BrowserProxyEndpoint? = nil, + isRemoteWorkspace: Bool = false, + remoteWebsiteDataStoreIdentifier: UUID? = nil ) { self.id = UUID() self.workspaceId = workspaceId @@ -2515,11 +2555,20 @@ final class BrowserPanel: Panel, ObservableObject { self.profileID = resolvedProfileID self.historyStore = BrowserProfileStore.shared.historyStore(for: resolvedProfileID) self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") + self.remoteProxyEndpoint = proxyEndpoint + self.usesRemoteWorkspaceProxy = isRemoteWorkspace self.browserThemeMode = BrowserThemeSettings.mode() + self.websiteDataStore = isRemoteWorkspace + ? WKWebsiteDataStore(forIdentifier: remoteWebsiteDataStoreIdentifier ?? workspaceId) + : BrowserProfileStore.shared.websiteDataStore(for: resolvedProfileID) - let webView = Self.makeWebView(profileID: resolvedProfileID) + let webView = Self.makeWebView( + profileID: resolvedProfileID, + websiteDataStore: websiteDataStore + ) self.webView = webView self.insecureHTTPAlertFactory = { NSAlert() } + applyRemoteProxyConfigurationIfAvailable() BrowserProfileStore.shared.noteUsed(resolvedProfileID) // Set up navigation delegate @@ -2540,14 +2589,52 @@ final class BrowserPanel: Panel, ObservableObject { // Downloads save to a temp file synchronously (no NSSavePanel during WebKit // callbacks), then show NSSavePanel after the download completes. let dlDelegate = BrowserDownloadDelegate() - dlDelegate.onDownloadStarted = { [weak self] _ in - self?.beginDownloadActivity() + dlDelegate.onDownloadStarted = { [weak self] filename in + guard let self else { return } + self.beginDownloadActivity() + NotificationCenter.default.post( + name: .browserDownloadEventDidArrive, + object: self, + userInfo: [ + "surfaceId": self.id, + "workspaceId": self.workspaceId, + "event": [ + "type": "started", + "filename": filename + ] + ] + ) } dlDelegate.onDownloadReadyToSave = { [weak self] in - self?.endDownloadActivity() + guard let self else { return } + self.endDownloadActivity() + NotificationCenter.default.post( + name: .browserDownloadEventDidArrive, + object: self, + userInfo: [ + "surfaceId": self.id, + "workspaceId": self.workspaceId, + "event": [ + "type": "ready_to_save" + ] + ] + ) } - dlDelegate.onDownloadFailed = { [weak self] _ in - self?.endDownloadActivity() + dlDelegate.onDownloadFailed = { [weak self] error in + guard let self else { return } + self.endDownloadActivity() + NotificationCenter.default.post( + name: .browserDownloadEventDidArrive, + object: self, + userInfo: [ + "surfaceId": self.id, + "workspaceId": self.workspaceId, + "event": [ + "type": "failed", + "error": error.localizedDescription + ] + ] + ) } navDelegate.downloadDelegate = dlDelegate self.downloadDelegate = dlDelegate @@ -2581,6 +2668,41 @@ final class BrowserPanel: Panel, ObservableObject { } } + func setRemoteProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) { + guard remoteProxyEndpoint != endpoint else { return } + remoteProxyEndpoint = endpoint + applyRemoteProxyConfigurationIfAvailable() + resumePendingRemoteNavigationIfNeeded() + } + + func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) { + guard remoteWorkspaceStatus != status else { return } + remoteWorkspaceStatus = status + } + + private func applyRemoteProxyConfigurationIfAvailable() { + guard #available(macOS 14.0, *) else { return } + + let store = webView.configuration.websiteDataStore + guard let endpoint = remoteProxyEndpoint else { + store.proxyConfigurations = [] + return + } + + let host = endpoint.host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty, + endpoint.port > 0 && endpoint.port <= 65535, + let nwPort = NWEndpoint.Port(rawValue: UInt16(endpoint.port)) else { + store.proxyConfigurations = [] + return + } + + let nwEndpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: nwPort) + let socks = ProxyConfiguration(socksv5Proxy: nwEndpoint) + let connect = ProxyConfiguration(httpCONNECTProxy: nwEndpoint) + store.proxyConfigurations = [socks, connect] + } + private func beginDownloadActivity() { let apply = { self.activeDownloadCount += 1 @@ -2609,6 +2731,33 @@ final class BrowserPanel: Panel, ObservableObject { workspaceId = newWorkspaceId } + func reattachToWorkspace( + _ newWorkspaceId: UUID, + isRemoteWorkspace: Bool, + remoteWebsiteDataStoreIdentifier: UUID? = nil, + proxyEndpoint: BrowserProxyEndpoint?, + remoteStatus: BrowserRemoteWorkspaceStatus? + ) { + workspaceId = newWorkspaceId + usesRemoteWorkspaceProxy = isRemoteWorkspace + let targetStore = isRemoteWorkspace + ? WKWebsiteDataStore(forIdentifier: remoteWebsiteDataStoreIdentifier ?? newWorkspaceId) + : BrowserProfileStore.shared.websiteDataStore(for: profileID) + let needsStoreSwap = webView.configuration.websiteDataStore !== targetStore + websiteDataStore = targetStore + remoteProxyEndpoint = proxyEndpoint + remoteWorkspaceStatus = remoteStatus + if needsStoreSwap { + replaceWebViewPreservingState( + from: webView, + websiteDataStore: targetStore, + reason: "workspace_reattach" + ) + } + applyRemoteProxyConfigurationIfAvailable() + resumePendingRemoteNavigationIfNeeded() + } + @discardableResult func switchToProfile(_ requestedProfileID: UUID) -> Bool { let resolvedProfileID = BrowserProfileStore.shared.profileDefinition(id: requestedProfileID) != nil @@ -2652,7 +2801,14 @@ final class BrowserPanel: Panel, ObservableObject { historyStore = BrowserProfileStore.shared.historyStore(for: resolvedProfileID) BrowserProfileStore.shared.noteUsed(resolvedProfileID) - let replacement = Self.makeWebView(profileID: resolvedProfileID) + if !usesRemoteWorkspaceProxy { + websiteDataStore = BrowserProfileStore.shared.websiteDataStore(for: resolvedProfileID) + } + + let replacement = Self.makeWebView( + profileID: resolvedProfileID, + websiteDataStore: websiteDataStore + ) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() webView = replacement @@ -2736,7 +2892,7 @@ final class BrowserPanel: Panel, ObservableObject { let urlObserver = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in Task { @MainActor in guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return } - self.currentURL = webView.url + self.currentURL = Self.remoteProxyDisplayURL(for: webView.url) } } webViewObservers.append(urlObserver) @@ -2802,20 +2958,33 @@ final class BrowserPanel: Panel, ObservableObject { } private func replaceWebViewAfterContentProcessTermination(for terminatedWebView: WKWebView) { - guard terminatedWebView === webView else { return } + replaceWebViewPreservingState( + from: terminatedWebView, + websiteDataStore: websiteDataStore, + reason: "webcontent_process_terminated" + ) + } + + private func replaceWebViewPreservingState( + from oldWebView: WKWebView, + websiteDataStore: WKWebsiteDataStore, + reason: String + ) { + guard oldWebView === webView else { return } let wasRenderable = shouldRenderWebView - let restoreURL = terminatedWebView.url ?? currentURL + let restoreURL = Self.remoteProxyDisplayURL(for: oldWebView.url) ?? currentURL let restoreURLString = restoreURL?.absoluteString let shouldRestoreURL = wasRenderable && restoreURLString != nil && restoreURLString != blankURLString let history = sessionNavigationHistorySnapshot() let historyCurrentURL = preferredURLStringForOmnibar() - let desiredZoom = max(minPageZoom, min(maxPageZoom, terminatedWebView.pageZoom)) + let desiredZoom = max(minPageZoom, min(maxPageZoom, oldWebView.pageZoom)) let restoreDevTools = preferredDeveloperToolsVisible #if DEBUG dlog( "browser.webview.replace.begin panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) " + "renderable=\(wasRenderable ? 1 : 0) restoreURL=\(restoreURLString ?? "nil") " + "restoreHistoryBack=\(history.backHistoryURLStrings.count) " + "restoreHistoryForward=\(history.forwardHistoryURLStrings.count)" @@ -2827,15 +2996,18 @@ final class BrowserPanel: Panel, ObservableObject { faviconTask?.cancel() faviconTask = nil faviconRefreshGeneration &+= 1 - BrowserWindowPortalRegistry.detach(webView: terminatedWebView) - terminatedWebView.stopLoading() - terminatedWebView.navigationDelegate = nil - terminatedWebView.uiDelegate = nil - if let terminatedCmuxWebView = terminatedWebView as? CmuxWebView { - terminatedCmuxWebView.onContextMenuDownloadStateChanged = nil + BrowserWindowPortalRegistry.detach(webView: oldWebView) + oldWebView.stopLoading() + oldWebView.navigationDelegate = nil + oldWebView.uiDelegate = nil + if let oldCmuxWebView = oldWebView as? CmuxWebView { + oldCmuxWebView.onContextMenuDownloadStateChanged = nil } - let replacement = Self.makeWebView(profileID: profileID) + let replacement = Self.makeWebView( + profileID: profileID, + websiteDataStore: websiteDataStore + ) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() webView = replacement @@ -2863,12 +3035,13 @@ final class BrowserPanel: Panel, ObservableObject { } if restoreDevTools { - requestDeveloperToolsRefreshAfterNextAttach(reason: "webcontent_process_terminated") + requestDeveloperToolsRefreshAfterNextAttach(reason: reason) } #if DEBUG dlog( "browser.webview.replace.end panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) " + "instance=\(webViewInstanceID.uuidString.prefix(6)) " + "restoreURL=\(restoreURLString ?? "nil") shouldRestore=\(shouldRestoreURL ? 1 : 0)" ) @@ -2892,7 +3065,7 @@ final class BrowserPanel: Panel, ObservableObject { // If nothing meaningful is loaded yet, prefer letting the omnibar take focus. if !webView.isLoading { - let urlString = webView.url?.absoluteString ?? currentURL?.absoluteString + let urlString = Self.remoteProxyDisplayURL(for: webView.url)?.absoluteString ?? currentURL?.absoluteString if urlString == nil || urlString == "about:blank" { return } @@ -2974,6 +3147,13 @@ final class BrowserPanel: Panel, ObservableObject { guard let self, let webView else { return } guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } guard self.isCurrentFaviconRefresh(generation: refreshGeneration) else { return } +#if DEBUG + dlog( + "browser.favicon.begin " + + "panel=\(id.uuidString.prefix(5)) " + + "page=\(pageURL.absoluteString)" + ) +#endif // Try to discover the best icon URL from the document. let js = """ @@ -3001,7 +3181,11 @@ final class BrowserPanel: Panel, ObservableObject { """ var discoveredURL: URL? - if let href = try? await webView.evaluateJavaScript(js) as? String { + if let href = await self.evaluateJavaScriptString( + js, + in: webView, + timeoutNanoseconds: 400_000_000 + ) { let trimmed = href.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty, let u = URL(string: trimmed) { discoveredURL = u @@ -3013,10 +3197,26 @@ final class BrowserPanel: Panel, ObservableObject { let fallbackURL = URL(string: "/favicon.ico", relativeTo: pageURL) let iconURL = discoveredURL ?? fallbackURL guard let iconURL else { return } +#if DEBUG + dlog( + "browser.favicon.iconURL " + + "panel=\(id.uuidString.prefix(5)) " + + "discovered=\(discoveredURL?.absoluteString ?? "<nil>") " + + "fallback=\(fallbackURL?.absoluteString ?? "<nil>") " + + "chosen=\(iconURL.absoluteString)" + ) +#endif // Avoid repeated fetches. let iconURLString = iconURL.absoluteString if iconURLString == lastFaviconURLString, faviconPNGData != nil { +#if DEBUG + dlog( + "browser.favicon.skipCached " + + "panel=\(id.uuidString.prefix(5)) " + + "icon=\(iconURLString)" + ) +#endif return } lastFaviconURLString = iconURLString @@ -3025,12 +3225,42 @@ final class BrowserPanel: Panel, ObservableObject { req.timeoutInterval = 2.0 req.cachePolicy = .returnCacheDataElseLoad req.setValue(BrowserUserAgentSettings.safariUserAgent, forHTTPHeaderField: "User-Agent") + let effectiveRequest = remoteProxyPreparedRequest(from: req, logScope: "faviconRewrite") let data: Data let response: URLResponse do { - (data, response) = try await URLSession.shared.data(for: req) + let remoteSession = remoteProxyURLSession() + defer { remoteSession?.finishTasksAndInvalidate() } + if let remoteSession { +#if DEBUG + dlog( + "browser.favicon.fetch " + + "panel=\(id.uuidString.prefix(5)) " + + "via=proxy " + + "url=\(effectiveRequest.url?.absoluteString ?? "<nil>")" + ) +#endif + (data, response) = try await remoteSession.data(for: effectiveRequest) + } else { +#if DEBUG + dlog( + "browser.favicon.fetch " + + "panel=\(id.uuidString.prefix(5)) " + + "via=direct " + + "url=\(effectiveRequest.url?.absoluteString ?? "<nil>")" + ) +#endif + (data, response) = try await URLSession.shared.data(for: effectiveRequest) + } } catch { +#if DEBUG + dlog( + "browser.favicon.fetchError " + + "panel=\(id.uuidString.prefix(5)) " + + "error=\(String(describing: error))" + ) +#endif return } guard self.isCurrentWebView(webView, instanceID: refreshWebViewInstanceID) else { return } @@ -3038,13 +3268,45 @@ final class BrowserPanel: Panel, ObservableObject { guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { +#if DEBUG + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + dlog( + "browser.favicon.badResponse " + + "panel=\(id.uuidString.prefix(5)) " + + "status=\(status)" + ) +#endif return } +#if DEBUG + dlog( + "browser.favicon.response " + + "panel=\(id.uuidString.prefix(5)) " + + "status=\(http.statusCode) " + + "bytes=\(data.count)" + ) +#endif // Use >= 2x the rendered point size so we don't upscale (blurry) on Retina. - guard let png = Self.makeFaviconPNGData(from: data, targetPx: 32) else { return } + guard let png = Self.makeFaviconPNGData(from: data, targetPx: 32) else { +#if DEBUG + dlog( + "browser.favicon.decodeFailed " + + "panel=\(id.uuidString.prefix(5)) " + + "bytes=\(data.count)" + ) +#endif + return + } // Only update if we got a real icon; keep the old one otherwise to avoid flashes. faviconPNGData = png +#if DEBUG + dlog( + "browser.favicon.ready " + + "panel=\(id.uuidString.prefix(5)) " + + "pngBytes=\(png.count)" + ) +#endif } } @@ -3053,6 +3315,35 @@ final class BrowserPanel: Panel, ObservableObject { return generation == faviconRefreshGeneration } + @MainActor + private func evaluateJavaScriptString( + _ script: String, + in webView: WKWebView, + timeoutNanoseconds: UInt64 + ) async -> String? { + await withCheckedContinuation { continuation in + var hasResumed = false + + func resume(_ value: String?) { + guard !hasResumed else { return } + hasResumed = true + continuation.resume(returning: value) + } + + webView.evaluateJavaScript(script) { result, _ in + let value = result as? String + Task { @MainActor in + resume(value) + } + } + + Task { @MainActor in + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + resume(nil) + } + } + } + @MainActor private static func makeFaviconPNGData(from raw: Data, targetPx: Int) -> Data? { guard let image = NSImage(data: raw) else { return nil } @@ -3183,17 +3474,113 @@ final class BrowserPanel: Panel, ObservableObject { preserveRestoredSessionHistory: Bool = false ) { guard let url = request.url else { return } + if usesRemoteWorkspaceProxy, remoteProxyEndpoint == nil { + pendingRemoteNavigation = PendingRemoteNavigation( + request: request, + recordTypedNavigation: recordTypedNavigation, + preserveRestoredSessionHistory: preserveRestoredSessionHistory + ) + shouldRenderWebView = true + currentURL = Self.remoteProxyDisplayURL(for: url) ?? url + navigationDelegate?.lastAttemptedURL = url + return + } + performNavigation( + request: request, + originalURL: url, + recordTypedNavigation: recordTypedNavigation, + preserveRestoredSessionHistory: preserveRestoredSessionHistory + ) + } + + private func resumePendingRemoteNavigationIfNeeded() { + guard remoteProxyEndpoint != nil, + let pendingRemoteNavigation else { + return + } + self.pendingRemoteNavigation = nil + guard let originalURL = pendingRemoteNavigation.request.url else { return } + performNavigation( + request: pendingRemoteNavigation.request, + originalURL: originalURL, + recordTypedNavigation: pendingRemoteNavigation.recordTypedNavigation, + preserveRestoredSessionHistory: pendingRemoteNavigation.preserveRestoredSessionHistory + ) + } + + private func performNavigation( + request: URLRequest, + originalURL: URL, + recordTypedNavigation: Bool, + preserveRestoredSessionHistory: Bool + ) { if !preserveRestoredSessionHistory { abandonRestoredSessionHistoryIfNeeded() } + let effectiveRequest = remoteProxyPreparedRequest(from: request, logScope: "rewrite") // Some installs can end up with a legacy Chrome UA override; keep this pinned. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent shouldRenderWebView = true if recordTypedNavigation { - historyStore.recordTypedNavigation(url: url) + historyStore.recordTypedNavigation(url: originalURL) } - navigationDelegate?.lastAttemptedURL = url - browserLoadRequest(request, in: webView) + navigationDelegate?.lastAttemptedURL = originalURL + browserLoadRequest(effectiveRequest, in: webView) + } + + private func remoteProxyPreparedRequest(from request: URLRequest, logScope: String) -> URLRequest { + guard remoteProxyEndpoint != nil else { return request } + guard let url = request.url else { return request } + guard let rewrittenURL = Self.remoteProxyLoopbackAliasURL(for: url) else { return request } + + var rewrittenRequest = request + rewrittenRequest.url = rewrittenURL +#if DEBUG + dlog( + "browser.remoteProxy.\(logScope) " + + "panel=\(id.uuidString.prefix(5)) " + + "from=\(url.absoluteString) " + + "to=\(rewrittenURL.absoluteString)" + ) +#endif + return rewrittenRequest + } + + private func remoteProxyURLSession() -> URLSession? { + guard let endpoint = remoteProxyEndpoint else { return nil } + let host = endpoint.host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !host.isEmpty, endpoint.port > 0, endpoint.port <= 65535 else { return nil } + + let configuration = URLSessionConfiguration.ephemeral + configuration.requestCachePolicy = .returnCacheDataElseLoad + configuration.timeoutIntervalForRequest = 2.0 + configuration.timeoutIntervalForResource = 4.0 + configuration.connectionProxyDictionary = [ + kCFNetworkProxiesSOCKSEnable as String: 1, + kCFNetworkProxiesSOCKSProxy as String: host, + kCFNetworkProxiesSOCKSPort as String: endpoint.port, + ] + return URLSession(configuration: configuration) + } + + private static func remoteProxyDisplayURL(for url: URL?) -> URL? { + guard let url else { return nil } + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return url } + guard host == BrowserInsecureHTTPSettings.normalizeHost(remoteLoopbackProxyAliasHost) else { return url } + + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.host = "localhost" + return components?.url ?? url + } + + private static func remoteProxyLoopbackAliasURL(for url: URL) -> URL? { + guard let scheme = url.scheme?.lowercased(), scheme == "http" else { return nil } + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return nil } + guard remoteLoopbackHosts.contains(host) else { return nil } + + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + components?.host = remoteLoopbackProxyAliasHost + return components?.url } /// Navigate with smart URL/search detection @@ -3416,7 +3803,10 @@ extension BrowserPanel { oldCmuxWebView.onContextMenuDownloadStateChanged = nil } - let replacement = Self.makeWebView(profileID: profileID) + let replacement = Self.makeWebView( + profileID: profileID, + websiteDataStore: websiteDataStore + ) webViewInstanceID = UUID() webView = replacement shouldRenderWebView = false @@ -4069,6 +4459,16 @@ extension BrowserPanel { applyPageZoom(1.0) } + func currentPageZoomFactor() -> CGFloat { + webView.pageZoom + } + + @discardableResult + func setPageZoomFactor(_ pageZoom: CGFloat) -> Bool { + let clamped = max(minPageZoom, min(maxPageZoom, pageZoom)) + return applyPageZoom(clamped) + } + /// Take a snapshot of the web view func takeSnapshot(completion: @escaping (NSImage?) -> Void) { let config = WKSnapshotConfiguration() @@ -4642,7 +5042,7 @@ extension BrowserPanel { /// Returns the most reliable URL string for omnibar-related matching and UI decisions. /// `currentURL` can lag behind navigation changes, so prefer the live WKWebView URL. func preferredURLStringForOmnibar() -> String? { - if let webViewURL = webView.url?.absoluteString + if let webViewURL = Self.remoteProxyDisplayURL(for: webView.url)?.absoluteString .trimmingCharacters(in: .whitespacesAndNewlines), !webViewURL.isEmpty, webViewURL != blankURLString { @@ -4660,7 +5060,7 @@ extension BrowserPanel { } private func resolvedCurrentSessionHistoryURL() -> URL? { - if let webViewURL = webView.url, + if let webViewURL = Self.remoteProxyDisplayURL(for: webView.url), Self.serializableSessionHistoryURLString(webViewURL) != nil { return webViewURL } @@ -4974,6 +5374,15 @@ private extension BrowserPanel { } } +extension BrowserPanel { + func hideBrowserPortalView(source: String) { + BrowserWindowPortalRegistry.hide( + webView: webView, + source: source + ) + } +} + extension WKWebView { func cmuxInspectorObject() -> NSObject? { let selector = NSSelectorFromString("_inspector") diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 7a8acf00..3df74b9c 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -88,14 +88,18 @@ final class TerminalPanel: Panel, ObservableObject { context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: ghostty_surface_config_s? = nil, workingDirectory: String? = nil, - additionalEnvironment: [String: String] = [:], - portOrdinal: Int = 0 + portOrdinal: Int = 0, + initialCommand: String? = nil, + initialEnvironmentOverrides: [String: String] = [:], + additionalEnvironment: [String: String] = [:] ) { let surface = TerminalSurface( tabId: workspaceId, context: context, configTemplate: configTemplate, workingDirectory: workingDirectory, + initialCommand: initialCommand, + initialEnvironmentOverrides: initialEnvironmentOverrides, additionalEnvironment: additionalEnvironment ) surface.portOrdinal = portOrdinal diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index 22d91a4d..8d44e573 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -434,6 +434,18 @@ struct SocketControlSettings { probeStableDefaultPathEntry: probeStableDefaultPathEntry ) + if let taggedDebugPath = taggedDebugSocketPath( + bundleIdentifier: bundleIdentifier, + environment: environment + ) { + if isTruthy(environment[allowSocketPathOverrideKey]), + let override = environment["CMUX_SOCKET_PATH"], + !override.isEmpty { + return override + } + return taggedDebugPath + } + guard let override = environment["CMUX_SOCKET_PATH"], !override.isEmpty else { return fallback } @@ -455,6 +467,9 @@ struct SocketControlSettings { currentUserID: uid_t = getuid(), probeStableDefaultPathEntry: (String) -> StableDefaultSocketPathEntry = inspectStableDefaultSocketPathEntry ) -> String { + if let taggedDebugPath = taggedDebugSocketPath(bundleIdentifier: bundleIdentifier, environment: [:]) { + return taggedDebugPath + } if bundleIdentifier == "com.cmuxterm.app.nightly" { return "/tmp/cmux-nightly.sock" } @@ -518,6 +533,37 @@ struct SocketControlSettings { || bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.") } + static func taggedDebugSocketPath( + bundleIdentifier: String?, + environment: [String: String] + ) -> String? { + let bundleId = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if bundleId.hasPrefix("\(baseDebugBundleIdentifier).") { + let suffix = String(bundleId.dropFirst(baseDebugBundleIdentifier.count + 1)) + let slug = suffix + .replacingOccurrences(of: ".", with: "-") + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + if !slug.isEmpty { + return "/tmp/cmux-debug-\(slug).sock" + } + } + + let tag = launchTag(environment: environment)? + .lowercased() + .replacingOccurrences(of: ".", with: "-") + .replacingOccurrences(of: "_", with: "-") + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + .joined(separator: "-") + + guard bundleId == baseDebugBundleIdentifier, + let tag, + !tag.isEmpty else { + return nil + } + return "/tmp/cmux-debug-\(tag).sock" + } + static func isStagingBundleIdentifier(_ bundleIdentifier: String?) -> Bool { guard let bundleIdentifier else { return false } return bundleIdentifier == "com.cmuxterm.app.staging" diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 10cda9d6..97271038 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -30,11 +30,20 @@ enum NewWorkspacePlacement: String, CaseIterable, Identifiable { var description: String { switch self { case .top: - return String(localized: "workspace.placement.top.description", defaultValue: "Insert new workspaces at the top of the list.") + return String( + localized: "workspace.placement.top.description", + defaultValue: "Insert new workspaces at the top of the list." + ) case .afterCurrent: - return String(localized: "workspace.placement.afterCurrent.description", defaultValue: "Insert new workspaces directly after the active workspace.") + return String( + localized: "workspace.placement.afterCurrent.description", + defaultValue: "Insert new workspaces directly after the active workspace." + ) case .end: - return String(localized: "workspace.placement.end.description", defaultValue: "Append new workspaces to the bottom of the list.") + return String( + localized: "workspace.placement.end.description", + defaultValue: "Append new workspaces to the bottom of the list." + ) } } } @@ -138,9 +147,9 @@ enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable { var displayName: String { switch self { case .leftRail: - return String(localized: "sidebar.indicator.leftRail", defaultValue: "Left Rail") + return String(localized: "sidebar.activeTabIndicator.leftRail", defaultValue: "Left Rail") case .solidFill: - return String(localized: "sidebar.indicator.solidFill", defaultValue: "Solid Fill") + return String(localized: "sidebar.activeTabIndicator.solidFill", defaultValue: "Solid Fill") } } } @@ -900,37 +909,39 @@ class TabManager: ObservableObject { } var isFindVisible: Bool { - if selectedTerminalPanel?.searchState != nil { return true } - if focusedBrowserPanel?.searchState != nil { return true } - return false + selectedTerminalPanel?.searchState != nil || focusedBrowserPanel?.searchState != nil } var canUseSelectionForFind: Bool { - if focusedBrowserPanel != nil { return false } - return selectedTerminalPanel?.hasSelection() == true + selectedTerminalPanel?.hasSelection() == true } func startSearch() { - if let browser = focusedBrowserPanel { - browser.startFind() + if let panel = selectedTerminalPanel { + if panel.searchState == nil { + panel.searchState = TerminalSurface.SearchState() + } + NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) + NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) + _ = panel.performBindingAction("start_search") return } - guard let panel = selectedTerminalPanel else { + if let panel = selectedTerminalPanel { + let hadExistingSearch = panel.searchState != nil + let handled = startOrFocusTerminalSearch(panel.surface) + NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) #if DEBUG - dlog("find.startSearch SKIPPED no selectedTerminalPanel") + dlog( + "find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) " + + "panel=\(panel.id.uuidString.prefix(5)) existing=\(hadExistingSearch ? "yes" : "no") " + + "handled=\(handled ? 1 : 0) " + + "firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))" + ) #endif return } - let hadExistingSearch = panel.searchState != nil - let handled = startOrFocusTerminalSearch(panel.surface) -#if DEBUG - dlog( - "find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) " + - "panel=\(panel.id.uuidString.prefix(5)) existing=\(hadExistingSearch ? "yes" : "no") " + - "handled=\(handled ? 1 : 0) " + - "firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))" - ) -#endif + + focusedBrowserPanel?.startFind() } func searchSelection() { @@ -938,27 +949,27 @@ class TabManager: ObservableObject { if panel.searchState == nil { panel.searchState = TerminalSurface.SearchState() } -#if DEBUG - dlog("find.searchSelection workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5))") -#endif + NSLog("Find: searchSelection workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString) NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface) _ = panel.performBindingAction("search_selection") } func findNext() { - if let browser = focusedBrowserPanel, browser.searchState != nil { - browser.findNext() + if let panel = selectedTerminalPanel { + _ = panel.performBindingAction("search:next") return } - _ = selectedTerminalPanel?.performBindingAction("search:next") + + focusedBrowserPanel?.findNext() } func findPrevious() { - if let browser = focusedBrowserPanel, browser.searchState != nil { - browser.findPrevious() + if let panel = selectedTerminalPanel { + _ = panel.performBindingAction("search:previous") return } - _ = selectedTerminalPanel?.performBindingAction("search:previous") + + focusedBrowserPanel?.findPrevious() } @discardableResult @@ -968,19 +979,19 @@ class TabManager: ObservableObject { } func hideFind() { - if let browser = focusedBrowserPanel, browser.searchState != nil { - browser.hideFind() + if let panel = selectedTerminalPanel { + panel.searchState = nil return } -#if DEBUG - dlog("find.hideFind panel=\(selectedTerminalPanel?.id.uuidString.prefix(5) ?? "nil")") -#endif - selectedTerminalPanel?.searchState = nil + + focusedBrowserPanel?.hideFind() } @discardableResult func addWorkspace( workingDirectory overrideWorkingDirectory: String? = nil, + initialTerminalCommand: String? = nil, + initialTerminalEnvironment: [String: String] = [:], select: Bool = true, eagerLoadTerminal: Bool = false, placementOverride: NewWorkspacePlacement? = nil, @@ -1000,11 +1011,16 @@ class TabManager: ObservableObject { title: "Terminal \(nextTabCount)", workingDirectory: workingDirectory, portOrdinal: ordinal, - configTemplate: inheritedConfig + configTemplate: inheritedConfig, + initialTerminalCommand: initialTerminalCommand, + initialTerminalEnvironment: initialTerminalEnvironment ) newWorkspace.owningTabManager = self wireClosedBrowserTracking(for: newWorkspace) let insertIndex = newTabInsertIndex(snapshot: snapshot, placementOverride: placementOverride) + if eagerLoadTerminal && !select { + requestBackgroundWorkspaceLoad(for: newWorkspace.id) + } var updatedTabs = snapshot.tabs if insertIndex >= 0 && insertIndex <= updatedTabs.count { updatedTabs.insert(newWorkspace, at: insertIndex) @@ -1021,8 +1037,9 @@ class TabManager: ObservableObject { ) } if eagerLoadTerminal { - requestBackgroundWorkspaceLoad(for: newWorkspace.id) - newWorkspace.requestBackgroundTerminalSurfaceStartIfNeeded() + if select { + newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded() + } } if select { #if DEBUG @@ -1052,20 +1069,63 @@ class TabManager: ObservableObject { return newWorkspace } - private func sendWelcomeWhenReady(to workspace: Workspace, attempt: Int = 0) { - let maxAttempts = 60 + @MainActor + private func sendWelcomeWhenReady(to workspace: Workspace) { if let terminalPanel = workspace.focusedTerminalPanel, terminalPanel.surface.surface != nil { - // Wait a bit more for the shell prompt to be ready DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) terminalPanel.sendText("cmux welcome\n") } return } - guard attempt < maxAttempts else { return } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.sendWelcomeWhenReady(to: workspace, attempt: attempt + 1) + + var resolved = false + var readyObserver: NSObjectProtocol? + var panelsCancellable: AnyCancellable? + + func finishIfReady() { + guard !resolved, + let terminalPanel = workspace.focusedTerminalPanel, + terminalPanel.surface.surface != nil else { return } + resolved = true + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + panelsCancellable?.cancel() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) + terminalPanel.sendText("cmux welcome\n") + } + } + + panelsCancellable = workspace.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in + finishIfReady() + } + } + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { note in + guard let workspaceId = note.userInfo?["workspaceId"] as? UUID, + workspaceId == workspace.id else { return } + Task { @MainActor in + finishIfReady() + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + Task { @MainActor in + if let readyObserver, !resolved { + NotificationCenter.default.removeObserver(readyObserver) + } + if !resolved { + panelsCancellable?.cancel() + } + } } } @@ -1439,21 +1499,33 @@ class TabManager: ObservableObject { } func requestBackgroundWorkspaceLoad(for workspaceId: UUID) { - guard pendingBackgroundWorkspaceLoadIds.insert(workspaceId).inserted else { return } + guard !pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { return } + var updated = pendingBackgroundWorkspaceLoadIds + updated.insert(workspaceId) + pendingBackgroundWorkspaceLoadIds = updated } func completeBackgroundWorkspaceLoad(for workspaceId: UUID) { - guard pendingBackgroundWorkspaceLoadIds.remove(workspaceId) != nil else { return } + guard pendingBackgroundWorkspaceLoadIds.contains(workspaceId) else { return } + var updated = pendingBackgroundWorkspaceLoadIds + updated.remove(workspaceId) + pendingBackgroundWorkspaceLoadIds = updated } func retainDebugWorkspaceLoads(for workspaceIds: Set<UUID>) { guard !workspaceIds.isEmpty else { return } - debugPinnedWorkspaceLoadIds.formUnion(workspaceIds) + var updated = debugPinnedWorkspaceLoadIds + updated.formUnion(workspaceIds) + guard updated != debugPinnedWorkspaceLoadIds else { return } + debugPinnedWorkspaceLoadIds = updated } func releaseDebugWorkspaceLoads(for workspaceIds: Set<UUID>) { guard !workspaceIds.isEmpty else { return } - debugPinnedWorkspaceLoadIds.subtract(workspaceIds) + var updated = debugPinnedWorkspaceLoadIds + updated.subtract(workspaceIds) + guard updated != debugPinnedWorkspaceLoadIds else { return } + debugPinnedWorkspaceLoadIds = updated } func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) { @@ -1579,16 +1651,6 @@ class TabManager: ObservableObject { tabs.insert(tab, at: insertIndex) } - func moveTabToTopForNotification(_ tabId: UUID) { - guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } - let pinnedCount = tabs.filter { $0.isPinned }.count - guard index != pinnedCount else { return } - let tab = tabs[index] - guard !tab.isPinned else { return } - tabs.remove(at: index) - tabs.insert(tab, at: pinnedCount) - } - func moveTabsToTop(_ tabIds: Set<UUID>) { guard !tabIds.isEmpty else { return } let selectedTabs = tabs.filter { tabIds.contains($0.id) } @@ -1601,6 +1663,16 @@ class TabManager: ObservableObject { tabs = selectedPinned + remainingPinned + selectedUnpinned + remainingUnpinned } + func moveTabToTopForNotification(_ tabId: UUID) { + guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } + let pinnedCount = tabs.filter { $0.isPinned }.count + guard index != pinnedCount else { return } + let tab = tabs[index] + guard !tab.isPinned else { return } + tabs.remove(at: index) + tabs.insert(tab, at: pinnedCount) + } + @discardableResult func reorderWorkspace(tabId: UUID, toIndex targetIndex: Int) -> Bool { guard let currentIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false } @@ -1705,24 +1777,26 @@ class TabManager: ObservableObject { func closeWorkspace(_ workspace: Workspace) { guard tabs.count > 1 else { return } - guard let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return } sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) clearInitialWorkspaceGitProbe(workspaceId: workspace.id) sidebarSelectedWorkspaceIds.remove(workspace.id) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) - unwireClosedBrowserTracking(for: workspace) workspace.teardownAllPanels() + workspace.teardownRemoteConnection() + unwireClosedBrowserTracking(for: workspace) workspace.owningTabManager = nil - tabs.remove(at: index) + if let index = tabs.firstIndex(where: { $0.id == workspace.id }) { + tabs.remove(at: index) - if selectedTabId == workspace.id { - // Keep the "focused index" stable when possible: - // - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up). - // - Otherwise (we closed the last workspace), focus the new last workspace (i-1). - let newIndex = min(index, max(0, tabs.count - 1)) - selectedTabId = tabs[newIndex].id + if selectedTabId == workspace.id { + // Keep the "focused index" stable when possible: + // - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up). + // - Otherwise (we closed the last workspace), focus the new last workspace (i-1). + let newIndex = min(index, max(0, tabs.count - 1)) + selectedTabId = tabs[newIndex].id + } } } @@ -1796,13 +1870,9 @@ class TabManager: ObservableObject { let count = plan.panelIds.count let titleLines = plan.titles.map { "• \($0)" }.joined(separator: "\n") - let message = if count == 1 { - String(localized: "dialog.closeOtherTabs.message.one", defaultValue: "This will close 1 tab in this pane:\n\(titleLines)") - } else { - String(localized: "dialog.closeOtherTabs.message.other", defaultValue: "This will close \(count) tabs in this pane:\n\(titleLines)") - } + let message = "This is about to close \(count) tab\(count == 1 ? "" : "s") in this pane:\n\(titleLines)" guard confirmClose( - title: String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"), + title: "Close other tabs?", message: message, acceptCmdD: false ) else { return } @@ -1881,8 +1951,8 @@ class TabManager: ObservableObject { alert.messageText = title alert.informativeText = message alert.alertStyle = .warning - alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close")) + alert.addButton(withTitle: String(localized: "dialog.closeTab.cancel", defaultValue: "Cancel")) if let closeButton = alert.buttons.first { closeButton.keyEquivalent = "\r" @@ -1953,7 +2023,7 @@ class TabManager: ObservableObject { if let collapsed, !collapsed.isEmpty { return collapsed } - return String(localized: "tab.untitled", defaultValue: "Untitled Tab") + return "Untitled Tab" } private func orderedClosableWorkspaces(_ workspaceIds: [UUID], allowPinned: Bool) -> [Workspace] { @@ -2357,32 +2427,28 @@ class TabManager: ObservableObject { guard !shouldSuppressFlash else { return } guard AppFocusState.isAppActive() else { return } guard let panelId = focusedPanelId(for: tabId) else { return } - _ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true) + markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId) } private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) { guard selectedTabId == tabId else { return } guard !suppressFocusFlash else { return } - _ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true) + guard AppFocusState.isAppActive() else { return } + guard let notificationStore = AppDelegate.shared?.notificationStore else { return } + guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: panelId) else { return } + if let tab = tabs.first(where: { $0.id == tabId }) { + tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false) + } + notificationStore.markRead(forTabId: tabId, surfaceId: panelId) } @discardableResult func dismissNotificationOnDirectInteraction(tabId: UUID, surfaceId: UUID?) -> Bool { - dismissNotificationIfActive(tabId: tabId, surfaceId: surfaceId, triggerFlash: true) - } - - @discardableResult - private func dismissNotificationIfActive( - tabId: UUID, - surfaceId: UUID?, - triggerFlash: Bool - ) -> Bool { guard selectedTabId == tabId else { return false } guard AppFocusState.isAppActive() else { return false } guard let notificationStore = AppDelegate.shared?.notificationStore else { return false } guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return false } - if triggerFlash, - let panelId = surfaceId, + if let panelId = surfaceId, let tab = tabs.first(where: { $0.id == tabId }) { tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false) } @@ -2725,28 +2791,22 @@ class TabManager: ObservableObject { // MARK: - Split Creation /// Create a new split in the current tab - func createSplit(direction: SplitDirection) { + @discardableResult + func createSplit(direction: SplitDirection) -> UUID? { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), - let focusedPanelId = tab.focusedPanelId else { return } -#if DEBUG - let directionLabel = direction.debugLabel - dlog( - "split.create.request kind=terminal dir=\(directionLabel) " + - "tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " + - "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" - ) -#endif + let focusedPanelId = tab.focusedPanelId else { return nil } + return createSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) + } + + /// Create a new split from an explicit source panel. + @discardableResult + func createSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? { + guard let tab = tabs.first(where: { $0.id == tabId }), + tab.panels[surfaceId] != nil else { return nil } tab.clearSplitZoom() sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)]) - let createdPanelId = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) -#if DEBUG - dlog( - "split.create.result kind=terminal dir=\(directionLabel) " + - "created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " + - "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" - ) -#endif + return newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction, focus: focus) } /// Create a new browser split from the currently focused panel. @@ -2755,30 +2815,14 @@ class TabManager: ObservableObject { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }), let focusedPanelId = tab.focusedPanelId else { return nil } -#if DEBUG - let directionLabel = direction.debugLabel - dlog( - "split.create.request kind=browser dir=\(directionLabel) " + - "tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " + - "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" - ) -#endif tab.clearSplitZoom() - let createdPanelId = newBrowserSplit( + return newBrowserSplit( tabId: selectedTabId, fromPanelId: focusedPanelId, orientation: direction.orientation, insertFirst: direction.insertFirst, url: url ) -#if DEBUG - dlog( - "split.create.result kind=browser dir=\(directionLabel) " + - "created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " + - "panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)" - ) -#endif - return createdPanelId } /// Refresh Bonsplit right-side action button tooltips for all workspaces. @@ -2879,21 +2923,12 @@ class TabManager: ObservableObject { /// Returns the new panel's ID (which is also the surface ID for terminals) func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } - let createdPanel = tab.newTerminalSplit( + return tab.newTerminalSplit( from: surfaceId, orientation: direction.orientation, insertFirst: direction.insertFirst, focus: focus )?.id -#if DEBUG - let directionLabel = direction.debugLabel - dlog( - "split.newSurface result dir=\(directionLabel) " + - "tab=\(tabId.uuidString.prefix(5)) source=\(surfaceId.uuidString.prefix(5)) " + - "created=\(createdPanel?.uuidString.prefix(5) ?? "nil") focus=\(focus ? 1 : 0)" - ) -#endif - return createdPanel } /// Move focus in the specified direction @@ -3284,31 +3319,150 @@ class TabManager: ObservableObject { } #if DEBUG + @MainActor + private func waitForWorkspacePanelsCondition( + tab: Workspace, + timeoutSeconds: TimeInterval, + condition: @escaping (Workspace) -> Bool + ) async -> Bool { + guard !condition(tab) else { return true } + + return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in + var resolved = false + var cancellable: AnyCancellable? + + func finish(_ value: Bool) { + guard !resolved else { return } + resolved = true + cancellable?.cancel() + cont.resume(returning: value) + } + + func evaluate() { + if condition(tab) { + finish(true) + } + } + + cancellable = tab.$panels + .map { _ in () } + .sink { _ in evaluate() } + + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { + Task { @MainActor in + finish(condition(tab)) + } + } + evaluate() + } + } + + @MainActor + private func waitForTerminalPanelCondition( + tab: Workspace, + panelId: UUID, + timeoutSeconds: TimeInterval, + condition: @escaping (TerminalPanel) -> Bool + ) async -> Bool { + if let panel = tab.terminalPanel(for: panelId), condition(panel) { + return true + } + + return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in + var resolved = false + var panelsCancellable: AnyCancellable? + var readyObserver: NSObjectProtocol? + var hostedViewObserver: NSObjectProtocol? + + @MainActor + func finish(_ value: Bool) { + guard !resolved else { return } + resolved = true + panelsCancellable?.cancel() + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + if let hostedViewObserver { + NotificationCenter.default.removeObserver(hostedViewObserver) + } + cont.resume(returning: value) + } + + @MainActor + func evaluate() { + guard let panel = tab.terminalPanel(for: panelId) else { + finish(false) + return + } + panel.surface.requestBackgroundSurfaceStartIfNeeded() + if condition(panel) { + finish(true) + } + } + + panelsCancellable = tab.$panels + .map { _ in () } + .sink { _ in + Task { @MainActor in + evaluate() + } + } + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { note in + guard let readySurfaceId = note.userInfo?["surfaceId"] as? UUID, + readySurfaceId == panelId else { return } + Task { @MainActor in + evaluate() + } + } + hostedViewObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { note in + guard let hostedSurfaceId = note.userInfo?["surfaceId"] as? UUID, + hostedSurfaceId == panelId else { return } + Task { @MainActor in + evaluate() + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { + Task { @MainActor in + if let panel = tab.terminalPanel(for: panelId) { + finish(condition(panel)) + } else { + finish(false) + } + } + } + evaluate() + } + } + @MainActor private func waitForTerminalPanelReadyForUITest( tab: Workspace, panelId: UUID, timeoutSeconds: TimeInterval = 6.0 ) async -> (attached: Bool, hasSurface: Bool, firstResponder: Bool) { - let deadline = Date().addingTimeInterval(timeoutSeconds) var attached = false var hasSurface = false var firstResponder = false - while Date() < deadline { - guard let panel = tab.terminalPanel(for: panelId) else { - return (false, false, false) - } - + let _ = await waitForTerminalPanelCondition( + tab: tab, + panelId: panelId, + timeoutSeconds: timeoutSeconds + ) { panel in panel.surface.requestBackgroundSurfaceStartIfNeeded() attached = panel.hostedView.window != nil hasSurface = panel.surface.surface != nil firstResponder = panel.hostedView.isSurfaceViewFirstResponder() - - if attached, hasSurface { - return (attached, hasSurface, firstResponder) - } - try? await Task.sleep(nanoseconds: 50_000_000) + return attached && hasSurface } return (attached, hasSurface, firstResponder) @@ -3525,7 +3679,7 @@ class TabManager: ObservableObject { continue } terminal.hostedView.reconcileGeometryNow() - terminal.surface.forceRefresh(reason: "tabManager.reconcileVisibleTerminalGeometry") + terminal.surface.forceRefresh() } } @@ -3912,7 +4066,16 @@ class TabManager: ObservableObject { for panelId in tab.panels.keys where panelId != leftPanelId { tab.closePanel(panelId, force: true) } - try? await Task.sleep(nanoseconds: 80_000_000) + let collapsed = await self.waitForWorkspacePanelsCondition( + tab: tab, + timeoutSeconds: 2.0 + ) { workspace in + workspace.panels.count == 1 + } + if !collapsed { + write(["setupError": "Timed out collapsing workspace before iteration \(i)", "done": "1"]) + return + } } guard let rightPanel = tab.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { @@ -3929,12 +4092,12 @@ class TabManager: ObservableObject { tab.focusPanel(rightPanel.id) // Wait for the split terminal surface to be attached before sending exit. // Without this, very early writes can be dropped during initial surface creation. - let readyDeadline = Date().addingTimeInterval(2.0) - while Date() < readyDeadline { - let attached = rightPanel.hostedView.window != nil - let hasSurface = rightPanel.surface.surface != nil - if attached && hasSurface { break } - try? await Task.sleep(nanoseconds: 50_000_000) + _ = await self.waitForTerminalPanelCondition( + tab: tab, + panelId: rightPanel.id, + timeoutSeconds: 2.0 + ) { panel in + panel.hostedView.window != nil && panel.surface.surface != nil } // Use an explicit shell exit command for deterministic child-exit behavior across // startup timing variance; this still exercises the same SHOW_CHILD_EXITED path. @@ -4081,12 +4244,13 @@ class TabManager: ObservableObject { tab.closePanel(bottomRight.id, force: true) exitPanelId = leftPanelId - let closeDeadline = Date().addingTimeInterval(2.0) - while Date() < closeDeadline { - if tab.panels.count == 2 { break } - try? await Task.sleep(nanoseconds: 50_000_000) + let collapsed = await self.waitForWorkspacePanelsCondition( + tab: tab, + timeoutSeconds: 2.0 + ) { workspace in + workspace.panels.count == 2 } - if tab.panels.count != 2 { + if !collapsed { write([ "setupError": "Expected 2 panels after closing right column, got \(tab.panels.count)", "done": "1", @@ -4119,12 +4283,13 @@ class TabManager: ObservableObject { for panelId in Array(tab.panels.keys) where !keepPanels.contains(panelId) { tab.focusPanel(panelId) tab.closePanel(panelId, force: true) - let deadline = Date().addingTimeInterval(1.0) - while Date() < deadline { - if tab.panels[panelId] == nil { break } - try? await Task.sleep(nanoseconds: 25_000_000) + let closed = await self.waitForWorkspacePanelsCondition( + tab: tab, + timeoutSeconds: 1.0 + ) { workspace in + workspace.panels[panelId] == nil } - if tab.panels[panelId] != nil { + if !closed { write([ "setupError": "Failed to close bottom pane \(panelId.uuidString)", "done": "1", @@ -4134,12 +4299,13 @@ class TabManager: ObservableObject { } exitPanelId = leftPanelId - let closeDeadline = Date().addingTimeInterval(2.0) - while Date() < closeDeadline { - if tab.panels.count == 2 { break } - try? await Task.sleep(nanoseconds: 50_000_000) + let collapsed = await self.waitForWorkspacePanelsCondition( + tab: tab, + timeoutSeconds: 2.0 + ) { workspace in + workspace.panels.count == 2 } - if tab.panels.count != 2 { + if !collapsed { write([ "setupError": "Expected 2 panels after closing bottom row, got \(tab.panels.count)", "done": "1", @@ -4174,7 +4340,6 @@ class TabManager: ObservableObject { return } self.ensureFocusedTerminalFirstResponder() - try? await Task.sleep(nanoseconds: 80_000_000) } else if let exitPanel = tab.terminalPanel(for: exitPanelId) { exitPanelAttachedBeforeCtrlD = exitPanel.hostedView.window != nil exitPanelHasSurfaceBeforeCtrlD = exitPanel.surface.surface != nil @@ -4292,20 +4457,19 @@ class TabManager: ObservableObject { var attachedBeforeTrigger = false var hasSurfaceBeforeTrigger = false if shouldWaitForSurface { - // Wait for the target panel to be fully attached after split churn. - let readyDeadline = Date().addingTimeInterval(5.0) - while Date() < readyDeadline { - guard let panel = tab.terminalPanel(for: exitPanelId) else { - write(["autoTriggerError": "missingExitPanelBeforeTrigger"]) - return - } - panel.surface.requestBackgroundSurfaceStartIfNeeded() + let ready = await self.waitForTerminalPanelCondition( + tab: tab, + panelId: exitPanelId, + timeoutSeconds: 5.0 + ) { panel in attachedBeforeTrigger = panel.hostedView.window != nil hasSurfaceBeforeTrigger = panel.surface.surface != nil - if attachedBeforeTrigger, hasSurfaceBeforeTrigger { - break - } - try? await Task.sleep(nanoseconds: 50_000_000) + return attachedBeforeTrigger && hasSurfaceBeforeTrigger + } + if !ready, + tab.terminalPanel(for: exitPanelId) == nil { + write(["autoTriggerError": "missingExitPanelBeforeTrigger"]) + return } } else if let panel = tab.terminalPanel(for: exitPanelId) { attachedBeforeTrigger = panel.hostedView.window != nil @@ -4403,11 +4567,13 @@ extension TabManager { } func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot { - let workspaceSnapshots = tabs + let restorableTabs = tabs + .filter { !$0.isRemoteWorkspace } .prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) + let workspaceSnapshots = restorableTabs .map { $0.sessionSnapshot(includeScrollback: includeScrollback) } let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in - tabs.firstIndex(where: { $0.id == selectedTabId }) + restorableTabs.firstIndex(where: { $0.id == selectedTabId }) } return SessionTabManagerSnapshot( selectedWorkspaceIndex: selectedWorkspaceIndex, @@ -4523,15 +4689,6 @@ enum SplitDirection { var insertFirst: Bool { self == .left || self == .up } - - var debugLabel: String { - switch self { - case .left: return "left" - case .right: return "right" - case .up: return "up" - case .down: return "down" - } - } } /// Resize direction for backwards compatibility @@ -4562,4 +4719,6 @@ extension Notification.Name { static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar") static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar") static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick") + static let terminalPortalVisibilityDidChange = Notification.Name("cmux.terminalPortalVisibilityDidChange") + static let browserPortalRegistryDidChange = Notification.Name("cmux.browserPortalRegistryDidChange") } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 4515805a..de1e96f3 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -4,6 +4,14 @@ import Foundation import Bonsplit import WebKit +extension Notification.Name { + static let socketListenerDidStart = Notification.Name("cmux.socketListenerDidStart") + static let terminalSurfaceDidBecomeReady = Notification.Name("cmux.terminalSurfaceDidBecomeReady") + static let terminalSurfaceHostedViewDidMoveToWindow = Notification.Name("cmux.terminalSurfaceHostedViewDidMoveToWindow") + static let mainWindowContextsDidChange = Notification.Name("cmux.mainWindowContextsDidChange") + static let browserDownloadEventDidArrive = Notification.Name("cmux.browserDownloadEventDidArrive") +} + /// Unix socket-based controller for programmatic terminal control /// Allows automated testing and external control of terminal tabs @MainActor @@ -13,9 +21,6 @@ class TerminalController { let acceptLoopAlive: Bool let socketPathMatches: Bool let socketPathExists: Bool - let socketProbePerformed: Bool - let socketConnectable: Bool? - let socketConnectErrno: Int32? var failureSignals: [String] { var signals: [String] = [] @@ -23,9 +28,6 @@ class TerminalController { if !acceptLoopAlive { signals.append("accept_loop_dead") } if !socketPathMatches { signals.append("socket_path_mismatch") } if !socketPathExists { signals.append("socket_missing") } - if socketProbePerformed && isRunning && acceptLoopAlive && socketPathMatches && socketPathExists && socketConnectable == false { - signals.append("socket_unreachable") - } return signals } @@ -43,6 +45,7 @@ class TerminalController { private nonisolated(unsafe) var activeAcceptLoopGeneration: UInt64 = 0 private nonisolated(unsafe) var nextAcceptLoopGeneration: UInt64 = 0 private nonisolated(unsafe) var pendingAcceptLoopRearmGeneration: UInt64? + private nonisolated(unsafe) var pendingAcceptLoopResumeGeneration: UInt64? private nonisolated(unsafe) var listenerStartInProgress = false private nonisolated let listenerStateLock = NSLock() private var clientHandlers: [Int32: Thread] = [:] @@ -76,9 +79,36 @@ class TerminalController { let acceptLoopAlive: Bool let activeGeneration: UInt64 let pendingRearmGeneration: UInt64? + let pendingResumeGeneration: UInt64? let listenerStartInProgress: Bool } + enum AcceptFailureRecoveryAction: Equatable { + case retryImmediately + case resumeAfterDelay(delayMs: Int) + case rearmAfterDelay(delayMs: Int) + + var delayMs: Int { + switch self { + case .retryImmediately: + return 0 + case .resumeAfterDelay(let delayMs), .rearmAfterDelay(let delayMs): + return delayMs + } + } + + var debugLabel: String { + switch self { + case .retryImmediately: + return "retry_immediately" + case .resumeAfterDelay: + return "resume_after_delay" + case .rearmAfterDelay: + return "rearm_after_delay" + } + } + } + private enum SocketBindAttemptResult { case success(path: String) case pathTooLong(path: String) @@ -167,8 +197,24 @@ class TerminalController { private var v2BrowserDownloadEventsBySurface: [UUID: [[String: Any]]] = [:] private var v2BrowserUnsupportedNetworkRequestsBySurface: [UUID: [[String: Any]]] = [:] private let v2BrowserUndefinedSentinel = V2BrowserUndefinedSentinel() + private var browserDownloadObserver: NSObjectProtocol? - private init() {} + private init() { + browserDownloadObserver = NotificationCenter.default.addObserver( + forName: .browserDownloadEventDidArrive, + object: nil, + queue: .main + ) { [weak self] note in + guard let surfaceId = note.userInfo?["surfaceId"] as? UUID, + let event = note.userInfo?["event"] as? [String: Any] else { return } + Task { @MainActor [weak self] in + guard let self else { return } + var queue = self.v2BrowserDownloadEventsBySurface[surfaceId] ?? [] + queue.append(event) + self.v2BrowserDownloadEventsBySurface[surfaceId] = queue + } + } + } private nonisolated func withListenerState<T>(_ body: () -> T) -> T { listenerStateLock.lock() @@ -185,6 +231,7 @@ class TerminalController { acceptLoopAlive: acceptLoopAlive, activeGeneration: activeAcceptLoopGeneration, pendingRearmGeneration: pendingAcceptLoopRearmGeneration, + pendingResumeGeneration: pendingAcceptLoopResumeGeneration, listenerStartInProgress: listenerStartInProgress ) } @@ -220,6 +267,28 @@ class TerminalController { return socketCommandFocusAllowanceStack.last ?? false } + private func socketCommandAllowsInAppFocusMutations() -> Bool { + Self.allowsInAppFocusMutationsForActiveSocketCommand() + } + + private func v2FocusAllowed(requested: Bool = true) -> Bool { + requested && socketCommandAllowsInAppFocusMutations() + } + + private func v2MaybeFocusWindow(for tabManager: TabManager) { + guard socketCommandAllowsInAppFocusMutations(), + let windowId = v2ResolveWindowId(tabManager: tabManager) else { return } + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + + private func v2MaybeSelectWorkspace(_ tabManager: TabManager, workspace: Workspace) { + guard socketCommandAllowsInAppFocusMutations() else { return } + if tabManager.selectedTabId != workspace.id { + tabManager.selectWorkspace(workspace) + } + } + private static func socketCommandAllowsInAppFocusMutations(commandKey: String, isV2: Bool) -> Bool { if isV2 { return focusIntentV2Methods.contains(commandKey) @@ -244,27 +313,26 @@ class TerminalController { return body() } - private func socketCommandAllowsInAppFocusMutations() -> Bool { - Self.allowsInAppFocusMutationsForActiveSocketCommand() - } - - private func v2FocusAllowed(requested: Bool = true) -> Bool { - requested && socketCommandAllowsInAppFocusMutations() - } - - private func v2MaybeFocusWindow(for tabManager: TabManager) { - guard socketCommandAllowsInAppFocusMutations(), - let windowId = v2ResolveWindowId(tabManager: tabManager) else { return } - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - - private func v2MaybeSelectWorkspace(_ tabManager: TabManager, workspace: Workspace) { - guard socketCommandAllowsInAppFocusMutations() else { return } - if tabManager.selectedTabId != workspace.id { - tabManager.selectWorkspace(workspace) +#if DEBUG + static func debugSocketCommandPolicySnapshot( + commandKey: String, + isV2: Bool + ) -> (insideSuppressed: Bool, insideAllowsFocus: Bool, outsideSuppressed: Bool, outsideAllowsFocus: Bool) { + var insideSuppressed = false + var insideAllowsFocus = false + _ = Self.shared.withSocketCommandPolicy(commandKey: commandKey, isV2: isV2) { + insideSuppressed = Self.shouldSuppressSocketCommandActivation() + insideAllowsFocus = Self.socketCommandAllowsInAppFocusMutations() + return 0 } + return ( + insideSuppressed: insideSuppressed, + insideAllowsFocus: insideAllowsFocus, + outsideSuppressed: Self.shouldSuppressSocketCommandActivation(), + outsideAllowsFocus: Self.socketCommandAllowsInAppFocusMutations() + ) } +#endif nonisolated static func shouldReplaceStatusEntry( current: SidebarStatusEntry?, @@ -377,7 +445,6 @@ class TerminalController { } private static let socketFastPathState = SocketFastPathState() - nonisolated static func explicitSocketScope( options: [String: String] ) -> (workspaceId: UUID, panelId: UUID)? { @@ -401,6 +468,36 @@ class TerminalController { return trimmed } + nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let url = URL(string: trimmed), + url.isFileURL, + !url.path.isEmpty { + return url.path + } + return trimmed.hasPrefix("/") ? trimmed : nil + } + + nonisolated static func shouldRemoveExportedScreenFile( + fileURL: URL, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> Bool { + let standardizedFile = fileURL.standardizedFileURL + let temporary = temporaryDirectory.standardizedFileURL + return standardizedFile.path.hasPrefix(temporary.path + "/") + } + + nonisolated static func shouldRemoveExportedScreenDirectory( + fileURL: URL, + temporaryDirectory: URL = FileManager.default.temporaryDirectory + ) -> Bool { + let directory = fileURL.deletingLastPathComponent().standardizedFileURL + let temporary = temporaryDirectory.standardizedFileURL + return directory.path.hasPrefix(temporary.path + "/") + } + nonisolated static func parseReportedShellActivityState( _ rawState: String ) -> Workspace.PanelShellActivityState? { @@ -583,6 +680,31 @@ class TerminalController { ) } + nonisolated static func acceptFailureRecoveryAction( + errnoCode: Int32, + consecutiveFailures: Int + ) -> AcceptFailureRecoveryAction { + let classification = acceptErrorClassification(errnoCode: errnoCode) + if classification == "immediate_retry" { + return .retryImmediately + } + + if classification == "fatal" + || shouldRearmForConsecutiveAcceptFailures(consecutiveFailures: consecutiveFailures) { + return .rearmAfterDelay( + delayMs: acceptFailureRearmDelayMilliseconds( + consecutiveFailures: consecutiveFailures + ) + ) + } + + return .resumeAfterDelay( + delayMs: acceptFailureBackoffMilliseconds( + consecutiveFailures: consecutiveFailures + ) + ) + } + nonisolated static func shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: Int) -> Bool { guard consecutiveFailures > 0 else { return false } if consecutiveFailures <= 3 { @@ -633,66 +755,33 @@ class TerminalController { } } - private nonisolated static func probeSocketConnectability(path: String) -> (isConnectable: Bool?, errnoCode: Int32?) { - let probeSocket = socket(AF_UNIX, SOCK_STREAM, 0) - guard probeSocket >= 0 else { - return (false, errno) - } - defer { close(probeSocket) } + private nonisolated static func makeSocketTimeout(_ timeout: TimeInterval) -> timeval { + let normalizedTimeout = max(timeout, 0) + let seconds = floor(normalizedTimeout) + let microseconds = (normalizedTimeout - seconds) * 1_000_000 + return timeval(tv_sec: Int(seconds), tv_usec: Int32(microseconds.rounded())) + } - let existingFlags = fcntl(probeSocket, F_GETFL, 0) - if existingFlags >= 0 { - _ = fcntl(probeSocket, F_SETFL, existingFlags | O_NONBLOCK) + private nonisolated static func configureSocketTimeouts(_ fd: Int32, timeout: TimeInterval) { + var socketTimeout = makeSocketTimeout(timeout) + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_RCVTIMEO, + ptr, + socklen_t(MemoryLayout<timeval>.size) + ) } - - guard var addr = unixSocketAddress(path: path) else { - return (false, ENAMETOOLONG) + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_SNDTIMEO, + ptr, + socklen_t(MemoryLayout<timeval>.size) + ) } - let connectResult = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - connect(probeSocket, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size)) - } - } - if connectResult == 0 { - return (true, nil) - } - let connectErrno = errno - if connectErrno == EINPROGRESS { - var pollDescriptor = pollfd(fd: probeSocket, events: Int16(POLLOUT), revents: 0) - for attempt in 0..<Self.socketProbePollAttempts { - pollDescriptor.revents = 0 - let pollResult = poll(&pollDescriptor, 1, Self.socketProbePollTimeoutMs) - if pollResult > 0 { - var socketError: Int32 = 0 - var socketErrorLength = socklen_t(MemoryLayout<Int32>.size) - let status = getsockopt( - probeSocket, - SOL_SOCKET, - SO_ERROR, - &socketError, - &socketErrorLength - ) - if status == 0 && socketError == 0 { - return (true, nil) - } - if status == 0 { - return (false, socketError) - } - return (false, errno) - } - - let pollErrno = errno - if pollResult == 0 || pollErrno == EINTR { - if attempt + 1 < Self.socketProbePollAttempts { - usleep(Self.socketProbePollRetryBackoffUs) - continue - } - return (false, pollResult == 0 ? ETIMEDOUT : pollErrno) - } - return (false, pollErrno) - } - } - return (false, connectErrno) } private nonisolated static func bindListenerSocket(_ socket: Int32, path: String) -> SocketBindAttemptResult { @@ -870,6 +959,7 @@ class TerminalController { let generation = withListenerState { isRunning = true pendingAcceptLoopRearmGeneration = nil + pendingAcceptLoopResumeGeneration = nil nextAcceptLoopGeneration &+= 1 let generation = nextAcceptLoopGeneration activeAcceptLoopGeneration = generation @@ -890,6 +980,11 @@ class TerminalController { "backlog": Self.socketListenBacklog ] ) + NotificationCenter.default.post( + name: .socketListenerDidStart, + object: self, + userInfo: ["path": activeSocketPath] + ) // Wire batched port scanner results back to workspace state. PortScanner.shared.onPortsUpdated = { [weak self] workspaceId, panelId, ports in @@ -898,14 +993,7 @@ class TerminalController { guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } let validSurfaceIds = Set(workspace.panels.keys) guard validSurfaceIds.contains(panelId) else { return } - let nextPorts = Array(Set(ports)).sorted() - let currentPorts = workspace.surfaceListeningPorts[panelId] ?? [] - guard currentPorts != nextPorts else { return } - if nextPorts.isEmpty { - workspace.surfaceListeningPorts.removeValue(forKey: panelId) - } else { - workspace.surfaceListeningPorts[panelId] = nextPorts - } + workspace.surfaceListeningPorts[panelId] = ports.isEmpty ? nil : ports workspace.recomputeListeningPorts() } } @@ -922,19 +1010,12 @@ class TerminalController { var st = stat() let exists = lstat(expectedSocketPath, &st) == 0 && (st.st_mode & S_IFMT) == S_IFSOCK - let shouldProbeConnection = snapshot.isRunning && snapshot.acceptLoopAlive && pathMatches && exists - let connectability = shouldProbeConnection - ? Self.probeSocketConnectability(path: expectedSocketPath) - : (isConnectable: nil, errnoCode: nil) return SocketListenerHealth( isRunning: snapshot.isRunning, acceptLoopAlive: snapshot.acceptLoopAlive, socketPathMatches: pathMatches, - socketPathExists: exists, - socketProbePerformed: shouldProbeConnection, - socketConnectable: connectability.isConnectable, - socketConnectErrno: connectability.errnoCode + socketPathExists: exists ) } @@ -946,6 +1027,7 @@ class TerminalController { let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { return nil } defer { close(fd) } + Self.configureSocketTimeouts(fd, timeout: timeout) #if os(macOS) var noSigPipe: Int32 = 1 @@ -1002,22 +1084,19 @@ class TerminalController { } guard wroteAll else { return nil } - let deadline = Date().addingTimeInterval(timeout) var buffer = [UInt8](repeating: 0, count: 4096) var response = "" - while Date() < deadline { - var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) - let ready = poll(&pollDescriptor, 1, 100) - if ready < 0 { + while true { + let count = read(fd, &buffer, buffer.count) + if count < 0 { + let readErrno = errno + if readErrno == EAGAIN || readErrno == EWOULDBLOCK { + break + } return nil } - if ready == 0 { - continue - } - - let count = read(fd, &buffer, buffer.count) - if count <= 0 { + if count == 0 { break } if let chunk = String(bytes: buffer[0..<count], encoding: .utf8) { @@ -1037,6 +1116,7 @@ class TerminalController { isRunning = false acceptLoopAlive = false pendingAcceptLoopRearmGeneration = nil + pendingAcceptLoopResumeGeneration = nil listenerStartInProgress = false nextAcceptLoopGeneration &+= 1 activeAcceptLoopGeneration = 0 @@ -1201,11 +1281,25 @@ class TerminalController { var lastAcceptErrno: Int32? var lastAcceptErrnoClass = "none" var rearmRequested = false + var resumeRequested = false defer { let cleanup = withListenerState { guard generation == activeAcceptLoopGeneration else { - return (shouldCaptureExit: false, socketToClose: Int32(-1), pathToUnlink: nil as String?) + return ( + shouldCaptureExit: false, + socketToClose: Int32(-1), + pathToUnlink: nil as String? + ) + } + + if resumeRequested && exitReason == "accept_backoff_resume" { + acceptLoopAlive = false + return ( + shouldCaptureExit: false, + socketToClose: Int32(-1), + pathToUnlink: nil as String? + ) } if isRunning && exitReason == "stopped" { @@ -1216,6 +1310,7 @@ class TerminalController { acceptLoopAlive = false isRunning = false activeAcceptLoopGeneration = 0 + pendingAcceptLoopResumeGeneration = nil var socketToClose: Int32 = -1 var pathToUnlink: String? @@ -1244,7 +1339,8 @@ class TerminalController { "reason": exitReason, "generation": generation, "errnoClass": lastAcceptErrnoClass, - "rearmRequested": rearmRequested ? 1 : 0 + "rearmRequested": rearmRequested ? 1 : 0, + "resumeRequested": resumeRequested ? 1 : 0 ] ) sentryBreadcrumb("socket.listener.accept_loop.exited", category: "socket", data: data) @@ -1285,10 +1381,8 @@ class TerminalController { } consecutiveFailures += 1 - let backoffMs = Self.acceptFailureBackoffMilliseconds( - consecutiveFailures: consecutiveFailures - ) - let rearmDelayMs = Self.acceptFailureRearmDelayMilliseconds( + let recoveryAction = Self.acceptFailureRecoveryAction( + errnoCode: errnoCode, consecutiveFailures: consecutiveFailures ) @@ -1303,18 +1397,16 @@ class TerminalController { "consecutiveFailures": consecutiveFailures, "generation": generation, "errnoClass": errnoClass, - "backoffMs": backoffMs + "delayMs": recoveryAction.delayMs, + "recoveryAction": recoveryAction.debugLabel ] ) ) } let shouldRearmForFatalErrno = Self.shouldRearmListenerForAcceptError(errnoCode: errnoCode) - let shouldRearmForPersistentFailures = Self.shouldRearmForConsecutiveAcceptFailures( - consecutiveFailures: consecutiveFailures - ) - if shouldRearmForFatalErrno || shouldRearmForPersistentFailures { + if case .rearmAfterDelay(let delayMs) = recoveryAction { exitReason = shouldRearmForFatalErrno ? "fatal_accept_error" : "persistent_accept_failures" @@ -1326,14 +1418,27 @@ class TerminalController { generation: generation, errnoCode: errnoCode, consecutiveFailures: consecutiveFailures, - delayMs: rearmDelayMs + delayMs: delayMs ) break } - if backoffMs > 0 { - usleep(useconds_t(backoffMs * 1_000)) + if case .resumeAfterDelay(let delayMs) = recoveryAction { + exitReason = "accept_backoff_resume" + resumeRequested = true + withListenerState { + pendingAcceptLoopResumeGeneration = generation + } + scheduleAcceptLoopResume( + listenerSocket: listenerSocket, + generation: generation, + errnoCode: errnoCode, + consecutiveFailures: consecutiveFailures, + delayMs: delayMs + ) + break } + continue } @@ -1351,6 +1456,51 @@ class TerminalController { } } + private nonisolated func scheduleAcceptLoopResume( + listenerSocket: Int32, + generation: UInt64, + errnoCode: Int32, + consecutiveFailures: Int, + delayMs: Int + ) { + let deadline = DispatchTime.now() + .milliseconds(delayMs) + DispatchQueue.main.asyncAfter(deadline: deadline) { [weak self] in + guard let self else { return } + let shouldResume = self.withListenerState { + guard self.pendingAcceptLoopResumeGeneration == generation else { return false } + guard self.activeAcceptLoopGeneration == generation else { + self.pendingAcceptLoopResumeGeneration = nil + return false + } + guard self.isRunning, self.serverSocket == listenerSocket else { + self.pendingAcceptLoopResumeGeneration = nil + return false + } + self.pendingAcceptLoopResumeGeneration = nil + return true + } + guard shouldResume else { return } + + sentryBreadcrumb( + "socket.listener.resume.requested", + category: "socket", + data: self.socketListenerEventData( + stage: "accept_resume", + errnoCode: errnoCode, + extra: [ + "generation": generation, + "consecutiveFailures": consecutiveFailures, + "resumeDelayMs": delayMs + ] + ) + ) + + Thread.detachNewThread { [weak self] in + self?.acceptLoop(listenerSocket: listenerSocket, generation: generation) + } + } + } + private nonisolated func scheduleListenerRearm( generation: UInt64, errnoCode: Int32, @@ -1392,7 +1542,7 @@ class TerminalController { defer { close(socket) } // In cmuxOnly mode, verify the connecting process is a descendant of cmux. - // Other modes allow external clients and apply separate auth controls. + // In allowAll mode (env-var only), skip the ancestry check. if accessMode == .cmuxOnly { // Use pre-captured peer PID if available (captured in accept loop before // the peer can disconnect), falling back to live lookup. @@ -1463,11 +1613,7 @@ class TerminalController { let cmd = parts[0].lowercased() let args = parts.count > 1 ? parts[1] : "" - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif - - let response = withSocketCommandPolicy(commandKey: cmd, isV2: false) { + return withSocketCommandPolicy(commandKey: cmd, isV2: false) { switch cmd { case "ping": return "PONG" @@ -1797,25 +1943,13 @@ class TerminalController { case "refresh_surfaces": return refreshSurfaces() - case "surface_health": - return surfaceHealth(args) + case "surface_health": + return surfaceHealth(args) - default: - return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands." + default: + return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands." + } } - } - - #if DEBUG - if cmd == "new_workspace" || cmd == "send" || cmd == "send_surface" { - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - let status = response.hasPrefix("OK") ? "ok" : "err" - dlog( - "socket.v1 cmd=\(cmd) status=\(status) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" - ) - } - #endif - - return response } // MARK: - V2 JSON Socket Protocol @@ -1850,11 +1984,7 @@ class TerminalController { v2MainSync { self.v2RefreshKnownRefs() } - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif - - let response = withSocketCommandPolicy(commandKey: method, isV2: true) { + return withSocketCommandPolicy(commandKey: method, isV2: true) { switch method { case "system.ping": return v2Ok(id: id, result: ["pong": true]) @@ -1911,6 +2041,16 @@ class TerminalController { return v2Result(id: id, self.v2WorkspacePrevious(params: params)) case "workspace.last": return v2Result(id: id, self.v2WorkspaceLast(params: params)) + case "workspace.remote.configure": + return v2Result(id: id, self.v2WorkspaceRemoteConfigure(params: params)) + case "workspace.remote.reconnect": + return v2Result(id: id, self.v2WorkspaceRemoteReconnect(params: params)) + case "workspace.remote.disconnect": + return v2Result(id: id, self.v2WorkspaceRemoteDisconnect(params: params)) + case "workspace.remote.status": + return v2Result(id: id, self.v2WorkspaceRemoteStatus(params: params)) + case "workspace.remote.terminal_session_end": + return v2Result(id: id, self.v2WorkspaceRemoteTerminalSessionEnd(params: params)) // Settings case "settings.open": @@ -1950,6 +2090,8 @@ class TerminalController { return v2Result(id: id, self.v2SurfaceRefresh(params: params)) case "surface.health": return v2Result(id: id, self.v2SurfaceHealth(params: params)) + case "debug.terminals": + return v2Result(id: id, self.v2DebugTerminals(params: params)) case "surface.send_text": return v2Result(id: id, self.v2SurfaceSendText(params: params)) case "surface.send_key": @@ -2205,6 +2347,8 @@ class TerminalController { return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelectAll(params: params)) case "debug.browser.address_bar_focused": return v2Result(id: id, self.v2DebugBrowserAddressBarFocused(params: params)) + case "debug.browser.favicon": + return v2Result(id: id, self.v2DebugBrowserFavicon(params: params)) case "debug.sidebar.visible": return v2Result(id: id, self.v2DebugSidebarVisible(params: params)) case "debug.terminal.is_focused": @@ -2239,22 +2383,10 @@ class TerminalController { return v2Result(id: id, self.v2DebugScreenshot(params: params)) #endif - default: - return v2Error(id: id, code: "method_not_found", message: "Unknown method") + default: + return v2Error(id: id, code: "method_not_found", message: "Unknown method") + } } - } - - #if DEBUG - if method == "workspace.create" || method == "surface.send_text" { - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - let status = response.contains("\"ok\":true") ? "ok" : "err" - dlog( - "socket.v2 method=\(method) status=\(status) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" - ) - } - #endif - - return response } private func v2Capabilities() -> [String: Any] { @@ -2281,6 +2413,11 @@ class TerminalController { "workspace.next", "workspace.previous", "workspace.last", + "workspace.remote.configure", + "workspace.remote.reconnect", + "workspace.remote.disconnect", + "workspace.remote.status", + "workspace.remote.terminal_session_end", "settings.open", "feedback.open", "feedback.submit", @@ -2297,6 +2434,7 @@ class TerminalController { "tab.action", "surface.refresh", "surface.health", + "debug.terminals", "surface.send_text", "surface.send_key", "surface.read_text", @@ -2420,6 +2558,7 @@ class TerminalController { "debug.command_palette.rename_input.selection", "debug.command_palette.rename_input.select_all", "debug.browser.address_bar_focused", + "debug.browser.favicon", "debug.sidebar.visible", "debug.terminal.is_focused", "debug.terminal.read_text", @@ -2864,6 +3003,42 @@ class TerminalController { return trimmed.isEmpty ? nil : trimmed } + private func v2StringArray(_ params: [String: Any], _ key: String) -> [String]? { + if let raw = params[key] as? [String] { + let normalized = raw + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return normalized + } + if let raw = params[key] as? [Any] { + let normalized = raw + .compactMap { $0 as? String } + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return normalized + } + if let single = v2String(params, key) { + return [single] + } + return nil + } + + private func v2StringMap(_ params: [String: Any], _ key: String) -> [String: String]? { + guard let raw = params[key] else { return nil } + if let dict = raw as? [String: String] { + return dict + } + if let anyDict = raw as? [String: Any] { + var out: [String: String] = [:] + for (k, value) in anyDict { + guard let stringValue = value as? String else { continue } + out[k] = stringValue + } + return out + } + return nil + } + private func v2ActionKey(_ params: [String: Any], _ key: String = "action") -> String? { guard let action = v2String(params, key) else { return nil } return action.lowercased().replacingOccurrences(of: "-", with: "_") @@ -2926,6 +3101,40 @@ class TerminalController { return nil } + private func v2HasNonNullParam(_ params: [String: Any], _ key: String) -> Bool { + guard let raw = params[key] else { return false } + return !(raw is NSNull) + } + + private func v2StrictInt(_ params: [String: Any], _ key: String) -> Int? { + v2StrictIntAny(params[key]) + } + + private func v2StrictIntAny(_ raw: Any?) -> Int? { + guard let raw else { return nil } + + if let numberValue = raw as? NSNumber { + if CFGetTypeID(numberValue) == CFBooleanGetTypeID() { + return nil + } + let doubleValue = numberValue.doubleValue + guard doubleValue.isFinite, floor(doubleValue) == doubleValue else { + return nil + } + return Int(exactly: doubleValue) + } + + if let intValue = raw as? Int { + return intValue + } + + if let stringValue = raw as? String { + return Int(stringValue.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + return nil + } + private func v2PanelType(_ params: [String: Any], _ key: String) -> PanelType? { guard let s = v2String(params, key) else { return nil } return PanelType(rawValue: s.lowercased()) @@ -3009,9 +3218,8 @@ class TerminalController { guard let windowId = v2MainSync({ AppDelegate.shared?.createMainWindow() }) else { return .err(code: "internal_error", message: "Failed to create window", data: nil) } - // Keep active routing stable unless this command is explicitly focus-intent. - if socketCommandAllowsInAppFocusMutations(), - let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { + // The new window should become key, but setActiveTabManager defensively. + if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { setActiveTabManager(tm) } return .ok([ @@ -3053,6 +3261,8 @@ class TerminalController { "title": ws.title, "selected": ws.id == tabManager.selectedTabId, "pinned": ws.isPinned, + "listening_ports": ws.listeningPorts, + "remote": ws.remoteStatusPayload(), "current_directory": v2OrNull(ws.currentDirectory), "custom_color": v2OrNull(ws.customColor) ] @@ -3071,8 +3281,22 @@ class TerminalController { return .err(code: "unavailable", message: "TabManager not available", data: nil) } + let requestedWorkingDirectory = v2RawString(params, "working_directory")?.trimmingCharacters(in: .whitespacesAndNewlines) + let workingDirectory = (requestedWorkingDirectory?.isEmpty == false) ? requestedWorkingDirectory : nil + + let requestedInitialCommand = v2RawString(params, "initial_command")?.trimmingCharacters(in: .whitespacesAndNewlines) + let initialCommand = (requestedInitialCommand?.isEmpty == false) ? requestedInitialCommand : nil + + let rawInitialEnv = v2StringMap(params, "initial_env") ?? [:] + let initialEnv = rawInitialEnv.reduce(into: [String: String]()) { result, pair in + let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return } + result[key] = pair.value + } let cwd: String? - if let raw = params["cwd"] { + if let workingDirectory { + cwd = workingDirectory + } else if let raw = params["cwd"] { guard let str = raw as? String else { return .err(code: "invalid_params", message: "cwd must be a string", data: nil) } @@ -3083,23 +3307,16 @@ class TerminalController { var newId: UUID? let shouldFocus = v2FocusAllowed() - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif v2MainSync { let ws = tabManager.addWorkspace( workingDirectory: cwd, + initialTerminalCommand: initialCommand, + initialTerminalEnvironment: initialEnv, select: shouldFocus, eagerLoadTerminal: !shouldFocus ) newId = ws.id } - #if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - dlog( - "socket.workspace.create focus=\(shouldFocus ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" - ) - #endif guard let newId else { return .err(code: "internal_error", message: "Failed to create workspace", data: nil) @@ -3123,8 +3340,12 @@ class TerminalController { var success = false v2MainSync { if let ws = tabManager.tabs.first(where: { $0.id == wsId }) { - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + // If this workspace belongs to another window, bring it forward so focus is visible. + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + tabManager.selectWorkspace(ws) success = true } } @@ -3147,8 +3368,20 @@ class TerminalController { return .err(code: "unavailable", message: "TabManager not available", data: nil) } var wsId: UUID? + var wsPayload: [String: Any]? v2MainSync { wsId = tabManager.selectedTabId + if let wsId, let workspace = tabManager.tabs.first(where: { $0.id == wsId }) { + wsPayload = [ + "id": workspace.id.uuidString, + "ref": v2Ref(kind: .workspace, uuid: workspace.id), + "title": workspace.title, + "selected": true, + "pinned": workspace.isPinned, + "listening_ports": workspace.listeningPorts, + "remote": workspace.remoteStatusPayload(), + ] + } } guard let wsId else { return .err(code: "not_found", message: "No workspace selected", data: nil) @@ -3158,7 +3391,8 @@ class TerminalController { "window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": wsId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: wsId) + "workspace_ref": v2Ref(kind: .workspace, uuid: wsId), + "workspace": wsPayload ?? NSNull() ]) } private func v2WorkspaceClose(params: [String: Any]) -> V2CallResult { @@ -3197,7 +3431,7 @@ class TerminalController { guard let windowId = v2UUID(params, "window_id") else { return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) } - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) var result: V2CallResult = .err(code: "internal_error", message: "Failed to move workspace", data: nil) v2MainSync { @@ -3317,7 +3551,10 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) v2MainSync { guard tabManager.selectedTabId != nil else { return } - v2MaybeFocusWindow(for: tabManager) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } tabManager.selectNextTab() guard let workspaceId = tabManager.selectedTabId else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -3339,7 +3576,10 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil) v2MainSync { guard tabManager.selectedTabId != nil else { return } - v2MaybeFocusWindow(for: tabManager) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } tabManager.selectPreviousTab() guard let workspaceId = tabManager.selectedTabId else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -3361,7 +3601,10 @@ class TerminalController { var result: V2CallResult = .err(code: "not_found", message: "No previous workspace in history", data: nil) v2MainSync { guard let before = tabManager.selectedTabId else { return } - v2MaybeFocusWindow(for: tabManager) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } tabManager.navigateBack() guard let after = tabManager.selectedTabId, after != before else { return } let windowId = v2ResolveWindowId(tabManager: tabManager) @@ -3375,6 +3618,277 @@ class TerminalController { return result } + private func v2WorkspaceRemoteConfigure(params: [String: Any]) -> V2CallResult { + let requestedWorkspaceId = v2UUID(params, "workspace_id") + if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let fallbackTabManager = v2ResolveTabManager(params: params) + let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId + guard let workspaceId else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + guard let destination = v2String(params, "destination") else { + return .err(code: "invalid_params", message: "Missing destination", data: nil) + } + + var sshPort: Int? + if v2HasNonNullParam(params, "port") { + guard let parsedPort = v2StrictInt(params, "port"), + parsedPort > 0, + parsedPort <= 65535 else { + return .err(code: "invalid_params", message: "port must be 1-65535", data: nil) + } + sshPort = parsedPort + } + + // Internal deterministic test hook: pin the local proxy listener port to force bind conflicts. + var localProxyPort: Int? + if v2HasNonNullParam(params, "local_proxy_port") { + guard let parsedLocalProxyPort = v2StrictInt(params, "local_proxy_port"), + parsedLocalProxyPort > 0, + parsedLocalProxyPort <= 65535 else { + return .err(code: "invalid_params", message: "local_proxy_port must be 1-65535", data: nil) + } + localProxyPort = parsedLocalProxyPort + } + + let identityFile = v2RawString(params, "identity_file")?.trimmingCharacters(in: .whitespacesAndNewlines) + let sshOptions = v2StringArray(params, "ssh_options") ?? [] + let autoConnect = v2Bool(params, "auto_connect") ?? true + var relayPort: Int? + if v2HasNonNullParam(params, "relay_port") { + guard let parsedRelayPort = v2StrictInt(params, "relay_port"), + parsedRelayPort > 0, + parsedRelayPort <= 65535 else { + return .err(code: "invalid_params", message: "relay_port must be 1-65535", data: nil) + } + relayPort = parsedRelayPort + } + let relayID = v2RawString(params, "relay_id")?.trimmingCharacters(in: .whitespacesAndNewlines) + let relayToken = v2RawString(params, "relay_token")?.trimmingCharacters(in: .whitespacesAndNewlines) + let localSocketPath = v2RawString(params, "local_socket_path") + let terminalStartupCommand = v2RawString(params, "terminal_startup_command")? + .trimmingCharacters(in: .whitespacesAndNewlines) + if relayPort != nil { + guard let relayID, !relayID.isEmpty else { + return .err(code: "invalid_params", message: "relay_id is required when relay_port is set", data: nil) + } + guard let relayToken, + relayToken.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { + return .err(code: "invalid_params", message: "relay_token must be 64 lowercase hex characters when relay_port is set", data: nil) + } + } + +#if DEBUG + dlog( + "workspace.remote.configure.request workspace=\(workspaceId.uuidString.prefix(8)) " + + "target=\(destination) port=\(sshPort.map(String.init) ?? "nil") " + + "autoConnect=\(autoConnect ? 1 : 0) relayPort=\(relayPort.map(String.init) ?? "nil") " + + "localSocket=\(localSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? localSocketPath! : "nil") " + + "sshOptions=\(sshOptions.joined(separator: "|"))" + ) +#endif + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) + + // Must run on main for v2MainSync because Workspace.configureRemoteConnection mutates TabManager/UI-owned workspace state. + v2MainSync { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return + } + + let config = WorkspaceRemoteConfiguration( + destination: destination, + port: sshPort, + identityFile: identityFile?.isEmpty == true ? nil : identityFile, + sshOptions: sshOptions, + localProxyPort: localProxyPort, + relayPort: relayPort, + relayID: relayID?.isEmpty == true ? nil : relayID, + relayToken: relayToken?.isEmpty == true ? nil : relayToken, + localSocketPath: localSocketPath, + terminalStartupCommand: terminalStartupCommand?.isEmpty == true ? nil : terminalStartupCommand + ) + workspace.configureRemoteConnection(config, autoConnect: autoConnect) + + let windowId = v2ResolveWindowId(tabManager: owner) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "remote": workspace.remoteStatusPayload(), + ]) + } + + return result + } + + private func v2WorkspaceRemoteDisconnect(params: [String: Any]) -> V2CallResult { + let requestedWorkspaceId = v2UUID(params, "workspace_id") + if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let fallbackTabManager = v2ResolveTabManager(params: params) + let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId + guard let workspaceId else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + + let clearConfiguration = v2Bool(params, "clear") ?? false + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) + + // Must run on main for v2MainSync because disconnect mutates TabManager/UI-owned workspace state. + v2MainSync { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return + } + + workspace.disconnectRemoteConnection(clearConfiguration: clearConfiguration) + let windowId = v2ResolveWindowId(tabManager: owner) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "remote": workspace.remoteStatusPayload(), + ]) + } + + return result + } + + private func v2WorkspaceRemoteReconnect(params: [String: Any]) -> V2CallResult { + let requestedWorkspaceId = v2UUID(params, "workspace_id") + if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let fallbackTabManager = v2ResolveTabManager(params: params) + let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId + guard let workspaceId else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) + + // Must run on main for v2MainSync because reconnect mutates TabManager/UI-owned workspace state. + v2MainSync { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return + } + + guard workspace.remoteConfiguration != nil else { + result = .err(code: "invalid_state", message: "Remote workspace is not configured", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) + return + } + + workspace.reconnectRemoteConnection() + let windowId = v2ResolveWindowId(tabManager: owner) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "remote": workspace.remoteStatusPayload(), + ]) + } + + return result + } + + private func v2WorkspaceRemoteStatus(params: [String: Any]) -> V2CallResult { + let requestedWorkspaceId = v2UUID(params, "workspace_id") + if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let fallbackTabManager = v2ResolveTabManager(params: params) + let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId + guard let workspaceId else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + ]) + + // Must run on main for v2MainSync because Workspace.remoteStatusPayload reads TabManager/UI-owned state. + v2MainSync { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return + } + let windowId = v2ResolveWindowId(tabManager: owner) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "remote": workspace.remoteStatusPayload(), + ]) + } + + return result + } + + private func v2WorkspaceRemoteTerminalSessionEnd(params: [String: Any]) -> V2CallResult { + guard let workspaceId = v2UUID(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + guard let surfaceId = v2UUID(params, "surface_id") else { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + guard let relayPort = v2StrictInt(params, "relay_port"), + relayPort > 0, + relayPort <= 65535 else { + return .err(code: "invalid_params", message: "Missing or invalid relay_port", data: nil) + } + + var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "relay_port": relayPort, + ]) + + v2MainSync { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return + } + workspace.markRemoteTerminalSessionEnded(surfaceId: surfaceId, relayPort: relayPort) + let windowId = v2ResolveWindowId(tabManager: owner) + result = .ok([ + "window_id": v2OrNull(windowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: windowId), + "workspace_id": workspace.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "relay_port": relayPort, + "remote": workspace.remoteStatusPayload(), + ]) + } + + return result + } + private func v2WorkspaceAction(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -3550,7 +4064,6 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - let allowFocusMutation = v2FocusAllowed() let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") ?? workspace.focusedPanelId guard let surfaceId else { @@ -3652,7 +4165,7 @@ class TerminalController { workspace.setPanelPinned(panelId: surfaceId, pinned: false) finish(["pinned": false]) - case "mark_read", "mark_as_read": + case "mark_read": workspace.markPanelRead(surfaceId) finish() @@ -3680,7 +4193,7 @@ class TerminalController { guard let newPanel = workspace.newBrowserSurface( inPane: paneId, url: browserPanel.currentURL, - focus: allowFocusMutation + focus: true ) else { result = .err(code: "internal_error", message: "Failed to duplicate tab", data: nil) return @@ -3701,7 +4214,7 @@ class TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: allowFocusMutation) else { + guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: true) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } @@ -3728,7 +4241,7 @@ class TerminalController { } let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: allowFocusMutation) else { + guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: true) else { result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } @@ -3918,8 +4431,15 @@ class TerminalController { return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + + // Make sure the workspace is selected so focus effects apply to the visible UI. + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } guard ws.panels[surfaceId] != nil else { result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) @@ -3947,9 +4467,6 @@ class TerminalController { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) - let targetSurfaceId: UUID? = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let targetSurfaceId else { result = .err(code: "not_found", message: "No focused surface", data: nil) @@ -3960,12 +4477,10 @@ class TerminalController { return } - if let newId = tabManager.newSplit( - tabId: ws.id, - surfaceId: targetSurfaceId, - direction: direction, - focus: v2FocusAllowed() - ) { + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) + + if let newId = tabManager.newSplit(tabId: ws.id, surfaceId: targetSurfaceId, direction: direction) { let paneUUID = ws.paneId(forPanelId: newId)?.id let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok([ @@ -4138,7 +4653,7 @@ class TerminalController { let beforeSurfaceId = v2UUID(params, "before_surface_id") let afterSurfaceId = v2UUID(params, "after_surface_id") let explicitIndex = v2Int(params, "index") - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true) + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) let anchorCount = (beforeSurfaceId != nil ? 1 : 0) + (afterSurfaceId != nil ? 1 : 0) if anchorCount > 1 { @@ -4256,8 +4771,9 @@ class TerminalController { } if focus { - v2MaybeFocusWindow(for: targetTabManager) - v2MaybeSelectWorkspace(targetTabManager, workspace: targetWorkspace) + _ = app.focusMainWindow(windowId: targetWindowId) + setActiveTabManager(targetTabManager) + targetTabManager.selectWorkspace(targetWorkspace) } result = .ok([ @@ -4404,6 +4920,265 @@ class TerminalController { return .ok(payload) } + private func v2DebugTerminals(params _: [String: Any]) -> V2CallResult { + var payload: [String: Any]? + + v2MainSync { + guard let app = AppDelegate.shared else { return } + + struct MappedTerminalLocation { + let windowIndex: Int + let windowId: UUID + let window: NSWindow? + let workspaceIndex: Int + let workspaceSelected: Bool + let workspace: Workspace + let terminalPanel: TerminalPanel + let paneId: PaneID? + let paneIndex: Int? + let surfaceIndex: Int + let selectedInPane: Bool? + let bonsplitTabId: TabID? + } + + func nonEmpty(_ raw: String?) -> String? { + guard let raw else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + func rectPayload(_ rect: CGRect) -> [String: Double] { + [ + "x": Double(rect.origin.x), + "y": Double(rect.origin.y), + "width": Double(rect.size.width), + "height": Double(rect.size.height) + ] + } + + func objectPointerString(_ object: AnyObject?) -> String { + guard let object else { return "nil" } + return String(describing: Unmanaged.passUnretained(object).toOpaque()) + } + + func ghosttyPointerString(_ surface: ghostty_surface_t?) -> String { + guard let surface else { return "nil" } + return String(describing: surface) + } + + func className(_ object: AnyObject?) -> String? { + guard let object else { return nil } + return String(describing: type(of: object)) + } + + let iso8601Formatter = ISO8601DateFormatter() + let now = Date() + + func iso8601String(_ date: Date?) -> String? { + guard let date else { return nil } + return iso8601Formatter.string(from: date) + } + + func ageSeconds(since date: Date?) -> Double? { + guard let date else { return nil } + return (now.timeIntervalSince(date) * 1000).rounded() / 1000 + } + + @MainActor + func superviewClassChain(for view: NSView, limit: Int = 8) -> [String] { + var chain: [String] = [String(describing: type(of: view))] + var currentSuperview = view.superview + while chain.count < limit, let nextSuperview = currentSuperview { + chain.append(String(describing: type(of: nextSuperview))) + currentSuperview = nextSuperview.superview + } + if currentSuperview != nil { + chain.append("...") + } + return chain + } + + let windows = app.scriptableMainWindows() + let windowIndexById = Dictionary( + uniqueKeysWithValues: windows.enumerated().map { ($0.element.windowId, $0.offset) } + ) + + @MainActor + func resolvedWindowMetadata(for window: NSWindow?) -> (windowId: UUID?, windowIndex: Int?) { + guard let window else { return (nil, nil) } + + if let match = windows.enumerated().first(where: { _, state in + guard let stateWindow = state.window else { return false } + return stateWindow === window || stateWindow.windowNumber == window.windowNumber + }) { + return (match.element.windowId, match.offset) + } + + guard let raw = window.identifier?.rawValue else { return (nil, nil) } + let prefix = "cmux.main." + guard raw.hasPrefix(prefix), + let parsedWindowId = UUID(uuidString: String(raw.dropFirst(prefix.count))) else { + return (nil, nil) + } + return (parsedWindowId, windowIndexById[parsedWindowId]) + } + + var mappedLocations: [ObjectIdentifier: MappedTerminalLocation] = [:] + for (windowIndex, state) in windows.enumerated() { + let tabManager = state.tabManager + for (workspaceIndex, workspace) in tabManager.tabs.enumerated() { + let paneIndexById = Dictionary( + uniqueKeysWithValues: workspace.bonsplitController.allPaneIds.enumerated().map { + ($0.element.id, $0.offset) + } + ) + var selectedInPaneByPanelId: [UUID: Bool] = [:] + for paneId in workspace.bonsplitController.allPaneIds { + let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId) + for tab in workspace.bonsplitController.tabs(inPane: paneId) { + guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { continue } + selectedInPaneByPanelId[panelId] = (tab.id == selectedTab?.id) + } + } + + for (surfaceIndex, panel) in orderedPanels(in: workspace).enumerated() { + guard let terminalPanel = panel as? TerminalPanel else { continue } + mappedLocations[ObjectIdentifier(terminalPanel.surface)] = MappedTerminalLocation( + windowIndex: windowIndex, + windowId: state.windowId, + window: state.window, + workspaceIndex: workspaceIndex, + workspaceSelected: workspace.id == tabManager.selectedTabId, + workspace: workspace, + terminalPanel: terminalPanel, + paneId: workspace.paneId(forPanelId: terminalPanel.id), + paneIndex: workspace.paneId(forPanelId: terminalPanel.id).flatMap { paneIndexById[$0.id] }, + surfaceIndex: surfaceIndex, + selectedInPane: selectedInPaneByPanelId[terminalPanel.id], + bonsplitTabId: workspace.surfaceIdFromPanelId(terminalPanel.id) + ) + } + } + } + + let surfaces = TerminalSurfaceRegistry.shared.allSurfaces() + let terminals: [[String: Any]] = surfaces.enumerated().map { index, terminalSurface in + let mapped = mappedLocations[ObjectIdentifier(terminalSurface)] + let hostedView = terminalSurface.hostedView + let hostedWindow = mapped?.window ?? hostedView.window + let fallbackWindowMetadata = resolvedWindowMetadata(for: hostedWindow) + let resolvedWindowId = mapped?.windowId ?? fallbackWindowMetadata.windowId + let resolvedWindowIndex = mapped?.windowIndex ?? fallbackWindowMetadata.windowIndex + let workspace = mapped?.workspace + let panelId = mapped?.terminalPanel.id ?? terminalSurface.id + let portalState = hostedView.portalBindingGuardState() + let portalHostLease = terminalSurface.debugPortalHostLease() + let gitBranchState = workspace?.panelGitBranches[panelId] + let listeningPorts = (workspace?.surfaceListeningPorts[panelId] ?? []).sorted() + let title = workspace?.panelTitle(panelId: panelId) + let paneId = mapped?.paneId + let treeVisible = mapped?.bonsplitTabId != nil && paneId != nil + let ttyName = workspace?.surfaceTTYNames[panelId] + let currentDirectory = nonEmpty(workspace?.panelDirectories[panelId] ?? mapped?.terminalPanel.directory) + let teardownRequest = terminalSurface.debugTeardownRequest() + let lastKnownWorkspaceId = terminalSurface.debugLastKnownWorkspaceId() + + var item: [String: Any] = [ + "index": index, + "mapped": mapped != nil, + "tree_visible": treeVisible, + "window_index": v2OrNull(resolvedWindowIndex), + "window_id": v2OrNull(resolvedWindowId?.uuidString), + "window_ref": v2Ref(kind: .window, uuid: resolvedWindowId), + "window_number": v2OrNull(hostedWindow?.windowNumber), + "window_key": hostedWindow?.isKeyWindow ?? false, + "window_main": hostedWindow?.isMainWindow ?? false, + "window_visible": hostedWindow?.isVisible ?? false, + "window_occluded": hostedWindow.map { !$0.occlusionState.contains(.visible) } ?? false, + "window_identifier": v2OrNull(hostedWindow?.identifier?.rawValue), + "window_title": v2OrNull(nonEmpty(hostedWindow?.title)), + "window_class": v2OrNull(className(hostedWindow)), + "window_delegate_class": v2OrNull(className(hostedWindow?.delegate as AnyObject?)), + "window_controller_class": v2OrNull(className(hostedWindow?.windowController)), + "window_level": v2OrNull(hostedWindow?.level.rawValue), + "window_frame": hostedWindow.map { rectPayload($0.frame) } ?? NSNull(), + "workspace_index": v2OrNull(mapped?.workspaceIndex), + "workspace_id": v2OrNull(workspace?.id.uuidString), + "workspace_ref": v2Ref(kind: .workspace, uuid: workspace?.id), + "workspace_title": v2OrNull(workspace?.title), + "workspace_selected": v2OrNull(mapped?.workspaceSelected), + "pane_index": v2OrNull(mapped?.paneIndex), + "pane_id": v2OrNull(paneId?.id.uuidString), + "pane_ref": v2Ref(kind: .pane, uuid: paneId?.id), + "surface_index": v2OrNull(mapped?.surfaceIndex), + "surface_index_in_pane": v2OrNull(workspace?.indexInPane(forPanelId: panelId)), + "surface_id": panelId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: panelId), + "surface_title": v2OrNull(title), + "surface_focused": v2OrNull(workspace.map { panelId == $0.focusedPanelId }), + "surface_selected_in_pane": v2OrNull(mapped?.selectedInPane), + "surface_pinned": v2OrNull(workspace.map { $0.isPanelPinned(panelId) }), + "surface_context": terminalSurface.debugSurfaceContextLabel(), + "surface_created_at": v2OrNull(iso8601String(terminalSurface.debugCreatedAt())), + "surface_age_seconds": v2OrNull(ageSeconds(since: terminalSurface.debugCreatedAt())), + "runtime_surface_created_at": v2OrNull(iso8601String(terminalSurface.debugRuntimeSurfaceCreatedAt())), + "runtime_surface_age_seconds": v2OrNull(ageSeconds(since: terminalSurface.debugRuntimeSurfaceCreatedAt())), + "bonsplit_tab_id": v2OrNull(mapped?.bonsplitTabId?.uuid.uuidString), + "terminal_object_ptr": objectPointerString(terminalSurface), + "ghostty_surface_ptr": ghosttyPointerString(terminalSurface.surface), + "runtime_surface_ready": terminalSurface.surface != nil, + "hosted_view_ptr": objectPointerString(hostedView), + "hosted_view_class": className(hostedView) ?? "nil", + "hosted_view_in_window": hostedView.window != nil, + "hosted_view_has_superview": hostedView.superview != nil, + "hosted_view_hidden": hostedView.isHidden, + "hosted_view_hidden_or_ancestor_hidden": hostedView.isHiddenOrHasHiddenAncestor, + "hosted_view_alpha": hostedView.alphaValue, + "hosted_view_visible_in_ui": hostedView.debugPortalVisibleInUI, + "hosted_view_superview_chain": superviewClassChain(for: hostedView), + "surface_view_first_responder": hostedView.isSurfaceViewFirstResponder(), + "hosted_view_frame": rectPayload(hostedView.frame), + "hosted_view_bounds": rectPayload(hostedView.bounds), + "hosted_view_frame_in_window": rectPayload(hostedView.debugPortalFrameInWindow), + "portal_binding_state": portalState.state, + "portal_binding_generation": v2OrNull(portalState.generation), + "portal_host_id": v2OrNull(portalHostLease.hostId), + "portal_host_in_window": v2OrNull(portalHostLease.inWindow), + "portal_host_area": v2OrNull(portalHostLease.area.map(Double.init)), + "tty": v2OrNull(ttyName), + "current_directory": v2OrNull(currentDirectory), + "requested_working_directory": v2OrNull(nonEmpty(terminalSurface.requestedWorkingDirectory)), + "initial_command": v2OrNull(nonEmpty(terminalSurface.debugInitialCommand())), + "git_branch": v2OrNull(nonEmpty(gitBranchState?.branch)), + "git_dirty": v2OrNull(gitBranchState?.isDirty), + "listening_ports": listeningPorts, + "key_state_indicator": v2OrNull(nonEmpty(terminalSurface.currentKeyStateIndicatorText)), + "last_known_workspace_id": lastKnownWorkspaceId.uuidString, + "last_known_workspace_ref": v2Ref(kind: .workspace, uuid: lastKnownWorkspaceId), + "teardown_requested": teardownRequest.requestedAt != nil, + "teardown_requested_at": v2OrNull(iso8601String(teardownRequest.requestedAt)), + "teardown_requested_age_seconds": v2OrNull(ageSeconds(since: teardownRequest.requestedAt)), + "teardown_requested_reason": v2OrNull(nonEmpty(teardownRequest.reason)) + ] + + if title == nil, let fallbackTitle = mapped?.terminalPanel.displayTitle, !fallbackTitle.isEmpty { + item["surface_title"] = fallbackTitle + } + return item + } + + payload = [ + "count": terminals.count, + "terminals": terminals + ] + } + + guard let payload else { + return .err(code: "unavailable", message: "AppDelegate not available", data: nil) + } + return .ok(payload) + } + private func v2SurfaceSendText(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -4444,21 +5219,13 @@ class TerminalController { terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() queued = true } - #if DEBUG +#if DEBUG let sendMs = (ProcessInfo.processInfo.systemUptime - sendStart) * 1000.0 dlog( "socket.surface.send_text workspace=\(ws.id.uuidString.prefix(8)) surface=\(surfaceId.uuidString.prefix(8)) queued=\(queued ? 1 : 0) chars=\(text.count) ms=\(String(format: "%.2f", sendMs))" ) - #endif - result = .ok([ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "queued": queued, - "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager)) - ]) +#endif + result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } return result } @@ -4486,7 +5253,7 @@ class TerminalController { result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString]) return } - guard let surface = terminalPanel.surface.surface else { + guard let surface = waitForTerminalSurface(terminalPanel, waitUpTo: 2.0) else { result = .err(code: "internal_error", message: "Surface not ready", data: ["surface_id": surfaceId.uuidString]) return } @@ -4606,41 +5373,87 @@ class TerminalController { private func readTerminalTextBase64(terminalPanel: TerminalPanel, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String { guard let surface = terminalPanel.surface.surface else { return "ERROR: Terminal surface not found" } - let pointTag: ghostty_point_tag_e = includeScrollback ? GHOSTTY_POINT_SCREEN : GHOSTTY_POINT_VIEWPORT - let topLeft = ghostty_point_s( - tag: pointTag, - coord: GHOSTTY_POINT_COORD_TOP_LEFT, - x: 0, - y: 0 - ) - let bottomRight = ghostty_point_s( - tag: pointTag, - coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, - x: 0, - y: 0 - ) - let selection = ghostty_selection_s( - top_left: topLeft, - bottom_right: bottomRight, - rectangle: true - ) - var text = ghostty_text_s() + func readSelectionText(pointTag: ghostty_point_tag_e) -> String? { + let topLeft = ghostty_point_s( + tag: pointTag, + coord: GHOSTTY_POINT_COORD_TOP_LEFT, + x: 0, + y: 0 + ) + let bottomRight = ghostty_point_s( + tag: pointTag, + coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, + x: 0, + y: 0 + ) + let selection = ghostty_selection_s( + top_left: topLeft, + bottom_right: bottomRight, + rectangle: false + ) - guard ghostty_surface_read_text(surface, selection, &text) else { - return "ERROR: Failed to read terminal text" - } - defer { - ghostty_surface_free_text(surface, &text) + var text = ghostty_text_s() + guard ghostty_surface_read_text(surface, selection, &text) else { + return nil + } + defer { + ghostty_surface_free_text(surface, &text) + } + + guard let ptr = text.text, text.text_len > 0 else { + return "" + } + let rawData = Data(bytes: ptr, count: Int(text.text_len)) + return String(decoding: rawData, as: UTF8.self) } - let rawData: Data - if let ptr = text.text, text.text_len > 0 { - rawData = Data(bytes: ptr, count: Int(text.text_len)) + var output: String + if includeScrollback { + func candidateScore(_ text: String) -> (lines: Int, bytes: Int) { + let lines = text.isEmpty ? 0 : text.split(separator: "\n", omittingEmptySubsequences: false).count + return (lines, text.utf8.count) + } + + // Read all available regions and pick the most complete candidate. + // Different point tags can lose different rows around resize/reflow boundaries. + let screen = readSelectionText(pointTag: GHOSTTY_POINT_SCREEN) + let history = readSelectionText(pointTag: GHOSTTY_POINT_SURFACE) + let active = readSelectionText(pointTag: GHOSTTY_POINT_ACTIVE) + + var candidates: [String] = [] + if let screen { + candidates.append(screen) + } + if history != nil || active != nil { + var merged = history ?? "" + if let active { + if !merged.isEmpty, !merged.hasSuffix("\n"), !active.isEmpty { + merged.append("\n") + } + merged.append(active) + } + candidates.append(merged) + } + + if let best = candidates.max(by: { lhs, rhs in + let left = candidateScore(lhs) + let right = candidateScore(rhs) + if left.lines != right.lines { + return left.lines < right.lines + } + return left.bytes < right.bytes + }) { + output = best + } else { + return "ERROR: Failed to read terminal text" + } } else { - rawData = Data() + guard let viewport = readSelectionText(pointTag: GHOSTTY_POINT_VIEWPORT) else { + return "ERROR: Failed to read terminal text" + } + output = viewport } - var output = String(decoding: rawData, as: UTF8.self) if let lineLimit { output = tailTerminalLines(output, maxLines: lineLimit) } @@ -4653,36 +5466,6 @@ class TerminalController { let representations: [(type: NSPasteboard.PasteboardType, data: Data)] } - nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? { - guard let raw else { return nil } - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if let url = URL(string: trimmed), - url.isFileURL, - !url.path.isEmpty { - return url.path - } - return trimmed.hasPrefix("/") ? trimmed : nil - } - - nonisolated static func shouldRemoveExportedScreenFile( - fileURL: URL, - temporaryDirectory: URL = FileManager.default.temporaryDirectory - ) -> Bool { - let standardizedFile = fileURL.standardizedFileURL - let temporary = temporaryDirectory.standardizedFileURL - return standardizedFile.path.hasPrefix(temporary.path + "/") - } - - nonisolated static func shouldRemoveExportedScreenDirectory( - fileURL: URL, - temporaryDirectory: URL = FileManager.default.temporaryDirectory - ) -> Bool { - let directory = fileURL.deletingLastPathComponent().standardizedFileURL - let temporary = temporaryDirectory.standardizedFileURL - return directory.path.hasPrefix(temporary.path + "/") - } - private func snapshotPasteboardItems(_ pasteboard: NSPasteboard) -> [PasteboardItemSnapshot] { guard let items = pasteboard.pasteboardItems else { return [] } return items.map { item in @@ -4729,7 +5512,6 @@ class TerminalController { terminalPanel: TerminalPanel, lineLimit: Int? ) -> String? { - // read_text strips style state; VT export keeps ANSI escape sequences. let pasteboard = NSPasteboard.general let snapshot = snapshotPasteboardItems(pasteboard) defer { @@ -4797,6 +5579,18 @@ class TerminalController { return decoded } + func readTerminalTextForSessionSnapshot( + terminalPanel: TerminalPanel, + includeScrollback: Bool = false, + lineLimit: Int? = nil + ) -> String? { + readTerminalTextForSnapshot( + terminalPanel: terminalPanel, + includeScrollback: includeScrollback, + lineLimit: lineLimit + ) + } + private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -4809,10 +5603,6 @@ class TerminalController { return } - // Only explicit focus-intent commands may mutate selection state. - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) - let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId guard let surfaceId else { result = .err(code: "not_found", message: "No focused surface", data: nil) @@ -4823,6 +5613,9 @@ class TerminalController { return } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) + ws.triggerFocusFlash(panelId: surfaceId) result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))]) } @@ -4892,8 +5685,13 @@ class TerminalController { result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString]) return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } ws.bonsplitController.focusPane(paneId) let windowId = v2ResolveWindowId(tabManager: tabManager) result = .ok(["window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "pane_id": paneId.id.uuidString, "pane_ref": v2Ref(kind: .pane, uuid: paneId.id)]) @@ -5345,7 +6143,7 @@ class TerminalController { detached, inPane: sourcePaneForRollback, atIndex: sourceIndex, - focus: focus + focus: true ) } result = .err(code: "internal_error", message: "Destination workspace has no pane", data: nil) @@ -5358,7 +6156,7 @@ class TerminalController { detached, inPane: sourcePaneForRollback, atIndex: sourceIndex, - focus: focus + focus: true ) } result = .err(code: "internal_error", message: "Failed to attach surface to new workspace", data: nil) @@ -5795,24 +6593,7 @@ class TerminalController { contentWorld: WKContentWorld ) -> V2JavaScriptResult { let timeoutSeconds = max(0.01, timeout) - let resultLock = NSLock() - let completionSignal = DispatchSemaphore(value: 0) - var done = false - var resultValue: Any? - var resultError: String? - - let finish: (_ value: Any?, _ error: String?) -> Void = { value, error in - resultLock.lock() - if !done { - done = true - resultValue = value - resultError = error - completionSignal.signal() - } - resultLock.unlock() - } - - let evaluator = { + let evaluator: (@escaping (Any?, String?) -> Void) -> Void = { finish in if preferAsync, #available(macOS 11.0, *) { webView.callAsyncJavaScript(script, arguments: [:], in: nil, in: contentWorld) { result in switch result { @@ -5833,32 +6614,163 @@ class TerminalController { } } + let outcome: (Any?, String?)? if Thread.isMainThread { - evaluator() - let deadline = Date().addingTimeInterval(timeoutSeconds) - while true { - resultLock.lock() - let isDone = done - resultLock.unlock() - if isDone { - break + outcome = v2AwaitCallback(timeout: timeoutSeconds) { finish in + evaluator { value, error in + finish((value, error)) } - if Date() >= deadline { - return .failure("Timed out waiting for JavaScript result") - } - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) } } else { - DispatchQueue.main.async(execute: evaluator) - if completionSignal.wait(timeout: .now() + timeoutSeconds) == .timedOut { - return .failure("Timed out waiting for JavaScript result") + outcome = v2AwaitCallback(timeout: timeoutSeconds) { finish in + DispatchQueue.main.async { + evaluator { value, error in + finish((value, error)) + } + } } } - if let resultError { + guard let outcome else { + return .failure("Timed out waiting for JavaScript result") + } + if let resultError = outcome.1 { return .failure(resultError) } - return .success(resultValue) + return .success(outcome.0) + } + + private func v2AwaitCallback<T>( + timeout: TimeInterval, + start: (@escaping (T) -> Void) -> Void + ) -> T? { + if Thread.isMainThread { + let runLoop = CFRunLoopGetCurrent() + var resolved = false + var timedOut = false + var result: T? + + let finish: (T) -> Void = { value in + guard !resolved else { return } + resolved = true + result = value + CFRunLoopStop(runLoop) + } + + start(finish) + guard !resolved else { return result } + + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { + guard !resolved else { return } + resolved = true + timedOut = true + CFRunLoopStop(runLoop) + } + + CFRunLoopRun() + return timedOut ? nil : result + } + + let semaphore = DispatchSemaphore(value: 0) + let lock = NSLock() + var result: T? + start { value in + lock.lock() + result = value + lock.unlock() + semaphore.signal() + } + guard semaphore.wait(timeout: .now() + timeout) == .success else { + return nil + } + lock.lock() + defer { lock.unlock() } + return result + } + + private func v2WaitForBrowserCondition( + _ webView: WKWebView, + surfaceId: UUID, + conditionScript: String, + timeoutMs: Int + ) -> Bool { + let timeout = Double(timeoutMs) / 1000.0 + let waitScript = """ + (() => { + const __cmuxEvaluate = () => { + try { + return !!(\(conditionScript)); + } catch (_) { + return false; + } + }; + + if (__cmuxEvaluate()) { + return true; + } + + return new Promise((resolve) => { + let finished = false; + let observer = null; + const cleanups = []; + const finish = (value) => { + if (finished) return; + finished = true; + if (observer) observer.disconnect(); + for (const cleanup of cleanups) { + try { cleanup(); } catch (_) {} + } + resolve(value); + }; + const recheck = () => { + if (__cmuxEvaluate()) { + finish(true); + } + }; + const addListener = (target, eventName, options) => { + if (!target || typeof target.addEventListener !== 'function') return; + const handler = () => recheck(); + target.addEventListener(eventName, handler, options); + cleanups.push(() => target.removeEventListener(eventName, handler, options)); + }; + + try { + observer = new MutationObserver(() => recheck()); + observer.observe(document.documentElement || document, { + childList: true, + subtree: true, + attributes: true, + characterData: true + }); + } catch (_) {} + + addListener(document, 'readystatechange', true); + addListener(window, 'load', true); + addListener(window, 'pageshow', true); + addListener(window, 'hashchange', true); + addListener(window, 'popstate', true); + + const timeoutId = window.setTimeout(() => { + finish(false); + }, \(timeoutMs)); + cleanups.push(() => window.clearTimeout(timeoutId)); + recheck(); + }); + })() + """ + + switch v2RunBrowserJavaScript( + webView, + surfaceId: surfaceId, + script: waitScript, + timeout: timeout + 1.0, + useEval: false + ) { + case .success(let value): + return (value as? Bool) == true + case .failure: + return false + } } private func v2BrowserSelector(_ params: [String: Any]) -> String? { @@ -6273,16 +7185,11 @@ class TerminalController { var placementStrategy = "split_right" let createdPanel: BrowserPanel? if let targetPane = ws.preferredBrowserTargetPane(fromPanelId: sourceSurfaceId) { - createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: v2FocusAllowed()) + createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: true) createdSplit = false placementStrategy = "reuse_right_sibling" } else { - createdPanel = ws.newBrowserSplit( - from: sourceSurfaceId, - orientation: .horizontal, - url: url, - focus: v2FocusAllowed() - ) + createdPanel = ws.newBrowserSplit(from: sourceSurfaceId, orientation: .horizontal, url: url) } guard let browserPanelId = createdPanel?.id else { @@ -6541,6 +7448,7 @@ class TerminalController { } let script = scriptBuilder(v2JSONLiteral(selector)) let retryAttempts = max(1, v2Int(params, "retry_attempts") ?? 3) + let selectorCondition = "document.querySelector(\(v2JSONLiteral(selector))) !== null" for attempt in 1...retryAttempts { switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, useEval: false) { @@ -6567,7 +7475,21 @@ class TerminalController { let errorText = (value as? [String: Any])?["error"] as? String if errorText == "not_found", attempt < retryAttempts { - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.08)) + let waitTimeoutMs = max(80, (retryAttempts - attempt) * 80) + guard v2WaitForBrowserCondition( + browserPanel.webView, + surfaceId: surfaceId, + conditionScript: selectorCondition, + timeoutMs: waitTimeoutMs + ) else { + return v2BrowserElementNotFoundResult( + actionName: actionName, + selector: selector, + attempts: attempt, + surfaceId: surfaceId, + browserPanel: browserPanel + ) + } continue } if errorText == "not_found" { @@ -6897,7 +7819,6 @@ class TerminalController { private func v2BrowserWait(params: [String: Any]) -> V2CallResult { let timeoutMs = max(1, v2Int(params, "timeout_ms") ?? 5_000) - let timeout = Double(timeoutMs) / 1000.0 let selectorRaw = v2BrowserSelector(params) let conditionScriptBase: String = { @@ -6974,45 +7895,21 @@ class TerminalController { conditionScript = conditionScriptBase } - let deadline = Date().addingTimeInterval(timeout) - let pollInterval = 0.05 - let wrappedScript = "(() => { try { return !!(\(conditionScript)); } catch (_) { return false; } })()" - - while true { - switch v2RunBrowserJavaScript( - webView, - surfaceId: surfaceIdOut, - script: wrappedScript, - timeout: max(0.5, pollInterval + 0.25), - useEval: false - ) { - case .success(let value): - if let b = value as? Bool, b { - return .ok([ - "workspace_id": workspaceId.uuidString, - "workspace_ref": self.v2Ref(kind: .workspace, uuid: workspaceId), - "surface_id": surfaceIdOut.uuidString, - "surface_ref": self.v2Ref(kind: .surface, uuid: surfaceIdOut), - "waited": true - ]) - } - case .failure(let message): - return .err( - code: "js_error", - message: message, - data: [ - "condition": conditionScript, - "timeout_ms": timeoutMs - ] - ) - } - - if Date() >= deadline { - return .err(code: "timeout", message: "Condition not met before timeout", data: ["timeout_ms": timeoutMs]) - } - - Thread.sleep(forTimeInterval: pollInterval) + if v2WaitForBrowserCondition( + webView, + surfaceId: surfaceIdOut, + conditionScript: conditionScript, + timeoutMs: timeoutMs + ) { + return .ok([ + "workspace_id": workspaceId.uuidString, + "workspace_ref": self.v2Ref(kind: .workspace, uuid: workspaceId), + "surface_id": surfaceIdOut.uuidString, + "surface_ref": self.v2Ref(kind: .surface, uuid: surfaceIdOut), + "waited": true + ]) } + return .err(code: "timeout", message: "Condition not met before timeout", data: ["timeout_ms": timeoutMs]) } private func v2BrowserClick(params: [String: Any]) -> V2CallResult { @@ -7337,22 +8234,16 @@ class TerminalController { private func v2BrowserScreenshot(params: [String: Any]) -> V2CallResult { return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in - var done = false - var imageData: Data? - browserPanel.takeSnapshot { image in - imageData = image.flatMap { self.v2PNGData(from: $0) } - done = true + let snapshotResult: Data?? = v2AwaitCallback(timeout: 5.0) { finish in + browserPanel.takeSnapshot { image in + finish(image.flatMap { self.v2PNGData(from: $0) }) + } } - let deadline = Date().addingTimeInterval(5.0) - while !done && Date() < deadline { - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) - } - - guard done else { + guard let snapshotResult else { return .err(code: "timeout", message: "Timed out waiting for snapshot", data: nil) } - guard let imageData else { + guard let imageData = snapshotResult else { return .err(code: "internal_error", message: "Failed to capture snapshot", data: nil) } @@ -7633,8 +8524,13 @@ class TerminalController { guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager), let browserPanel = ws.browserPanel(for: surfaceId) else { return } - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } // Prevent omnibar auto-focus from immediately stealing first responder back. browserPanel.suppressOmnibarAutofocus(for: 1.0) @@ -8283,45 +9179,122 @@ class TerminalController { let path = v2String(params, "path") if let path { - let deadline = Date().addingTimeInterval(timeout) let fm = FileManager.default - while Date() < deadline { - if fm.fileExists(atPath: path), - let attrs = try? fm.attributesOfItem(atPath: path), - let size = attrs[.size] as? NSNumber, - size.intValue > 0 { - return .ok([ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "path": path, - "downloaded": true - ]) + let pathIsReady = { + guard fm.fileExists(atPath: path), + let attrs = try? fm.attributesOfItem(atPath: path), + let size = attrs[.size] as? NSNumber else { + return false } - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05)) + return size.intValue > 0 } - return .err(code: "timeout", message: "Timed out waiting for download file", data: ["path": path, "timeout_ms": timeoutMs]) - } - - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - let entries = v2BrowserDownloadEventsBySurface[surfaceId] ?? [] - if let first = entries.first { - var remaining = entries - remaining.removeFirst() - v2BrowserDownloadEventsBySurface[surfaceId] = remaining + if pathIsReady() { return .ok([ "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "download": first + "path": path, + "downloaded": true ]) } - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05)) + + let watchedPath = URL(fileURLWithPath: path).deletingLastPathComponent().path + let fd = open(watchedPath, O_EVTONLY) + guard fd >= 0 else { + return .err(code: "internal_error", message: "Failed to watch download path", data: ["path": path]) + } + defer { close(fd) } + + let ready = v2AwaitCallback(timeout: timeout) { finish in + var source: DispatchSourceFileSystemObject? + var timeoutWorkItem: DispatchWorkItem? + var finished = false + let finishOnce: (Bool) -> Void = { value in + guard !finished else { return } + finished = true + timeoutWorkItem?.cancel() + source?.cancel() + finish(value) + } + source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .extend, .attrib, .link, .rename], + queue: .main + ) + source?.setEventHandler { + if pathIsReady() { + finishOnce(true) + } + } + source?.setCancelHandler { + source = nil + } + source?.resume() + timeoutWorkItem = DispatchWorkItem { + finishOnce(pathIsReady()) + } + if let timeoutWorkItem { + DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: timeoutWorkItem) + } + if pathIsReady() { + finishOnce(true) + } + } ?? false + guard ready else { + return .err(code: "timeout", message: "Timed out waiting for download file", data: ["path": path, "timeout_ms": timeoutMs]) + } + return .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "path": path, + "downloaded": true + ]) } - return .err(code: "timeout", message: "No download event observed", data: ["timeout_ms": timeoutMs]) + + if let first = v2BrowserDownloadEventsBySurface[surfaceId]?.first { + var remaining = v2BrowserDownloadEventsBySurface[surfaceId] ?? [] + remaining.removeFirst() + v2BrowserDownloadEventsBySurface[surfaceId] = remaining + return .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "download": first + ]) + } + + let downloadEvent = v2AwaitCallback(timeout: timeout) { finish in + var observer: NSObjectProtocol? + observer = NotificationCenter.default.addObserver( + forName: .browserDownloadEventDidArrive, + object: nil, + queue: .main + ) { note in + guard let candidateSurfaceId = note.userInfo?["surfaceId"] as? UUID, + candidateSurfaceId == surfaceId, + let event = note.userInfo?["event"] as? [String: Any] else { + return + } + if let observer { + NotificationCenter.default.removeObserver(observer) + } + finish(event) + } + } + guard let downloadEvent else { + return .err(code: "timeout", message: "No download event observed", data: ["timeout_ms": timeoutMs]) + } + return .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "download": downloadEvent + ]) } } @@ -8343,41 +9316,27 @@ class TerminalController { } private func v2BrowserCookieStoreAll(_ store: WKHTTPCookieStore, timeout: TimeInterval = 3.0) -> [HTTPCookie]? { - var done = false - var cookies: [HTTPCookie] = [] - store.getAllCookies { items in - cookies = items - done = true + v2AwaitCallback(timeout: timeout) { finish in + store.getAllCookies { items in + finish(items) + } } - let deadline = Date().addingTimeInterval(timeout) - while !done && Date() < deadline { - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) - } - return done ? cookies : nil } private func v2BrowserCookieStoreSet(_ store: WKHTTPCookieStore, cookie: HTTPCookie, timeout: TimeInterval = 3.0) -> Bool { - var done = false - store.setCookie(cookie) { - done = true - } - let deadline = Date().addingTimeInterval(timeout) - while !done && Date() < deadline { - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) - } - return done + v2AwaitCallback(timeout: timeout) { finish in + store.setCookie(cookie) { + finish(true) + } + } ?? false } private func v2BrowserCookieStoreDelete(_ store: WKHTTPCookieStore, cookie: HTTPCookie, timeout: TimeInterval = 3.0) -> Bool { - var done = false - store.delete(cookie) { - done = true - } - let deadline = Date().addingTimeInterval(timeout) - while !done && Date() < deadline { - _ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) - } - return done + v2AwaitCallback(timeout: timeout) { finish in + store.delete(cookie) { + finish(true) + } + } ?? false } private func v2BrowserCookieFromObject(_ raw: [String: Any], fallbackURL: URL?) -> HTTPCookie? { @@ -8673,7 +9632,7 @@ class TerminalController { "id": panel.id.uuidString, "ref": v2Ref(kind: .surface, uuid: panel.id), "index": index, - "title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle, + "title": panel.displayTitle, "url": panel.currentURL?.absoluteString ?? "", "focused": panel.id == ws.focusedPanelId, "pane_id": v2OrNull(ws.paneId(forPanelId: panel.id)?.id.uuidString), @@ -8718,7 +9677,7 @@ class TerminalController { return } - guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: v2FocusAllowed()) else { + guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: true) else { result = .err(code: "internal_error", message: "Failed to create browser tab", data: nil) return } @@ -9520,6 +10479,21 @@ class TerminalController { return .ok(payload) } + private func v2DebugBrowserFavicon(params: [String: Any]) -> V2CallResult { + return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in + let pngData = browserPanel.faviconPNGData + return .ok([ + "workspace_id": ws.id.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), + "surface_id": surfaceId.uuidString, + "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), + "has_favicon": pngData != nil, + "png_base64": pngData?.base64EncodedString() ?? "", + "current_url": v2OrNull(browserPanel.currentURL?.absoluteString) + ]) + } + } + private func v2DebugSidebarVisible(params: [String: Any]) -> V2CallResult { guard let windowId = v2UUID(params, "window_id") else { return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) @@ -9817,7 +10791,6 @@ class TerminalController { Available commands: ping - Check if server is running - auth <password> - Authenticate this connection (required in password mode) list_workspaces - List all workspaces with IDs new_workspace - Create a new workspace select_workspace <id|index> - Select workspace by ID or index (0-based) @@ -9934,37 +10907,6 @@ class TerminalController { } #if DEBUG - private func debugShortcutName(for action: KeyboardShortcutSettings.Action) -> String { - let snakeCase = action.rawValue.replacingOccurrences( - of: "([a-z0-9])([A-Z])", - with: "$1_$2", - options: .regularExpression - ) - return snakeCase.lowercased() - } - - private func debugShortcutAction(named rawName: String) -> KeyboardShortcutSettings.Action? { - let normalized = rawName - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - .replacingOccurrences(of: "-", with: "_") - - for action in KeyboardShortcutSettings.Action.allCases { - let snakeCaseName = debugShortcutName(for: action) - if normalized == snakeCaseName || normalized == snakeCaseName.replacingOccurrences(of: "_", with: "") { - return action - } - } - return nil - } - - private func debugShortcutSupportedNames() -> String { - KeyboardShortcutSettings.Action.allCases - .map(debugShortcutName(for:)) - .sorted() - .joined(separator: ", ") - } - private func setShortcut(_ args: String) -> String { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) @@ -9972,15 +10914,29 @@ class TerminalController { return "ERROR: Usage: set_shortcut <name> <combo|clear>" } - let name = parts[0] + let name = parts[0].lowercased() let combo = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - guard let action = debugShortcutAction(named: name) else { - return "ERROR: Unknown shortcut name. Supported: \(debugShortcutSupportedNames())" + let defaultsKey: String? + switch name { + case "focus_left", "focusleft": + defaultsKey = KeyboardShortcutSettings.focusLeftKey + case "focus_right", "focusright": + defaultsKey = KeyboardShortcutSettings.focusRightKey + case "focus_up", "focusup": + defaultsKey = KeyboardShortcutSettings.focusUpKey + case "focus_down", "focusdown": + defaultsKey = KeyboardShortcutSettings.focusDownKey + default: + defaultsKey = nil + } + + guard let defaultsKey else { + return "ERROR: Unknown shortcut name. Supported: focus_left, focus_right, focus_up, focus_down" } if combo.lowercased() == "clear" || combo.lowercased() == "default" || combo.lowercased() == "reset" { - UserDefaults.standard.removeObject(forKey: action.defaultsKey) + UserDefaults.standard.removeObject(forKey: defaultsKey) return "OK" } @@ -9998,13 +10954,12 @@ class TerminalController { guard let data = try? JSONEncoder().encode(shortcut) else { return "ERROR: Failed to encode shortcut" } - UserDefaults.standard.set(data, forKey: action.defaultsKey) + UserDefaults.standard.set(data, forKey: defaultsKey) return "OK" } private func prepareWindowForSyntheticInput(_ window: NSWindow?) { guard let window else { return } - // Keep socket-driven input simulation focused on the intended window without // paying repeated activation/order-front costs for every synthetic key event. if !NSApp.isActive { @@ -10143,22 +11098,7 @@ class TerminalController { return } - // If workspace handoff temporarily leaves a non-terminal first responder, - // route debug typing to the selected terminal's focused panel directly. - if let tabManager, - let tabId = tabManager.selectedTabId, - let tab = tabManager.tabs.first(where: { $0.id == tabId }), - let panelId = tab.focusedPanelId, - let terminalPanel = tab.terminalPanel(for: panelId), - !terminalPanel.hostedView.isSurfaceViewFirstResponder() { - // Match Enter semantics expected by tests/debug tooling when bypassing AppKit. - let directText = text.replacingOccurrences(of: "\n", with: "\r") - terminalPanel.surface.sendText(directText) - result = "OK" - return - } - - // Fall back to the responder-chain insertText action. + // Fall back to the responder chain insertText action. (fr as? NSResponder)?.insertText(text) result = "OK" } @@ -10751,10 +11691,6 @@ class TerminalController { let charactersIgnoringModifiers: String switch keyToken.lowercased() { - case "esc", "escape": - storedKey = "\u{1b}" - keyCode = UInt16(kVK_Escape) - charactersIgnoringModifiers = storedKey case "left": storedKey = "←" keyCode = 123 @@ -10775,10 +11711,6 @@ class TerminalController { storedKey = "\r" keyCode = UInt16(kVK_Return) charactersIgnoringModifiers = storedKey - case "backspace", "delete", "del": - storedKey = "\u{7f}" - keyCode = UInt16(kVK_Delete) - charactersIgnoringModifiers = storedKey default: let key = keyToken.lowercased() guard let code = keyCodeForShortcutKey(key) else { return nil } @@ -10917,8 +11849,7 @@ class TerminalController { guard let windowId = v2MainSync({ AppDelegate.shared?.createMainWindow() }) else { return "ERROR: Failed to create window" } - if socketCommandAllowsInAppFocusMutations(), - let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { + if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { setActiveTabManager(tm) } return "OK \(windowId.uuidString)" @@ -10976,19 +11907,10 @@ class TerminalController { var newTabId: UUID? let focus = socketCommandAllowsInAppFocusMutations() - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif DispatchQueue.main.sync { let workspace = tabManager.addTab(select: focus, eagerLoadTerminal: !focus) newTabId = workspace.id } - #if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - dlog( - "socket.new_workspace focus=\(focus ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)" - ) - #endif return "OK \(newTabId?.uuidString ?? "unknown")" } @@ -11032,12 +11954,7 @@ class TerminalController { return } - if let newPanelId = tabManager.newSplit( - tabId: tabId, - surfaceId: targetSurface, - direction: direction, - focus: socketCommandAllowsInAppFocusMutations() - ) { + if let newPanelId = tabManager.newSplit(tabId: tabId, surfaceId: targetSurface, direction: direction) { result = "OK \(newPanelId.uuidString)" } } @@ -11883,13 +12800,43 @@ class TerminalController { private func waitForTerminalSurface(_ terminalPanel: TerminalPanel, waitUpTo timeout: TimeInterval = 0.6) -> ghostty_surface_t? { if let surface = terminalPanel.surface.surface { return surface } - // This can be transient during bonsplit tree restructuring when the SwiftUI - // view is temporarily detached and then reattached (surface creation is - // gated on view/window/bounds). Pump the runloop briefly to allow pending - // attach retries to execute. - let deadline = Date().addingTimeInterval(timeout) - while terminalPanel.surface.surface == nil && Date() < deadline { - RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) + let terminalSurface = terminalPanel.surface + terminalSurface.requestBackgroundSurfaceStartIfNeeded() + _ = v2AwaitCallback(timeout: timeout) { finish in + var readyObserver: NSObjectProtocol? + var hostedViewObserver: NSObjectProtocol? + let finishOnce: () -> Void = { + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + if let hostedViewObserver { + NotificationCenter.default.removeObserver(hostedViewObserver) + } + finish(()) + } + + readyObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: terminalSurface, + queue: .main + ) { _ in + finishOnce() + } + hostedViewObserver = NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: terminalSurface, + queue: .main + ) { _ in + Task { @MainActor in + if terminalSurface.surface != nil { + finishOnce() + } + } + } + + if terminalSurface.surface != nil { + finishOnce() + } } return terminalPanel.surface.surface @@ -12042,29 +12989,6 @@ class TerminalController { } } - private func sendSocketText(_ text: String, surface: ghostty_surface_t) { - let chunks = Self.socketTextChunks(text) - #if DEBUG - let startedAt = ProcessInfo.processInfo.systemUptime - #endif - for chunk in chunks { - switch chunk { - case .text(let value): - sendTextEvent(surface: surface, text: value) - case .control(let scalar): - _ = handleControlScalar(scalar, surface: surface) - } - } - #if DEBUG - let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 - if elapsedMs >= 8 || chunks.count > 1 { - dlog( - "socket.send_text.inject chars=\(text.count) chunks=\(chunks.count) ms=\(String(format: "%.2f", elapsedMs))" - ) - } - #endif - } - private func handleControlScalar(_ scalar: UnicodeScalar, surface: ghostty_surface_t) -> Bool { switch scalar.value { case 0x0A, 0x0D: @@ -12167,6 +13091,15 @@ class TerminalController { return } + guard let surface = resolveTerminalSurface( + from: terminalPanel.id.uuidString, + tabManager: tabManager, + waitUpTo: 2.0 + ) else { + error = "ERROR: Surface not ready" + return + } + // Unescape common escape sequences // Note: \n is converted to \r for terminal (Enter key sends \r) let unescaped = text @@ -12174,11 +13107,13 @@ class TerminalController { .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - if let surface = terminalPanel.surface.surface { - sendSocketText(unescaped, surface: surface) - } else { - terminalPanel.sendText(unescaped) - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + for char in unescaped { + if char.unicodeScalars.count == 1, + let scalar = char.unicodeScalars.first, + handleControlScalar(scalar, surface: surface) { + continue + } + sendTextEvent(surface: surface, text: String(char)) } success = true } @@ -12186,6 +13121,29 @@ class TerminalController { return success ? "OK" : "ERROR: Failed to send input" } + private func sendSocketText(_ text: String, surface: ghostty_surface_t) { + let chunks = Self.socketTextChunks(text) +#if DEBUG + let startedAt = ProcessInfo.processInfo.systemUptime +#endif + for chunk in chunks { + switch chunk { + case .text(let value): + sendTextEvent(surface: surface, text: value) + case .control(let scalar): + _ = handleControlScalar(scalar, surface: surface) + } + } +#if DEBUG + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 + if elapsedMs >= 8 || chunks.count > 1 { + dlog( + "socket.send_text.inject chars=\(text.count) chunks=\(chunks.count) ms=\(String(format: "%.2f", elapsedMs))" + ) + } +#endif + } + private func sendInputToWorkspace(_ args: String) -> String { guard let tabManager else { return "ERROR: TabManager not available" } let parts = args.split(separator: " ", maxSplits: 1).map(String.init) @@ -12287,18 +13245,20 @@ class TerminalController { var success = false DispatchQueue.main.sync { - guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else { return } + guard let surface = resolveSurface(from: target, tabManager: tabManager) else { return } let unescaped = text .replacingOccurrences(of: "\\n", with: "\r") .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - if let surface = terminalPanel.surface.surface { - sendSocketText(unescaped, surface: surface) - } else { - terminalPanel.sendText(unescaped) - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + for char in unescaped { + if char.unicodeScalars.count == 1, + let scalar = char.unicodeScalars.first, + handleControlScalar(scalar, surface: surface) { + continue + } + sendTextEvent(surface: surface, text: String(char)) } success = true } @@ -12319,7 +13279,11 @@ class TerminalController { return } - guard let surface = terminalPanel.surface.surface else { + guard let surface = resolveTerminalSurface( + from: terminalPanel.id.uuidString, + tabManager: tabManager, + waitUpTo: 2.0 + ) else { error = "ERROR: Surface not ready" return } @@ -12341,11 +13305,11 @@ class TerminalController { var success = false var error: String? DispatchQueue.main.sync { - guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else { + guard resolveTerminalPanel(from: target, tabManager: tabManager) != nil else { error = "ERROR: Surface not found" return } - guard let surface = terminalPanel.surface.surface else { + guard let surface = resolveTerminalSurface(from: target, tabManager: tabManager, waitUpTo: 2.0) else { error = "ERROR: Surface not ready" return } @@ -12363,9 +13327,9 @@ class TerminalController { let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) let url: URL? = trimmed.isEmpty ? nil : URL(string: trimmed) - let shouldFocus = socketCommandAllowsInAppFocusMutations() var result = "ERROR: Failed to create browser panel" + let focus = socketCommandAllowsInAppFocusMutations() DispatchQueue.main.sync { guard let tabId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == tabId }), @@ -12377,7 +13341,7 @@ class TerminalController { from: focusedPanelId, orientation: .horizontal, url: url, - focus: shouldFocus + focus: focus )?.id { result = "OK \(browserPanelId.uuidString)" } @@ -12780,9 +13744,9 @@ class TerminalController { let orientation = direction.orientation let insertFirst = direction.insertFirst - let shouldFocus = socketCommandAllowsInAppFocusMutations() var result = "ERROR: Failed to create pane" + let focus = socketCommandAllowsInAppFocusMutations() DispatchQueue.main.sync { guard let tabId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == tabId }), @@ -12797,14 +13761,14 @@ class TerminalController { orientation: orientation, insertFirst: insertFirst, url: url, - focus: shouldFocus + focus: focus )?.id } else { newPanelId = tab.newTerminalSplit( from: focusedPanelId, orientation: orientation, insertFirst: insertFirst, - focus: shouldFocus + focus: focus )?.id } @@ -13492,9 +14456,6 @@ class TerminalController { result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } - guard Self.shouldReplaceProgress(current: tab.progress, value: clamped, label: label) else { - return - } tab.progress = SidebarProgressState(value: clamped, label: label) } return result @@ -13507,9 +14468,7 @@ class TerminalController { result = "ERROR: Tab not found" return } - if tab.progress != nil { - tab.progress = nil - } + tab.progress = nil } return result } @@ -13517,7 +14476,7 @@ class TerminalController { private func reportGitBranch(_ args: String) -> String { let parsed = parseOptions(args) guard let branch = parsed.positional.first else { - return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y]" + return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X]" } let isDirty = parsed.options["status"]?.lowercased() == "dirty" @@ -13544,35 +14503,7 @@ class TerminalController { result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" return } - let validSurfaceIds = Set(tab.panels.keys) - tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) - - let panelArg = parsed.options["panel"] ?? parsed.options["surface"] - let surfaceId: UUID - if let panelArg { - if panelArg.isEmpty { - result = "ERROR: Missing panel id — usage: report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y]" - return - } - guard let parsedId = UUID(uuidString: panelArg) else { - result = "ERROR: Invalid panel id '\(panelArg)'" - return - } - surfaceId = parsedId - } else { - guard let focused = tab.focusedPanelId else { - result = "ERROR: Missing panel id (no focused surface)" - return - } - surfaceId = focused - } - - guard validSurfaceIds.contains(surfaceId) else { - result = "ERROR: Panel not found '\(surfaceId.uuidString)'" - return - } - - tab.updatePanelGitBranch(panelId: surfaceId, branch: branch, isDirty: isDirty) + tab.gitBranch = SidebarGitBranchState(branch: branch, isDirty: isDirty) } return result } @@ -13596,42 +14527,13 @@ class TerminalController { } return "OK" } - var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { - result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected" + result = "ERROR: Tab not found" return } - let validSurfaceIds = Set(tab.panels.keys) - tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) - - let panelArg = parsed.options["panel"] ?? parsed.options["surface"] - let surfaceId: UUID - if let panelArg { - if panelArg.isEmpty { - result = "ERROR: Missing panel id — usage: clear_git_branch [--tab=X] [--panel=Y]" - return - } - guard let parsedId = UUID(uuidString: panelArg) else { - result = "ERROR: Invalid panel id '\(panelArg)'" - return - } - surfaceId = parsedId - } else { - guard let focused = tab.focusedPanelId else { - result = "ERROR: Missing panel id (no focused surface)" - return - } - surfaceId = focused - } - - guard validSurfaceIds.contains(surfaceId) else { - result = "ERROR: Panel not found '\(surfaceId.uuidString)'" - return - } - - tab.clearPanelGitBranch(panelId: surfaceId) + tab.gitBranch = nil } return result } @@ -13716,7 +14618,6 @@ class TerminalController { } ports.append(port) } - let normalizedPorts = Array(Set(ports)).sorted() var result = "OK" DispatchQueue.main.sync { @@ -13753,43 +14654,33 @@ class TerminalController { return } - guard Self.shouldReplacePorts(current: tab.surfaceListeningPorts[surfaceId], next: normalizedPorts) else { - return - } - - tab.surfaceListeningPorts[surfaceId] = normalizedPorts + tab.surfaceListeningPorts[surfaceId] = ports tab.recomputeListeningPorts() } return result } private func reportPwd(_ args: String) -> String { + guard let tabManager else { return "ERROR: TabManager not available" } let parsed = parseOptions(args) guard !parsed.positional.isEmpty else { return "ERROR: Missing path — usage: report_pwd <path> [--tab=X] [--panel=Y]" } - let directory = Self.normalizeReportedDirectory(parsed.positional.joined(separator: " ")) - - // Shell integration provides explicit UUID handles for cwd updates. - // Keep this hot path off-main and drop no-op reports before scheduling UI work. + let directory = parsed.positional.joined(separator: " ") if let scope = Self.explicitSocketScope(options: parsed.options) { - guard Self.socketFastPathState.shouldPublishDirectory( - workspaceId: scope.workspaceId, - panelId: scope.panelId, - directory: directory - ) else { - return "OK" - } DispatchQueue.main.async { - guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId) else { return } + guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId), + let tab = tabManager.tabs.first(where: { $0.id == scope.workspaceId }) else { + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + guard validSurfaceIds.contains(scope.panelId) else { return } tabManager.updateSurfaceDirectory(tabId: scope.workspaceId, surfaceId: scope.panelId, directory: directory) } return "OK" } - - guard let tabManager else { return "ERROR: TabManager not available" } - var result = "OK" DispatchQueue.main.sync { guard let tab = resolveTabForReport(args) else { @@ -13922,15 +14813,11 @@ class TerminalController { result = "ERROR: Panel not found '\(surfaceId.uuidString)'" return } - if tab.surfaceListeningPorts.removeValue(forKey: surfaceId) != nil { - tab.recomputeListeningPorts() - } + tab.surfaceListeningPorts.removeValue(forKey: surfaceId) } else { - if !tab.surfaceListeningPorts.isEmpty { - tab.surfaceListeningPorts.removeAll() - tab.recomputeListeningPorts() - } + tab.surfaceListeningPorts.removeAll() } + tab.recomputeListeningPorts() } return result } @@ -13941,14 +14828,18 @@ class TerminalController { return "ERROR: Missing tty name — usage: report_tty <tty_name> [--tab=X] [--panel=Y]" } - // Shell integration always provides explicit UUID handles. - // Handle that common path off-main to avoid sync-hopping on every report. if let scope = Self.explicitSocketScope(options: parsed.options) { - PortScanner.shared.registerTTY( - workspaceId: scope.workspaceId, - panelId: scope.panelId, - ttyName: ttyName - ) + DispatchQueue.main.async { + guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId), + let tab = tabManager.tabs.first(where: { $0.id == scope.workspaceId }) else { + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + guard validSurfaceIds.contains(scope.panelId) else { return } + tab.surfaceTTYNames[scope.panelId] = ttyName + PortScanner.shared.registerTTY(workspaceId: scope.workspaceId, panelId: scope.panelId, ttyName: ttyName) + } return "OK" } @@ -13985,7 +14876,6 @@ class TerminalController { return } - guard tab.surfaceTTYNames[surfaceId] != ttyName else { return } tab.surfaceTTYNames[surfaceId] = ttyName PortScanner.shared.registerTTY(workspaceId: tab.id, panelId: surfaceId, ttyName: ttyName) } @@ -13994,11 +14884,17 @@ class TerminalController { private func portsKick(_ args: String) -> String { let parsed = parseOptions(args) - - // Shell integration always provides explicit UUID handles. - // Handle that common path off-main to keep prompt hooks from blocking UI work. if let scope = Self.explicitSocketScope(options: parsed.options) { - PortScanner.shared.kick(workspaceId: scope.workspaceId, panelId: scope.panelId) + DispatchQueue.main.async { + guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId), + let tab = tabManager.tabs.first(where: { $0.id == scope.workspaceId }) else { + return + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + guard validSurfaceIds.contains(scope.panelId) else { return } + PortScanner.shared.kick(workspaceId: scope.workspaceId, panelId: scope.panelId) + } return "OK" } @@ -14250,7 +15146,6 @@ class TerminalController { var panelType: PanelType = .terminal var paneArg: String? = nil var url: URL? = nil - let shouldFocus = socketCommandAllowsInAppFocusMutations() let parts = args.split(separator: " ") for part in parts { @@ -14267,6 +15162,7 @@ class TerminalController { } var result = "ERROR: Failed to create tab" + let focus = socketCommandAllowsInAppFocusMutations() DispatchQueue.main.sync { guard let tabId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == tabId }) else { @@ -14295,9 +15191,9 @@ class TerminalController { let newPanelId: UUID? if panelType == .browser { - newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: shouldFocus)?.id + newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: focus)?.id } else { - newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: shouldFocus)?.id + newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: focus)?.id } if let id = newPanelId { @@ -14308,6 +15204,9 @@ class TerminalController { } deinit { + if let browserDownloadObserver { + NotificationCenter.default.removeObserver(browserDownloadObserver) + } stop() } } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index b44fbffb..e4b78917 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -680,10 +680,18 @@ final class WindowTerminalPortal: NSObject { private func scheduleExternalGeometrySynchronize() { guard !hasExternalGeometrySyncScheduled else { return } hasExternalGeometrySyncScheduled = true + let requiresSettledLayout = !(hostView.inLiveResize || window?.inLiveResize == true) DispatchQueue.main.async { [weak self] in guard let self else { return } - self.hasExternalGeometrySyncScheduled = false - self.synchronizeAllEntriesFromExternalGeometryChange() + let performSync = { + self.hasExternalGeometrySyncScheduled = false + self.synchronizeAllEntriesFromExternalGeometryChange() + } + if requiresSettledLayout { + DispatchQueue.main.async(execute: performSync) + } else { + performSync() + } } } @@ -1785,9 +1793,11 @@ enum TerminalWindowPortalRegistry { guard !Self.hasPendingExternalGeometrySyncForAllWindows else { return } Self.hasPendingExternalGeometrySyncForAllWindows = true DispatchQueue.main.async { - Self.hasPendingExternalGeometrySyncForAllWindows = false - for portal in Self.portalsByWindowId.values { - portal.synchronizeAllEntriesFromExternalGeometryChange() + DispatchQueue.main.async { + Self.hasPendingExternalGeometrySyncForAllWindows = false + for portal in Self.portalsByWindowId.values { + portal.synchronizeAllEntriesFromExternalGeometryChange() + } } } } diff --git a/Sources/Update/UpdateController.swift b/Sources/Update/UpdateController.swift index 7cc9beb9..ef1176bf 100644 --- a/Sources/Update/UpdateController.swift +++ b/Sources/Update/UpdateController.swift @@ -3,6 +3,47 @@ import Cocoa import Combine import SwiftUI +enum UpdateSettings { + static let automaticChecksKey = "SUEnableAutomaticChecks" + static let automaticallyUpdateKey = "SUAutomaticallyUpdate" + static let scheduledCheckIntervalKey = "SUScheduledCheckInterval" + static let sendProfileInfoKey = "SUSendProfileInfo" + static let migrationKey = "cmux.sparkle.automaticChecksMigration.v1" + static let scheduledCheckInterval: TimeInterval = 60 * 60 * 24 + + static func apply(to defaults: UserDefaults) { + defaults.register(defaults: [ + automaticChecksKey: true, + automaticallyUpdateKey: false, + scheduledCheckIntervalKey: scheduledCheckInterval, + sendProfileInfoKey: false, + ]) + + guard !defaults.bool(forKey: migrationKey) else { return } + + // Repair older installs that may have ended up with automatic checks disabled + // before the updater defaults were embedded in Info.plist. + defaults.set(true, forKey: automaticChecksKey) + + if let interval = defaults.object(forKey: scheduledCheckIntervalKey) as? NSNumber { + if interval.doubleValue <= 0 { + defaults.set(scheduledCheckInterval, forKey: scheduledCheckIntervalKey) + } + } else { + defaults.set(scheduledCheckInterval, forKey: scheduledCheckIntervalKey) + } + + if defaults.object(forKey: automaticallyUpdateKey) == nil { + defaults.set(false, forKey: automaticallyUpdateKey) + } + if defaults.object(forKey: sendProfileInfoKey) == nil { + defaults.set(false, forKey: sendProfileInfoKey) + } + + defaults.set(true, forKey: migrationKey) + } +} + /// Controller for managing Sparkle updates in cmux. class UpdateController { private(set) var updater: SPUUpdater @@ -27,13 +68,8 @@ class UpdateController { } init() { - // cmux checks for updates in the background, but keeps automatic download and - // profile submission disabled so all install intent stays user-driven. let defaults = UserDefaults.standard - defaults.register(defaults: [ - "SUSendProfileInfo": false, - "SUAutomaticallyUpdate": false, - ]) + UpdateSettings.apply(to: defaults) let hostBundle = Bundle.main self.userDriver = UpdateDriver(viewModel: .init(), hostBundle: hostBundle) @@ -63,19 +99,22 @@ class UpdateController { // delegate now suppresses Sparkle's permission UI entirely. if ProcessInfo.processInfo.environment["CMUX_UI_TEST_RESET_SPARKLE_PERMISSION"] == "1" { let defaults = UserDefaults.standard - defaults.removeObject(forKey: "SUEnableAutomaticChecks") - defaults.removeObject(forKey: "SUSendProfileInfo") - defaults.removeObject(forKey: "SUAutomaticallyUpdate") + defaults.removeObject(forKey: UpdateSettings.automaticChecksKey) + defaults.removeObject(forKey: UpdateSettings.automaticallyUpdateKey) + defaults.removeObject(forKey: UpdateSettings.scheduledCheckIntervalKey) + defaults.removeObject(forKey: UpdateSettings.sendProfileInfoKey) + defaults.removeObject(forKey: UpdateSettings.migrationKey) defaults.synchronize() UpdateLogStore.shared.append("reset sparkle permission defaults (ui test)") } #endif do { - updater.automaticallyChecksForUpdates = true - updater.automaticallyDownloadsUpdates = false - updater.sendsSystemProfile = false try updater.start() didStartUpdater = true + let interval = Int(updater.updateCheckInterval.rounded()) + UpdateLogStore.shared.append( + "updater started (autoChecks=\(updater.automaticallyChecksForUpdates), interval=\(interval)s, autoDownloads=\(updater.automaticallyDownloadsUpdates))" + ) } catch { userDriver.viewModel.state = .error(.init( error: error, diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index b3adfc15..7de114d3 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -33,7 +33,15 @@ extension UpdateDriver: SPUUpdaterDelegate { let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeedURL) UpdateLogStore.shared.append("update channel: \(resolved.isNightly ? "nightly" : "stable")") recordFeedURLString(resolved.url, usedFallback: resolved.usedFallback) - return infoFeedURL + return resolved.url + } + + func updater(_ updater: SPUUpdater, willScheduleUpdateCheckAfterDelay delay: TimeInterval) { + UpdateLogStore.shared.append("next update check scheduled in \(Int(delay.rounded()))s") + } + + func updaterWillNotScheduleUpdateCheck(_ updater: SPUUpdater) { + UpdateLogStore.shared.append("automatic update checks disabled; no scheduled check") } /// Called when an update is scheduled to install silently, diff --git a/Sources/Update/UpdateDriver.swift b/Sources/Update/UpdateDriver.swift index 04dedebd..289df890 100644 --- a/Sources/Update/UpdateDriver.swift +++ b/Sources/Update/UpdateDriver.swift @@ -27,11 +27,11 @@ class UpdateDriver: NSObject, SPUUserDriver { return } #endif - // Never show Sparkle's permission UI. cmux relies on its in-app update pill instead, - // and defaults to manual update checks unless explicitly enabled elsewhere. - UpdateLogStore.shared.append("auto-deny update permission (no UI)") + // Never show Sparkle's permission UI. cmux always enables scheduled checks and keeps + // automatic downloads disabled so installs remain user-driven. + UpdateLogStore.shared.append("auto-allow update permission (no UI)") DispatchQueue.main.async { - reply(SUUpdatePermissionResponse(automaticUpdateChecks: false, sendSystemProfile: false)) + reply(SUUpdatePermissionResponse(automaticUpdateChecks: true, sendSystemProfile: false)) } } diff --git a/Sources/WindowToolbarController.swift b/Sources/WindowToolbarController.swift index 52d9ff26..462b036f 100644 --- a/Sources/WindowToolbarController.swift +++ b/Sources/WindowToolbarController.swift @@ -94,7 +94,7 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { let text: String if let selectedId = tabManager.selectedTabId, let tab = tabManager.tabs.first(where: { $0.id == selectedId }) { - let title = tab.title.trimmingCharacters(in: .whitespacesAndNewlines) + let title = tab.title.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) text = title.isEmpty ? "Cmd: —" : "Cmd: \(title)" } else { text = "Cmd: —" diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a14cbf69..d8e75ea2 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3,6 +3,9 @@ import SwiftUI import AppKit import Bonsplit import Combine +import CryptoKit +import Darwin +import Network import CoreText func cmuxSurfaceContextName(_ context: ghostty_surface_context_e) -> String { @@ -23,7 +26,7 @@ func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? { return nil } - let ctFont = Unmanaged<CTFont>.fromOpaque(quicklookFont).takeRetainedValue() + let ctFont = Unmanaged<CTFont>.fromOpaque(quicklookFont).takeUnretainedValue() let points = Float(CTFontGetSize(ctFont)) guard points > 0 else { return nil } return points @@ -104,7 +107,49 @@ private struct SessionPaneRestoreEntry { let snapshot: SessionPaneLayoutSnapshot } +struct WorkspaceRemoteDaemonManifest: Decodable, Equatable { + struct Entry: Decodable, Equatable { + let goOS: String + let goArch: String + let assetName: String + let downloadURL: String + let sha256: String + } + + let schemaVersion: Int + let appVersion: String + let releaseTag: String + let releaseURL: String + let checksumsAssetName: String + let checksumsURL: String + let entries: [Entry] + + func entry(goOS: String, goArch: String) -> Entry? { + entries.first { $0.goOS == goOS && $0.goArch == goArch } + } +} + extension Workspace { + nonisolated static let remoteDaemonManifestInfoKey = WorkspaceRemoteSessionController.remoteDaemonManifestInfoKey + + nonisolated static func remoteDaemonManifest(from infoDictionary: [String: Any]?) -> WorkspaceRemoteDaemonManifest? { + WorkspaceRemoteSessionController.remoteDaemonManifest(from: infoDictionary) + } + + nonisolated static func remoteDaemonCachedBinaryURL( + version: String, + goOS: String, + goArch: String, + fileManager: FileManager = .default + ) throws -> URL { + try WorkspaceRemoteSessionController.remoteDaemonCachedBinaryURL( + version: version, + goOS: goOS, + goArch: goArch, + fileManager: fileManager + ) + } + func sessionSnapshot(includeScrollback: Bool) -> SessionWorkspaceSnapshot { let tree = bonsplitController.treeSnapshot() let layout = sessionLayoutSnapshot(from: tree) @@ -330,17 +375,17 @@ extension Workspace { urlString: browserPanel.preferredURLStringForOmnibar(), profileID: browserPanel.profileID, shouldRenderWebView: browserPanel.shouldRenderWebView, - pageZoom: Double(browserPanel.webView.pageZoom), + pageZoom: Double(browserPanel.currentPageZoomFactor()), developerToolsVisible: browserPanel.isDeveloperToolsVisible(), backHistoryURLStrings: historySnapshot.backHistoryURLStrings, forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings ) markdownSnapshot = nil case .markdown: - guard let mdPanel = panel as? MarkdownPanel else { return nil } + guard let markdownPanel = panel as? MarkdownPanel else { return nil } terminalSnapshot = nil browserSnapshot = nil - markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: mdPanel.filePath) + markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: markdownPanel.filePath) } return SessionPanelSnapshot( @@ -522,14 +567,12 @@ extension Workspace { applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id) return browserPanel.id case .markdown: - guard let filePath = snapshot.markdown?.filePath else { - return nil - } - guard let markdownPanel = newMarkdownSurface( - inPane: paneId, - filePath: filePath, - focus: false - ) else { + guard let filePath = snapshot.markdown?.filePath, + let markdownPanel = newMarkdownSurface( + inPane: paneId, + filePath: filePath, + focus: false + ) else { return nil } applySessionPanelMetadata(snapshot, toPanelId: markdownPanel.id) @@ -579,7 +622,7 @@ extension Workspace { let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom))) if pageZoom.isFinite { - browserPanel.webView.pageZoom = pageZoom + _ = browserPanel.setPageZoomFactor(pageZoom) } if browserSnapshot.developerToolsVisible { @@ -612,6 +655,3744 @@ extension Workspace { } } +final class WorkspaceRemoteDaemonPendingCallRegistry { + final class PendingCall { + let id: Int + fileprivate let semaphore = DispatchSemaphore(value: 0) + fileprivate var response: [String: Any]? + fileprivate var failureMessage: String? + + fileprivate init(id: Int) { + self.id = id + } + } + + enum WaitOutcome { + case response([String: Any]) + case failure(String) + case missing + case timedOut + } + + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.pending.\(UUID().uuidString)") + private var nextRequestID = 1 + private var pendingCalls: [Int: PendingCall] = [:] + + func reset() { + queue.sync { + nextRequestID = 1 + pendingCalls.removeAll(keepingCapacity: false) + } + } + + func register() -> PendingCall { + queue.sync { + let call = PendingCall(id: nextRequestID) + nextRequestID += 1 + pendingCalls[call.id] = call + return call + } + } + + @discardableResult + func resolve(id: Int, payload: [String: Any]) -> Bool { + queue.sync { + guard let pendingCall = pendingCalls[id] else { return false } + pendingCall.response = payload + pendingCall.semaphore.signal() + return true + } + } + + func failAll(_ message: String) { + queue.sync { + let calls = Array(pendingCalls.values) + for call in calls { + guard call.response == nil, call.failureMessage == nil else { continue } + call.failureMessage = message + call.semaphore.signal() + } + } + } + + func remove(_ call: PendingCall) { + _ = queue.sync { + pendingCalls.removeValue(forKey: call.id) + } + } + + func wait(for call: PendingCall, timeout: TimeInterval) -> WaitOutcome { + if call.semaphore.wait(timeout: .now() + timeout) == .timedOut { + _ = queue.sync { + pendingCalls.removeValue(forKey: call.id) + } + // A response can win the race immediately before timeout cleanup removes the call. + // Drain any late signal so DispatchSemaphore is not deallocated with a positive count. + _ = call.semaphore.wait(timeout: .now()) + return .timedOut + } + + return queue.sync { + guard let pendingCall = pendingCalls.removeValue(forKey: call.id) else { + return .missing + } + if let failure = pendingCall.failureMessage { + return .failure(failure) + } + guard let response = pendingCall.response else { + return .missing + } + return .response(response) + } + } +} + +private final class WorkspaceRemoteDaemonRPCClient { + private static let maxStdoutBufferBytes = 256 * 1024 + static let requiredProxyStreamCapability = "proxy.stream.push" + + enum StreamEvent { + case data(Data) + case eof(Data) + case error(String) + } + + private struct StreamSubscription { + let queue: DispatchQueue + let handler: (StreamEvent) -> Void + } + + private let configuration: WorkspaceRemoteConfiguration + private let remotePath: String + private let onUnexpectedTermination: (String) -> Void + private let writeQueue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.write.\(UUID().uuidString)") + private let stateQueue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-rpc.state.\(UUID().uuidString)") + private let pendingCalls = WorkspaceRemoteDaemonPendingCallRegistry() + + private var process: Process? + private var stdinHandle: FileHandle? + private var stdoutHandle: FileHandle? + private var stderrHandle: FileHandle? + private var isClosed = true + private var shouldReportTermination = true + + private var stdoutBuffer = Data() + private var stderrBuffer = "" + private var streamSubscriptions: [String: StreamSubscription] = [:] + + init( + configuration: WorkspaceRemoteConfiguration, + remotePath: String, + onUnexpectedTermination: @escaping (String) -> Void + ) { + self.configuration = configuration + self.remotePath = remotePath + self.onUnexpectedTermination = onUnexpectedTermination + } + + func start() throws { + let process = Process() + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = Self.daemonArguments(configuration: configuration, remotePath: remotePath) + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + stdoutPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + self?.stateQueue.async { + self?.consumeStdoutData(data) + } + } + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + self?.stateQueue.async { + self?.consumeStderrData(data) + } + } + process.terminationHandler = { [weak self] terminated in + self?.stateQueue.async { + self?.handleProcessTermination(terminated) + } + } + + do { + try process.run() + } catch { + throw NSError(domain: "cmux.remote.daemon.rpc", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to launch SSH daemon transport: \(error.localizedDescription)", + ]) + } + + stateQueue.sync { + self.process = process + self.stdinHandle = stdinPipe.fileHandleForWriting + self.stdoutHandle = stdoutPipe.fileHandleForReading + self.stderrHandle = stderrPipe.fileHandleForReading + self.isClosed = false + self.shouldReportTermination = true + self.stdoutBuffer = Data() + self.stderrBuffer = "" + self.streamSubscriptions.removeAll(keepingCapacity: false) + } + pendingCalls.reset() + + do { + let hello = try call(method: "hello", params: [:], timeout: 8.0) + let capabilities = (hello["capabilities"] as? [String]) ?? [] + guard capabilities.contains(Self.requiredProxyStreamCapability) else { + throw NSError(domain: "cmux.remote.daemon.rpc", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon missing required capability \(Self.requiredProxyStreamCapability)", + ]) + } + } catch { + stop(suppressTerminationCallback: true) + throw error + } + } + + func stop() { + stop(suppressTerminationCallback: true) + } + + func openStream(host: String, port: Int, timeoutMs: Int = 10000) throws -> String { + let result = try call( + method: "proxy.open", + params: [ + "host": host, + "port": port, + "timeout_ms": timeoutMs, + ], + timeout: 12.0 + ) + let streamID = (result["stream_id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !streamID.isEmpty else { + throw NSError(domain: "cmux.remote.daemon.rpc", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "proxy.open missing stream_id", + ]) + } + return streamID + } + + func writeStream(streamID: String, data: Data) throws { + _ = try call( + method: "proxy.write", + params: [ + "stream_id": streamID, + "data_base64": data.base64EncodedString(), + ], + timeout: 8.0 + ) + } + + func attachStream( + streamID: String, + queue: DispatchQueue, + onEvent: @escaping (StreamEvent) -> Void + ) throws { + let trimmedStreamID = streamID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedStreamID.isEmpty else { + throw NSError(domain: "cmux.remote.daemon.rpc", code: 17, userInfo: [ + NSLocalizedDescriptionKey: "proxy.stream.subscribe requires stream_id", + ]) + } + + stateQueue.sync { + streamSubscriptions[trimmedStreamID] = StreamSubscription(queue: queue, handler: onEvent) + } + + do { + _ = try call( + method: "proxy.stream.subscribe", + params: ["stream_id": trimmedStreamID], + timeout: 8.0 + ) + } catch { + unregisterStream(streamID: trimmedStreamID) + throw error + } + } + + func unregisterStream(streamID: String) { + let trimmedStreamID = streamID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedStreamID.isEmpty else { return } + _ = stateQueue.sync { + streamSubscriptions.removeValue(forKey: trimmedStreamID) + } + } + + func closeStream(streamID: String) { + unregisterStream(streamID: streamID) + _ = try? call( + method: "proxy.close", + params: ["stream_id": streamID], + timeout: 4.0 + ) + } + + private func call(method: String, params: [String: Any], timeout: TimeInterval) throws -> [String: Any] { + let pendingCall = pendingCalls.register() + let requestID = pendingCall.id + + let payload: Data + do { + payload = try Self.encodeJSON([ + "id": requestID, + "method": method, + "params": params, + ]) + } catch { + pendingCalls.remove(pendingCall) + throw NSError(domain: "cmux.remote.daemon.rpc", code: 10, userInfo: [ + NSLocalizedDescriptionKey: "failed to encode daemon RPC request \(method): \(error.localizedDescription)", + ]) + } + + do { + try writeQueue.sync { + try writePayload(payload) + } + } catch { + pendingCalls.remove(pendingCall) + throw error + } + + let response: [String: Any] + switch pendingCalls.wait(for: pendingCall, timeout: timeout) { + case .timedOut: + stop(suppressTerminationCallback: false) + throw NSError(domain: "cmux.remote.daemon.rpc", code: 11, userInfo: [ + NSLocalizedDescriptionKey: "daemon RPC timeout waiting for \(method) response", + ]) + case .failure(let failure): + throw NSError(domain: "cmux.remote.daemon.rpc", code: 12, userInfo: [ + NSLocalizedDescriptionKey: failure, + ]) + case .missing: + throw NSError(domain: "cmux.remote.daemon.rpc", code: 13, userInfo: [ + NSLocalizedDescriptionKey: "daemon RPC \(method) returned empty response", + ]) + case .response(let pendingResponse): + response = pendingResponse + } + + let ok = (response["ok"] as? Bool) ?? false + if ok { + return (response["result"] as? [String: Any]) ?? [:] + } + + let errorObject = (response["error"] as? [String: Any]) ?? [:] + let code = (errorObject["code"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "rpc_error" + let message = (errorObject["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "daemon RPC call failed" + throw NSError(domain: "cmux.remote.daemon.rpc", code: 14, userInfo: [ + NSLocalizedDescriptionKey: "\(method) failed (\(code)): \(message)", + ]) + } + + private func writePayload(_ payload: Data) throws { + let stdinHandle: FileHandle = stateQueue.sync { + self.stdinHandle ?? FileHandle.nullDevice + } + if stdinHandle === FileHandle.nullDevice { + throw NSError(domain: "cmux.remote.daemon.rpc", code: 15, userInfo: [ + NSLocalizedDescriptionKey: "daemon transport is not connected", + ]) + } + do { + try stdinHandle.write(contentsOf: payload) + try stdinHandle.write(contentsOf: Data([0x0A])) + } catch { + stop(suppressTerminationCallback: false) + throw NSError(domain: "cmux.remote.daemon.rpc", code: 16, userInfo: [ + NSLocalizedDescriptionKey: "failed writing daemon RPC request: \(error.localizedDescription)", + ]) + } + } + + private func consumeStdoutData(_ data: Data) { + guard !data.isEmpty else { + signalPendingFailureLocked("daemon transport closed stdout") + return + } + + stdoutBuffer.append(data) + if stdoutBuffer.count > Self.maxStdoutBufferBytes { + stdoutBuffer.removeAll(keepingCapacity: false) + signalPendingFailureLocked("daemon transport stdout exceeded \(Self.maxStdoutBufferBytes) bytes without message framing") + process?.terminate() + return + } + while let newlineIndex = stdoutBuffer.firstIndex(of: 0x0A) { + var lineData = Data(stdoutBuffer[..<newlineIndex]) + stdoutBuffer.removeSubrange(...newlineIndex) + + if let carriageIndex = lineData.lastIndex(of: 0x0D), carriageIndex == lineData.index(before: lineData.endIndex) { + lineData.remove(at: carriageIndex) + } + guard !lineData.isEmpty else { continue } + + guard let payload = try? JSONSerialization.jsonObject(with: lineData, options: []) as? [String: Any] else { + continue + } + + if let responseID = Self.responseID(in: payload) { + _ = pendingCalls.resolve(id: responseID, payload: payload) + continue + } + + consumeEventPayload(payload) + } + } + + private func consumeStderrData(_ data: Data) { + guard !data.isEmpty else { return } + guard let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty else { return } + stderrBuffer.append(chunk) + if stderrBuffer.count > 8192 { + stderrBuffer.removeFirst(stderrBuffer.count - 8192) + } + } + + private func consumeEventPayload(_ payload: [String: Any]) { + guard let eventName = (payload["event"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !eventName.isEmpty, + let streamID = (payload["stream_id"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !streamID.isEmpty else { + return + } + + let subscription: StreamSubscription? + let event: StreamEvent? + switch eventName { + case "proxy.stream.data": + subscription = streamSubscriptions[streamID] + event = .data(Self.decodeBase64Data(payload["data_base64"])) + + case "proxy.stream.eof": + subscription = streamSubscriptions.removeValue(forKey: streamID) + event = .eof(Self.decodeBase64Data(payload["data_base64"])) + + case "proxy.stream.error": + subscription = streamSubscriptions.removeValue(forKey: streamID) + let detail = ((payload["error"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } + ?? "stream error" + event = .error(detail) + + default: + return + } + + guard let subscription, let event else { return } + subscription.queue.async { + subscription.handler(event) + } + } + + private func handleProcessTermination(_ process: Process) { + let shouldNotify: Bool = { + guard self.process === process else { return false } + return !isClosed && shouldReportTermination + }() + let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport exited with status \(process.terminationStatus)" + + isClosed = true + self.process = nil + stdinHandle = nil + stdoutHandle?.readabilityHandler = nil + stdoutHandle = nil + stderrHandle?.readabilityHandler = nil + stderrHandle = nil + streamSubscriptions.removeAll(keepingCapacity: false) + signalPendingFailureLocked(detail) + + guard shouldNotify else { return } + onUnexpectedTermination(detail) + } + + private func stop(suppressTerminationCallback: Bool) { + let captured: (Process?, FileHandle?, FileHandle?, FileHandle?, Bool, String) = stateQueue.sync { + let detail = Self.bestErrorLine(stderr: stderrBuffer) ?? "daemon transport stopped" + let shouldNotify = !suppressTerminationCallback && !isClosed + shouldReportTermination = !suppressTerminationCallback + if isClosed { + return (nil, nil, nil, nil, false, detail) + } + + isClosed = true + signalPendingFailureLocked("daemon transport stopped") + let capturedProcess = process + let capturedStdin = stdinHandle + let capturedStdout = stdoutHandle + let capturedStderr = stderrHandle + + process = nil + stdinHandle = nil + stdoutHandle = nil + stderrHandle = nil + streamSubscriptions.removeAll(keepingCapacity: false) + return (capturedProcess, capturedStdin, capturedStdout, capturedStderr, shouldNotify, detail) + } + + captured.2?.readabilityHandler = nil + captured.3?.readabilityHandler = nil + try? captured.1?.close() + try? captured.2?.close() + try? captured.3?.close() + if let process = captured.0, process.isRunning { + process.terminate() + } + if captured.4 { + onUnexpectedTermination(captured.5) + } + } + + private func signalPendingFailureLocked(_ message: String) { + pendingCalls.failAll(message) + } + + private static func responseID(in payload: [String: Any]) -> Int? { + if let intValue = payload["id"] as? Int { + return intValue + } + if let numberValue = payload["id"] as? NSNumber { + return numberValue.intValue + } + return nil + } + + private static func decodeBase64Data(_ value: Any?) -> Data { + guard let encoded = value as? String, !encoded.isEmpty else { return Data() } + return Data(base64Encoded: encoded) ?? Data() + } + + private static func encodeJSON(_ object: [String: Any]) throws -> Data { + try JSONSerialization.data(withJSONObject: object, options: []) + } + + private static func daemonArguments(configuration: WorkspaceRemoteConfiguration, remotePath: String) -> [String] { + let script = "exec \(shellSingleQuoted(remotePath)) serve --stdio" + // Use non-login sh so remote ~/.profile noise does not interfere with daemon transport startup. + let command = "sh -c \(shellSingleQuoted(script))" + return ["-T", "-S", "none"] + + sshCommonArguments(configuration: configuration, batchMode: true) + + ["-o", "RequestTTY=no", configuration.destination, command] + } + + private static let batchSSHControlOptionKeys: Set<String> = [ + "controlmaster", + "controlpersist", + ] + + private static func sshCommonArguments(configuration: WorkspaceRemoteConfiguration, batchMode: Bool) -> [String] { + let effectiveSSHOptions: [String] = { + if batchMode { + return backgroundSSHOptions(configuration.sshOptions) + } + return normalizedSSHOptions(configuration.sshOptions) + }() + var args: [String] = [ + "-o", "ConnectTimeout=6", + "-o", "ServerAliveInterval=20", + "-o", "ServerAliveCountMax=2", + ] + if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { + args += ["-o", "StrictHostKeyChecking=accept-new"] + } + if batchMode { + args += ["-o", "BatchMode=yes"] + // Batch helpers should reuse an existing ControlPath if one was configured, + // but must never try to negotiate a new master connection. + args += ["-o", "ControlMaster=no"] + } + if let port = configuration.port { + args += ["-p", String(port)] + } + if let identityFile = configuration.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args += ["-i", identityFile] + } + for option in effectiveSSHOptions { + args += ["-o", option] + } + return args + } + + private static func hasSSHOptionKey(_ options: [String], key: String) -> Bool { + let loweredKey = key.lowercased() + for option in options { + let token = sshOptionKey(option) + if token == loweredKey { + return true + } + } + return false + } + + private static func normalizedSSHOptions(_ options: [String]) -> [String] { + options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + } + } + + private static func backgroundSSHOptions(_ options: [String]) -> [String] { + normalizedSSHOptions(options).filter { option in + guard let key = sshOptionKey(option) else { return false } + return !batchSSHControlOptionKeys.contains(key) + } + } + + private static func sshOptionKey(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + } + + private static func shellSingleQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + private static func bestErrorLine(stderr: String) -> String? { + let lines = stderr + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + for line in lines.reversed() where !isNoiseLine(line) { + return line + } + return lines.last + } + + private static func isNoiseLine(_ line: String) -> Bool { + let lowered = line.lowercased() + if lowered.hasPrefix("warning: permanently added") { return true } + if lowered.hasPrefix("debug") { return true } + if lowered.hasPrefix("transferred:") { return true } + if lowered.hasPrefix("openbsd_") { return true } + if lowered.contains("pseudo-terminal will not be allocated") { return true } + return false + } +} + +enum RemoteLoopbackHTTPRequestRewriter { + private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a]) + private static let canonicalLoopbackHost = "localhost" + private static let requestLineMethods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "PRI"] + + static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data { + rewriteIfNeeded(data: data, aliasHost: aliasHost, allowIncompleteHeadersAtEOF: false) + } + + static func rewriteIfNeeded(data: Data, aliasHost: String, allowIncompleteHeadersAtEOF: Bool) -> Data { + let headerData: Data + let remainder: Data + + if let headerRange = data.range(of: headerDelimiter) { + headerData = Data(data[..<headerRange.upperBound]) + remainder = Data(data[headerRange.upperBound...]) + } else if allowIncompleteHeadersAtEOF { + headerData = data + remainder = Data() + } else { + return data + } + + guard let headerText = String(data: headerData, encoding: .utf8) else { return data } + + var lines = headerText.components(separatedBy: "\r\n") + guard !lines.isEmpty else { return data } + guard let requestLineIndex = lines.firstIndex(where: { !$0.isEmpty }) else { return data } + guard requestLineLooksHTTP(lines[requestLineIndex]) else { return data } + + let rewrittenRequestLine = rewriteRequestLine(lines[requestLineIndex], aliasHost: aliasHost) + if rewrittenRequestLine != lines[requestLineIndex] { + lines[requestLineIndex] = rewrittenRequestLine + } + + for index in (requestLineIndex + 1)..<lines.count where !lines[index].isEmpty { + lines[index] = rewriteHeaderLine(lines[index], aliasHost: aliasHost) + } + + let rewrittenHeaderText = lines.joined(separator: "\r\n") + guard rewrittenHeaderText != headerText else { return data } + return Data(rewrittenHeaderText.utf8) + remainder + } + + private static func requestLineLooksHTTP(_ requestLine: String) -> Bool { + let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines) + let method = trimmed.split(separator: " ", maxSplits: 1).first.map(String.init)?.uppercased() ?? "" + return requestLineMethods.contains(method) + } + + private static func rewriteRequestLine(_ requestLine: String, aliasHost: String) -> String { + let trimmed = requestLine.trimmingCharacters(in: .whitespacesAndNewlines) + let parts = trimmed.split(separator: " ", omittingEmptySubsequences: false) + guard parts.count >= 3 else { return requestLine } + + var components = URLComponents(string: String(parts[1])) + guard let host = components?.host, + BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { + return requestLine + } + components?.host = canonicalLoopbackHost + guard let rewrittenURL = components?.string else { return requestLine } + + var rewritten = parts + rewritten[1] = Substring(rewrittenURL) + let leadingTrivia = requestLine.prefix { $0.isWhitespace || $0.isNewline } + let trailingTrivia = String(requestLine.reversed().prefix { $0.isWhitespace || $0.isNewline }.reversed()) + return String(leadingTrivia) + rewritten.joined(separator: " ") + trailingTrivia + } + + private static func rewriteHeaderLine(_ line: String, aliasHost: String) -> String { + guard let colonIndex = line.firstIndex(of: ":") else { return line } + let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let valueStart = line.index(after: colonIndex) + let rawValue = line[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines) + + switch name { + case "host": + guard let rewrittenHost = rewriteHostValue(rawValue, aliasHost: aliasHost) else { return line } + return "\(line[..<valueStart]) \(rewrittenHost)" + case "origin", "referer": + guard let rewrittenURL = rewriteURLValue(rawValue, aliasHost: aliasHost) else { return line } + return "\(line[..<valueStart]) \(rewrittenURL)" + default: + return line + } + } + + private static func rewriteHostValue(_ value: String, aliasHost: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.hasPrefix("["), + let closing = trimmed.firstIndex(of: "]") { + let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<closing]) + guard BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { + return nil + } + let remainder = String(trimmed[closing...].dropFirst()) + return canonicalLoopbackHost + remainder + } + + if let colonIndex = trimmed.lastIndex(of: ":"), !trimmed[..<colonIndex].contains(":") { + let host = String(trimmed[..<colonIndex]) + guard BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { + return nil + } + return canonicalLoopbackHost + trimmed[colonIndex...] + } + + guard BrowserInsecureHTTPSettings.normalizeHost(trimmed) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { + return nil + } + return canonicalLoopbackHost + } + + private static func rewriteURLValue(_ value: String, aliasHost: String) -> String? { + var components = URLComponents(string: value) + guard let host = components?.host, + BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(aliasHost) else { + return nil + } + components?.host = canonicalLoopbackHost + return components?.string + } +} + +struct RemoteLoopbackHTTPRequestStreamRewriter { + private static let maxHeaderBytes = 64 * 1024 + private static let headerDelimiter = Data([0x0D, 0x0A, 0x0D, 0x0A]) + + private let aliasHost: String + private var pendingHeaderBytes = Data() + private var hasForwardedHeaders = false + + init(aliasHost: String) { + self.aliasHost = aliasHost + } + + mutating func rewriteNextChunk(_ data: Data, eof: Bool) -> Data { + guard !hasForwardedHeaders else { return data } + + pendingHeaderBytes.append(data) + if pendingHeaderBytes.count > Self.maxHeaderBytes { + hasForwardedHeaders = true + let payload = pendingHeaderBytes + pendingHeaderBytes = Data() + return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: payload, + aliasHost: aliasHost, + allowIncompleteHeadersAtEOF: true + ) + } + + guard pendingHeaderBytes.range(of: Self.headerDelimiter) != nil else { + guard eof else { return Data() } + hasForwardedHeaders = true + let payload = pendingHeaderBytes + pendingHeaderBytes = Data() + return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: payload, + aliasHost: aliasHost, + allowIncompleteHeadersAtEOF: true + ) + } + + hasForwardedHeaders = true + let payload = pendingHeaderBytes + pendingHeaderBytes = Data() + return RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: payload, + aliasHost: aliasHost + ) + } +} + +enum RemoteLoopbackHTTPResponseRewriter { + private static let headerDelimiter = Data([0x0d, 0x0a, 0x0d, 0x0a]) + private static let canonicalLoopbackHost = "localhost" + + static func rewriteIfNeeded(data: Data, aliasHost: String) -> Data { + guard let headerRange = data.range(of: headerDelimiter) else { return data } + let headerData = Data(data[..<headerRange.upperBound]) + guard let headerText = String(data: headerData, encoding: .utf8) else { return data } + + var lines = headerText.components(separatedBy: "\r\n") + guard let statusLineIndex = lines.firstIndex(where: { !$0.isEmpty }) else { return data } + guard lines[statusLineIndex].uppercased().hasPrefix("HTTP/") else { return data } + + for index in (statusLineIndex + 1)..<lines.count where !lines[index].isEmpty { + lines[index] = rewriteHeaderLine(lines[index], aliasHost: aliasHost) + } + + let rewrittenHeaderText = lines.joined(separator: "\r\n") + guard rewrittenHeaderText != headerText else { return data } + return Data(rewrittenHeaderText.utf8) + data[headerRange.upperBound...] + } + + private static func rewriteHeaderLine(_ line: String, aliasHost: String) -> String { + guard let colonIndex = line.firstIndex(of: ":") else { return line } + let name = line[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let valueStart = line.index(after: colonIndex) + let rawValue = line[valueStart...].trimmingCharacters(in: .whitespacesAndNewlines) + + switch name { + case "location", "content-location", "origin", "referer", "access-control-allow-origin": + guard let rewrittenURL = rewriteURLValue(rawValue, aliasHost: aliasHost) else { return line } + return "\(line[..<valueStart]) \(rewrittenURL)" + case "set-cookie": + guard let rewrittenCookie = rewriteCookieValue(rawValue, aliasHost: aliasHost) else { return line } + return "\(line[..<valueStart]) \(rewrittenCookie)" + default: + return line + } + } + + private static func rewriteURLValue(_ value: String, aliasHost: String) -> String? { + var components = URLComponents(string: value) + guard let host = components?.host, + BrowserInsecureHTTPSettings.normalizeHost(host) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else { + return nil + } + components?.host = aliasHost + return components?.string + } + + private static func rewriteCookieValue(_ value: String, aliasHost: String) -> String? { + let parts = value.split(separator: ";", omittingEmptySubsequences: false).map(String.init) + guard !parts.isEmpty else { return nil } + + var didRewrite = false + let rewrittenParts = parts.map { part -> String in + let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.lowercased().hasPrefix("domain=") else { return part } + let domainValue = String(trimmed.dropFirst("domain=".count)) + guard BrowserInsecureHTTPSettings.normalizeHost(domainValue) == BrowserInsecureHTTPSettings.normalizeHost(canonicalLoopbackHost) else { + return part + } + didRewrite = true + let leadingWhitespace = part.prefix { $0.isWhitespace } + return "\(leadingWhitespace)Domain=\(aliasHost)" + } + + return didRewrite ? rewrittenParts.joined(separator: ";") : nil + } +} + +private final class WorkspaceRemoteDaemonProxyTunnel { + private final class ProxySession { + private static let maxHandshakeBytes = 64 * 1024 + private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me" + + private enum HandshakeProtocol { + case undecided + case socks5 + case connect + } + + private enum SocksStage { + case greeting + case request + } + + private struct SocksRequest { + let host: String + let port: Int + let command: UInt8 + let consumedBytes: Int + } + + let id = UUID() + + private let connection: NWConnection + private let rpcClient: WorkspaceRemoteDaemonRPCClient + private let queue: DispatchQueue + private let onClose: (UUID) -> Void + + private var isClosed = false + private var protocolKind: HandshakeProtocol = .undecided + private var socksStage: SocksStage = .greeting + private var handshakeBuffer = Data() + private var streamID: String? + private var localInputEOF = false + private var rewritesLoopbackHTTPHeaders = false + private var loopbackRequestHeaderRewriter: RemoteLoopbackHTTPRequestStreamRewriter? + private var pendingRemoteHTTPHeaderBytes = Data() + private var hasForwardedRemoteHTTPHeaders = false + + init( + connection: NWConnection, + rpcClient: WorkspaceRemoteDaemonRPCClient, + queue: DispatchQueue, + onClose: @escaping (UUID) -> Void + ) { + self.connection = connection + self.rpcClient = rpcClient + self.queue = queue + self.onClose = onClose + } + + func start() { + connection.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .failed(let error): + self.close(reason: "proxy client connection failed: \(error)") + case .cancelled: + self.close(reason: nil) + default: + break + } + } + connection.start(queue: queue) + receiveNext() + } + + func stop() { + close(reason: nil) + } + + private func receiveNext() { + guard !isClosed else { return } + connection.receive(minimumIncompleteLength: 1, maximumLength: 32768) { [weak self] data, _, isComplete, error in + guard let self, !self.isClosed else { return } + + if let data, !data.isEmpty { + if self.streamID == nil { + if self.handshakeBuffer.count + data.count > Self.maxHandshakeBytes { + self.close(reason: "proxy handshake exceeded \(Self.maxHandshakeBytes) bytes") + return + } + self.handshakeBuffer.append(data) + self.processHandshakeBuffer() + } else { + self.forwardToRemote(data, eof: isComplete) + } + } + + if isComplete { + // Treat local EOF as a half-close: keep remote read loop alive so we can + // drain upstream response bytes (for example curl closing write-side after + // sending an HTTP request through SOCKS/CONNECT). + self.localInputEOF = true + if self.streamID != nil, data?.isEmpty ?? true { + self.forwardToRemote(Data(), eof: true, allowAfterEOF: true) + } + if self.streamID == nil { + self.close(reason: nil) + } + return + } + if let error { + self.close(reason: "proxy client receive error: \(error)") + return + } + + self.receiveNext() + } + } + + private func processHandshakeBuffer() { + guard !isClosed else { return } + while streamID == nil { + switch protocolKind { + case .undecided: + guard let first = handshakeBuffer.first else { return } + protocolKind = (first == 0x05) ? .socks5 : .connect + case .socks5: + if !processSocksHandshakeStep() { + return + } + case .connect: + if !processConnectHandshakeStep() { + return + } + } + } + } + + private func processSocksHandshakeStep() -> Bool { + switch socksStage { + case .greeting: + guard handshakeBuffer.count >= 2 else { return false } + let methodCount = Int(handshakeBuffer[1]) + let total = 2 + methodCount + guard handshakeBuffer.count >= total else { return false } + + let methods = [UInt8](handshakeBuffer[2..<total]) + handshakeBuffer = Data(handshakeBuffer.dropFirst(total)) + socksStage = .request + + if !methods.contains(0x00) { + sendAndClose(Data([0x05, 0xFF])) + return false + } + sendLocal(Data([0x05, 0x00])) + return true + + case .request: + let request: SocksRequest + do { + guard let parsed = try parseSocksRequest(from: handshakeBuffer) else { return false } + request = parsed + } catch { + sendAndClose(Data([0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) + return false + } + + let pending = handshakeBuffer.count > request.consumedBytes + ? Data(handshakeBuffer[request.consumedBytes...]) + : Data() + handshakeBuffer = Data() + guard request.command == 0x01 else { + sendAndClose(Data([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])) + return false + } + + openRemoteStream( + host: request.host, + port: request.port, + successResponse: Data([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), + failureResponse: Data([0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]), + pendingPayload: pending + ) + return false + } + } + + private func parseSocksRequest(from data: Data) throws -> SocksRequest? { + let bytes = [UInt8](data) + guard bytes.count >= 4 else { return nil } + guard bytes[0] == 0x05 else { + throw NSError(domain: "cmux.remote.proxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS version"]) + } + + let command = bytes[1] + let addressType = bytes[3] + var cursor = 4 + let host: String + + switch addressType { + case 0x01: + guard bytes.count >= cursor + 4 + 2 else { return nil } + let octets = bytes[cursor..<(cursor + 4)].map { String($0) } + host = octets.joined(separator: ".") + cursor += 4 + + case 0x03: + guard bytes.count >= cursor + 1 else { return nil } + let length = Int(bytes[cursor]) + cursor += 1 + guard bytes.count >= cursor + length + 2 else { return nil } + let hostData = Data(bytes[cursor..<(cursor + length)]) + host = String(data: hostData, encoding: .utf8) ?? "" + cursor += length + + case 0x04: + guard bytes.count >= cursor + 16 + 2 else { return nil } + var address = in6_addr() + withUnsafeMutableBytes(of: &address) { target in + for i in 0..<16 { + target[i] = bytes[cursor + i] + } + } + var text = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) + let pointer = withUnsafePointer(to: &address) { + inet_ntop(AF_INET6, UnsafeRawPointer($0), &text, socklen_t(INET6_ADDRSTRLEN)) + } + host = pointer != nil ? String(cString: text) : "" + cursor += 16 + + default: + throw NSError(domain: "cmux.remote.proxy", code: 2, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS address type"]) + } + + guard !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw NSError(domain: "cmux.remote.proxy", code: 3, userInfo: [NSLocalizedDescriptionKey: "empty SOCKS host"]) + } + guard bytes.count >= cursor + 2 else { return nil } + let port = Int(UInt16(bytes[cursor]) << 8 | UInt16(bytes[cursor + 1])) + cursor += 2 + + guard port > 0 && port <= 65535 else { + throw NSError(domain: "cmux.remote.proxy", code: 4, userInfo: [NSLocalizedDescriptionKey: "invalid SOCKS port"]) + } + + return SocksRequest(host: host, port: port, command: command, consumedBytes: cursor) + } + + private func processConnectHandshakeStep() -> Bool { + let marker = Data([0x0D, 0x0A, 0x0D, 0x0A]) + guard let headerRange = handshakeBuffer.range(of: marker) else { return false } + + let headerData = Data(handshakeBuffer[..<headerRange.upperBound]) + let pending = headerRange.upperBound < handshakeBuffer.count + ? Data(handshakeBuffer[headerRange.upperBound...]) + : Data() + handshakeBuffer = Data() + guard let headerText = String(data: headerData, encoding: .utf8) else { + sendAndClose(Self.httpResponse(status: "400 Bad Request")) + return false + } + + let firstLine = headerText.components(separatedBy: "\r\n").first ?? "" + let parts = firstLine.split(whereSeparator: \.isWhitespace).map(String.init) + guard parts.count >= 2, parts[0].uppercased() == "CONNECT" else { + sendAndClose(Self.httpResponse(status: "400 Bad Request")) + return false + } + + guard let (host, port) = Self.parseConnectAuthority(parts[1]) else { + sendAndClose(Self.httpResponse(status: "400 Bad Request")) + return false + } + + openRemoteStream( + host: host, + port: port, + successResponse: Self.httpResponse(status: "200 Connection Established", closeAfterResponse: false), + failureResponse: Self.httpResponse(status: "502 Bad Gateway", closeAfterResponse: true), + pendingPayload: pending + ) + return false + } + + private func openRemoteStream( + host: String, + port: Int, + successResponse: Data, + failureResponse: Data, + pendingPayload: Data + ) { + guard !isClosed else { return } + do { + rewritesLoopbackHTTPHeaders = + BrowserInsecureHTTPSettings.normalizeHost(host) + == BrowserInsecureHTTPSettings.normalizeHost(Self.remoteLoopbackProxyAliasHost) + loopbackRequestHeaderRewriter = rewritesLoopbackHTTPHeaders + ? RemoteLoopbackHTTPRequestStreamRewriter(aliasHost: Self.remoteLoopbackProxyAliasHost) + : nil + pendingRemoteHTTPHeaderBytes = Data() + hasForwardedRemoteHTTPHeaders = false + let targetHost = Self.normalizedProxyTargetHost(host) + let streamID = try rpcClient.openStream(host: targetHost, port: port) + self.streamID = streamID + try rpcClient.attachStream(streamID: streamID, queue: queue) { [weak self] event in + self?.handleRemoteStreamEvent(streamID: streamID, event: event) + } + connection.send(content: successResponse, completion: .contentProcessed { [weak self] error in + guard let self else { return } + if let error { + self.close(reason: "proxy client send error: \(error)") + return + } + if !pendingPayload.isEmpty { + self.forwardToRemote(pendingPayload, allowAfterEOF: true) + } + }) + } catch { + sendAndClose(failureResponse) + } + } + + private func forwardToRemote(_ data: Data, eof: Bool = false, allowAfterEOF: Bool = false) { + guard !isClosed else { return } + guard !localInputEOF || allowAfterEOF else { return } + guard let streamID else { return } + do { + let outgoingData: Data + if rewritesLoopbackHTTPHeaders { + outgoingData = loopbackRequestHeaderRewriter?.rewriteNextChunk(data, eof: eof) ?? data + } else { + outgoingData = data + } + guard !outgoingData.isEmpty else { return } + try rpcClient.writeStream(streamID: streamID, data: outgoingData) + } catch { + close(reason: "proxy.write failed: \(error.localizedDescription)") + } + } + + private func handleRemoteStreamEvent( + streamID: String, + event: WorkspaceRemoteDaemonRPCClient.StreamEvent + ) { + guard !isClosed else { return } + guard self.streamID == streamID else { return } + + switch event { + case .data(let data): + forwardRemotePayloadToLocal(data, eof: false) + + case .eof(let data): + forwardRemotePayloadToLocal(data, eof: true) + + case .error(let detail): + close(reason: "proxy.stream failed: \(detail)") + } + } + + private func forwardRemotePayloadToLocal(_ data: Data, eof: Bool) { + let localData = rewriteRemoteResponseIfNeeded(data, eof: eof) + if !localData.isEmpty { + connection.send(content: localData, completion: .contentProcessed { [weak self] error in + guard let self else { return } + if let error { + self.close(reason: "proxy client send error: \(error)") + return + } + if eof { + self.close(reason: nil) + } + }) + return + } + + if eof { + close(reason: nil) + } + } + + private func rewriteRemoteResponseIfNeeded(_ data: Data, eof: Bool) -> Data { + guard rewritesLoopbackHTTPHeaders else { return data } + guard !data.isEmpty else { return data } + guard !hasForwardedRemoteHTTPHeaders else { return data } + + pendingRemoteHTTPHeaderBytes.append(data) + let marker = Data([0x0D, 0x0A, 0x0D, 0x0A]) + guard pendingRemoteHTTPHeaderBytes.range(of: marker) != nil else { + guard eof else { return Data() } + hasForwardedRemoteHTTPHeaders = true + let payload = pendingRemoteHTTPHeaderBytes + pendingRemoteHTTPHeaderBytes = Data() + return payload + } + + hasForwardedRemoteHTTPHeaders = true + let payload = pendingRemoteHTTPHeaderBytes + pendingRemoteHTTPHeaderBytes = Data() + return RemoteLoopbackHTTPResponseRewriter.rewriteIfNeeded( + data: payload, + aliasHost: Self.remoteLoopbackProxyAliasHost + ) + } + + private func close(reason: String?) { + guard !isClosed else { return } + isClosed = true + + let streamID = self.streamID + self.streamID = nil + + if let streamID { + rpcClient.closeStream(streamID: streamID) + } + connection.cancel() + onClose(id) + } + + private func sendLocal(_ data: Data) { + guard !isClosed else { return } + connection.send(content: data, completion: .contentProcessed { [weak self] error in + guard let self else { return } + if let error { + self.close(reason: "proxy client send error: \(error)") + } + }) + } + + private func sendAndClose(_ data: Data) { + guard !isClosed else { return } + connection.send(content: data, completion: .contentProcessed { [weak self] _ in + self?.close(reason: nil) + }) + } + + private static func parseConnectAuthority(_ authority: String) -> (host: String, port: Int)? { + let trimmed = authority.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if trimmed.hasPrefix("[") { + guard let closing = trimmed.firstIndex(of: "]") else { return nil } + let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<closing]) + let portStart = trimmed.index(after: closing) + guard portStart < trimmed.endIndex, trimmed[portStart] == ":" else { return nil } + let portString = String(trimmed[trimmed.index(after: portStart)...]) + guard let port = Int(portString), port > 0, port <= 65535 else { return nil } + return (host, port) + } + + guard let colon = trimmed.lastIndex(of: ":") else { return nil } + let host = String(trimmed[..<colon]) + let portString = String(trimmed[trimmed.index(after: colon)...]) + guard !host.isEmpty else { return nil } + guard let port = Int(portString), port > 0, port <= 65535 else { return nil } + return (host, port) + } + + private static func normalizedProxyTargetHost(_ host: String) -> String { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = trimmed + .trimmingCharacters(in: CharacterSet(charactersIn: ".")) + .lowercased() + // BrowserPanel rewrites loopback URLs to this alias so proxy routing works. + // Resolve it back to true loopback before dialing from the remote daemon. + if normalized == remoteLoopbackProxyAliasHost { + return "127.0.0.1" + } + return host + } + + private static func httpResponse(status: String, closeAfterResponse: Bool = true) -> Data { + var text = "HTTP/1.1 \(status)\r\nProxy-Agent: cmux\r\n" + if closeAfterResponse { + text += "Connection: close\r\n" + } + text += "\r\n" + return Data(text.utf8) + } + } + + private let configuration: WorkspaceRemoteConfiguration + private let remotePath: String + private let localPort: Int + private let onFatalError: (String) -> Void + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.daemon-tunnel.\(UUID().uuidString)", qos: .utility) + + private var listener: NWListener? + private var rpcClient: WorkspaceRemoteDaemonRPCClient? + private var sessions: [UUID: ProxySession] = [:] + private var isStopped = false + + init( + configuration: WorkspaceRemoteConfiguration, + remotePath: String, + localPort: Int, + onFatalError: @escaping (String) -> Void + ) { + self.configuration = configuration + self.remotePath = remotePath + self.localPort = localPort + self.onFatalError = onFatalError + } + + func start() throws { + var capturedError: Error? + queue.sync { + guard !isStopped else { + capturedError = NSError(domain: "cmux.remote.proxy", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "proxy tunnel already stopped", + ]) + return + } + do { + let client = WorkspaceRemoteDaemonRPCClient( + configuration: configuration, + remotePath: remotePath + ) { [weak self] detail in + self?.queue.async { + self?.failLocked("Remote daemon transport failed: \(detail)") + } + } + try client.start() + + let listener = try Self.makeLoopbackListener(port: localPort) + listener.newConnectionHandler = { [weak self] connection in + self?.queue.async { + self?.acceptConnectionLocked(connection) + } + } + listener.stateUpdateHandler = { [weak self] state in + self?.queue.async { + self?.handleListenerStateLocked(state) + } + } + + self.rpcClient = client + self.listener = listener + listener.start(queue: queue) + } catch { + capturedError = error + stopLocked(notify: false) + } + } + if let capturedError { + throw capturedError + } + } + + func stop() { + queue.sync { + stopLocked(notify: false) + } + } + + private func handleListenerStateLocked(_ state: NWListener.State) { + guard !isStopped else { return } + switch state { + case .failed(let error): + failLocked("Local proxy listener failed: \(error)") + default: + break + } + } + + private func acceptConnectionLocked(_ connection: NWConnection) { + guard !isStopped else { + connection.cancel() + return + } + guard let rpcClient else { + connection.cancel() + return + } + + let session = ProxySession( + connection: connection, + rpcClient: rpcClient, + queue: queue + ) { [weak self] id in + self?.queue.async { + self?.sessions.removeValue(forKey: id) + } + } + sessions[session.id] = session + session.start() + } + + private func failLocked(_ detail: String) { + guard !isStopped else { return } + stopLocked(notify: false) + onFatalError(detail) + } + + private func stopLocked(notify: Bool) { + guard !isStopped else { return } + isStopped = true + + listener?.stateUpdateHandler = nil + listener?.newConnectionHandler = nil + listener?.cancel() + listener = nil + + let activeSessions = sessions.values + sessions.removeAll() + for session in activeSessions { + session.stop() + } + + rpcClient?.stop() + rpcClient = nil + } + + private static func makeLoopbackListener(port: Int) throws -> NWListener { + guard let localPort = NWEndpoint.Port(rawValue: UInt16(port)) else { + throw NSError(domain: "cmux.remote.proxy", code: 21, userInfo: [ + NSLocalizedDescriptionKey: "invalid local proxy port \(port)", + ]) + } + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.noDelay = true + let parameters = NWParameters(tls: nil, tcp: tcpOptions) + parameters.allowLocalEndpointReuse = true + parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: localPort) + return try NWListener(using: parameters) + } +} + +private final class WorkspaceRemoteProxyBroker { + enum Update { + case connecting + case ready(BrowserProxyEndpoint) + case error(String) + } + + final class Lease { + private let key: String + private let subscriberID: UUID + private weak var broker: WorkspaceRemoteProxyBroker? + private var isReleased = false + + fileprivate init(key: String, subscriberID: UUID, broker: WorkspaceRemoteProxyBroker) { + self.key = key + self.subscriberID = subscriberID + self.broker = broker + } + + func release() { + guard !isReleased else { return } + isReleased = true + broker?.release(key: key, subscriberID: subscriberID) + } + + deinit { + release() + } + } + + private final class Entry { + let configuration: WorkspaceRemoteConfiguration + var remotePath: String + var tunnel: WorkspaceRemoteDaemonProxyTunnel? + var endpoint: BrowserProxyEndpoint? + var restartWorkItem: DispatchWorkItem? + var subscribers: [UUID: (Update) -> Void] = [:] + + init(configuration: WorkspaceRemoteConfiguration, remotePath: String) { + self.configuration = configuration + self.remotePath = remotePath + } + } + + static let shared = WorkspaceRemoteProxyBroker() + + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.proxy-broker", qos: .utility) + private var entries: [String: Entry] = [:] + + func acquire( + configuration: WorkspaceRemoteConfiguration, + remotePath: String, + onUpdate: @escaping (Update) -> Void + ) -> Lease { + queue.sync { + let key = Self.transportKey(for: configuration) + let subscriberID = UUID() + let entry: Entry + if let existing = entries[key] { + entry = existing + if existing.remotePath != remotePath { + existing.remotePath = remotePath + if existing.tunnel != nil { + stopEntryRuntimeLocked(existing) + notifyLocked(existing, update: .connecting) + } + } + } else { + entry = Entry(configuration: configuration, remotePath: remotePath) + entries[key] = entry + } + + entry.subscribers[subscriberID] = onUpdate + if let endpoint = entry.endpoint { + onUpdate(.ready(endpoint)) + } else { + onUpdate(.connecting) + } + + if entry.tunnel == nil, entry.restartWorkItem == nil { + startEntryLocked(key: key, entry: entry) + } + + return Lease(key: key, subscriberID: subscriberID, broker: self) + } + } + + private func release(key: String, subscriberID: UUID) { + queue.async { [weak self] in + guard let self, let entry = self.entries[key] else { return } + entry.subscribers.removeValue(forKey: subscriberID) + guard entry.subscribers.isEmpty else { return } + self.teardownEntryLocked(key: key, entry: entry) + } + } + + private func startEntryLocked(key: String, entry: Entry) { + entry.restartWorkItem?.cancel() + entry.restartWorkItem = nil + + let localPort: Int + if let forcedLocalPort = entry.configuration.localProxyPort { + // Internal deterministic test hook used by docker regressions to force bind conflicts. + localPort = forcedLocalPort + } else { + guard let allocatedPort = Self.allocateLoopbackPort() else { + notifyLocked( + entry, + update: .error("Failed to allocate local proxy port\(Self.retrySuffix(delay: 3.0))") + ) + scheduleRestartLocked(key: key, entry: entry, delay: 3.0) + return + } + localPort = allocatedPort + } + + do { + let tunnel = WorkspaceRemoteDaemonProxyTunnel( + configuration: entry.configuration, + remotePath: entry.remotePath, + localPort: localPort + ) { [weak self] detail in + self?.queue.async { + self?.handleTunnelFailureLocked(key: key, detail: detail) + } + } + try tunnel.start() + entry.tunnel = tunnel + let endpoint = BrowserProxyEndpoint(host: "127.0.0.1", port: localPort) + entry.endpoint = endpoint + notifyLocked(entry, update: .ready(endpoint)) + } catch { + stopEntryRuntimeLocked(entry) + let detail = "Failed to start local daemon proxy: \(error.localizedDescription)" + notifyLocked(entry, update: .error("\(detail)\(Self.retrySuffix(delay: 3.0))")) + scheduleRestartLocked(key: key, entry: entry, delay: 3.0) + } + } + + private func handleTunnelFailureLocked(key: String, detail: String) { + guard let entry = entries[key], entry.tunnel != nil else { return } + stopEntryRuntimeLocked(entry) + notifyLocked(entry, update: .error("\(detail)\(Self.retrySuffix(delay: 3.0))")) + scheduleRestartLocked(key: key, entry: entry, delay: 3.0) + } + + private func scheduleRestartLocked(key: String, entry: Entry, delay: TimeInterval) { + guard !entry.subscribers.isEmpty else { + teardownEntryLocked(key: key, entry: entry) + return + } + guard entry.restartWorkItem == nil else { return } + + let workItem = DispatchWorkItem { [weak self] in + guard let self, let currentEntry = self.entries[key] else { return } + currentEntry.restartWorkItem = nil + guard !currentEntry.subscribers.isEmpty else { + self.teardownEntryLocked(key: key, entry: currentEntry) + return + } + self.notifyLocked(currentEntry, update: .connecting) + self.startEntryLocked(key: key, entry: currentEntry) + } + + entry.restartWorkItem = workItem + queue.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + private func teardownEntryLocked(key: String, entry: Entry) { + entry.restartWorkItem?.cancel() + entry.restartWorkItem = nil + stopEntryRuntimeLocked(entry) + entries.removeValue(forKey: key) + } + + private func stopEntryRuntimeLocked(_ entry: Entry) { + entry.tunnel?.stop() + entry.tunnel = nil + entry.endpoint = nil + } + + private func notifyLocked(_ entry: Entry, update: Update) { + for callback in entry.subscribers.values { + callback(update) + } + } + + private static func transportKey(for configuration: WorkspaceRemoteConfiguration) -> String { + configuration.proxyBrokerTransportKey + } + + private static func allocateLoopbackPort() -> Int? { + for _ in 0..<8 { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { return nil } + defer { close(fd) } + + var yes: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &yes, socklen_t(MemoryLayout<Int32>.size)) + + var addr = sockaddr_in() + addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size) + addr.sin_family = sa_family_t(AF_INET) + addr.sin_port = in_port_t(0) + addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + bind(fd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_in>.size)) + } + } + guard bindResult == 0 else { continue } + + var bound = sockaddr_in() + var len = socklen_t(MemoryLayout<sockaddr_in>.size) + let nameResult = withUnsafeMutablePointer(to: &bound) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + getsockname(fd, sockaddrPtr, &len) + } + } + guard nameResult == 0 else { continue } + + let port = Int(UInt16(bigEndian: bound.sin_port)) + if port > 0 && port <= 65535 { + return port + } + } + return nil + } + + private static func retrySuffix(delay: TimeInterval) -> String { + let seconds = max(1, Int(delay.rounded())) + return " (retry in \(seconds)s)" + } +} + +private final class WorkspaceRemoteCLIRelayServer { + private final class Session { + private enum Phase { + case awaitingAuth + case awaitingCommand + case forwarding + case closed + } + + private let connection: NWConnection + private let localSocketPath: String + private let relayID: String + private let relayToken: Data + private let queue: DispatchQueue + private let onClose: () -> Void + private let challengeProtocol = "cmux-relay-auth" + private let challengeVersion = 1 + private let minimumFailureDelay: TimeInterval = 0.05 + private let maximumFrameBytes = 16 * 1024 + + private var buffer = Data() + private var phase: Phase = .awaitingAuth + private var challengeNonce = "" + private var challengeSentAt = Date() + private var isClosed = false + + init( + connection: NWConnection, + localSocketPath: String, + relayID: String, + relayToken: Data, + queue: DispatchQueue, + onClose: @escaping () -> Void + ) { + self.connection = connection + self.localSocketPath = localSocketPath + self.relayID = relayID + self.relayToken = relayToken + self.queue = queue + self.onClose = onClose + } + + func start() { + connection.stateUpdateHandler = { [weak self] state in + self?.queue.async { + self?.handleState(state) + } + } + connection.start(queue: queue) + } + + func stop() { + close() + } + + private func handleState(_ state: NWConnection.State) { + guard !isClosed else { return } + switch state { + case .ready: + sendChallenge() + receive() + case .failed, .cancelled: + close() + default: + break + } + } + + private func sendChallenge() { + challengeSentAt = Date() + challengeNonce = Self.randomHex(byteCount: 16) + let challenge: [String: Any] = [ + "protocol": challengeProtocol, + "version": challengeVersion, + "relay_id": relayID, + "nonce": challengeNonce, + ] + sendJSONLine(challenge) { _ in } + } + + private func receive() { + guard !isClosed else { return } + connection.receive(minimumIncompleteLength: 1, maximumLength: maximumFrameBytes) { [weak self] data, _, isComplete, error in + guard let self else { return } + self.queue.async { + if error != nil { + self.close() + return + } + if let data, !data.isEmpty { + self.buffer.append(data) + if self.buffer.count > self.maximumFrameBytes { + self.sendFailureAndClose() + return + } + self.processBufferedLines() + } + if isComplete { + self.close() + return + } + if !self.isClosed { + self.receive() + } + } + } + } + + private func processBufferedLines() { + while let newlineIndex = buffer.firstIndex(of: 0x0A), !isClosed { + let lineData = buffer.prefix(upTo: newlineIndex) + buffer.removeSubrange(...newlineIndex) + let line = String(data: lineData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + switch phase { + case .awaitingAuth: + handleAuthLine(line) + case .awaitingCommand: + handleCommandLine(Data(lineData) + Data([0x0A])) + case .forwarding, .closed: + return + } + } + } + + private func handleAuthLine(_ line: String) { + guard let data = line.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let receivedRelayID = object["relay_id"] as? String, + receivedRelayID == relayID, + let macHex = object["mac"] as? String, + let receivedMAC = Self.hexData(from: macHex) + else { + sendFailureAndClose() + return + } + + let message = Self.authMessage(relayID: relayID, nonce: challengeNonce, version: challengeVersion) + let expectedMAC = Self.authMAC(token: relayToken, message: message) + guard Self.constantTimeEqual(receivedMAC, expectedMAC) else { + sendFailureAndClose() + return + } + + phase = .awaitingCommand + sendJSONLine(["ok": true]) { [weak self] _ in + self?.queue.async { + self?.processBufferedLines() + } + } + } + + private func handleCommandLine(_ commandLine: Data) { + guard !commandLine.isEmpty else { + sendFailureAndClose() + return + } + phase = .forwarding + DispatchQueue.global(qos: .utility).async { [localSocketPath, commandLine, queue] in + let result = Result { try Self.roundTripUnixSocket(socketPath: localSocketPath, request: commandLine) } + queue.async { [weak self] in + guard let self else { return } + switch result { + case .success(let response): + self.connection.send(content: response, completion: .contentProcessed { [weak self] _ in + self?.queue.async { + self?.close() + } + }) + case .failure: + self.sendFailureAndClose() + } + } + } + } + + private func sendFailureAndClose() { + let elapsed = Date().timeIntervalSince(challengeSentAt) + let delay = max(0, minimumFailureDelay - elapsed) + phase = .closed + queue.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.sendJSONLine(["ok": false]) { [weak self] _ in + self?.queue.async { + self?.close() + } + } + } + } + + private func sendJSONLine(_ object: [String: Any], completion: @escaping (NWError?) -> Void) { + guard !isClosed else { + completion(nil) + return + } + guard let payload = try? JSONSerialization.data(withJSONObject: object) else { + completion(nil) + return + } + connection.send(content: payload + Data([0x0A]), completion: .contentProcessed(completion)) + } + + private func close() { + guard !isClosed else { return } + isClosed = true + phase = .closed + connection.stateUpdateHandler = nil + connection.cancel() + onClose() + } + + private static func authMessage(relayID: String, nonce: String, version: Int) -> Data { + Data("relay_id=\(relayID)\nnonce=\(nonce)\nversion=\(version)".utf8) + } + + private static func authMAC(token: Data, message: Data) -> Data { + let key = SymmetricKey(data: token) + let code = HMAC<SHA256>.authenticationCode(for: message, using: key) + return Data(code) + } + + private static func constantTimeEqual(_ lhs: Data, _ rhs: Data) -> Bool { + guard lhs.count == rhs.count else { return false } + var diff: UInt8 = 0 + for index in lhs.indices { + diff |= lhs[index] ^ rhs[index] + } + return diff == 0 + } + + fileprivate static func hexData(from string: String) -> Data? { + let normalized = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalized.count.isMultiple(of: 2), !normalized.isEmpty else { return nil } + var data = Data(capacity: normalized.count / 2) + var cursor = normalized.startIndex + while cursor < normalized.endIndex { + let next = normalized.index(cursor, offsetBy: 2) + guard let byte = UInt8(normalized[cursor..<next], radix: 16) else { return nil } + data.append(byte) + cursor = next + } + return data + } + + private static func randomHex(byteCount: Int) -> String { + var bytes = [UInt8](repeating: 0, count: byteCount) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return bytes.map { String(format: "%02x", $0) }.joined() + } + + private static func roundTripUnixSocket(socketPath: String, request: Data) throws -> Data { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError(domain: "cmux.remote.relay", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "failed to create local relay socket", + ]) + } + defer { Darwin.close(fd) } + + var timeout = timeval(tv_sec: 15, tv_usec: 0) + withUnsafePointer(to: &timeout) { pointer in + _ = setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, pointer, socklen_t(MemoryLayout<timeval>.size)) + _ = setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, pointer, socklen_t(MemoryLayout<timeval>.size)) + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketPath.utf8CString) + guard pathBytes.count <= MemoryLayout.size(ofValue: address.sun_path) else { + throw NSError(domain: "cmux.remote.relay", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "local relay socket path is too long", + ]) + } + let sunPathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0 + withUnsafeMutableBytes(of: &address) { rawBuffer in + let destination = rawBuffer.baseAddress!.advanced(by: sunPathOffset) + pathBytes.withUnsafeBytes { pathBuffer in + destination.copyMemory(from: pathBuffer.baseAddress!, byteCount: pathBytes.count) + } + } + + let addressLength = socklen_t(MemoryLayout.size(ofValue: address.sun_family) + pathBytes.count) + let connectResult = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, addressLength) + } + } + guard connectResult == 0 else { + throw NSError(domain: "cmux.remote.relay", code: 3, userInfo: [ + NSLocalizedDescriptionKey: "failed to connect to local cmux socket", + ]) + } + + try request.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } + var bytesRemaining = rawBuffer.count + var pointer = baseAddress + while bytesRemaining > 0 { + let written = Darwin.write(fd, pointer, bytesRemaining) + if written <= 0 { + throw NSError(domain: "cmux.remote.relay", code: 4, userInfo: [ + NSLocalizedDescriptionKey: "failed to write relay request", + ]) + } + bytesRemaining -= written + pointer = pointer.advanced(by: written) + } + } + _ = shutdown(fd, SHUT_WR) + + var response = Data() + var scratch = [UInt8](repeating: 0, count: 4096) + while true { + let count = Darwin.read(fd, &scratch, scratch.count) + if count > 0 { + response.append(scratch, count: count) + continue + } + if count == 0 { + break + } + + if errno == EAGAIN || errno == EWOULDBLOCK { + if !response.isEmpty { + break + } + throw NSError(domain: "cmux.remote.relay", code: 5, userInfo: [ + NSLocalizedDescriptionKey: "timed out waiting for local cmux response", + ]) + } + throw NSError(domain: "cmux.remote.relay", code: 6, userInfo: [ + NSLocalizedDescriptionKey: "failed to read local cmux response", + ]) + } + return response + } + } + + private let localSocketPath: String + private let relayID: String + private let relayToken: Data + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.cli-relay.\(UUID().uuidString)", qos: .utility) + + private var listener: NWListener? + private var sessions: [UUID: Session] = [:] + private var isStopped = false + private(set) var localPort: Int? + + init(localSocketPath: String, relayID: String, relayTokenHex: String) throws { + guard let relayToken = Session.hexData(from: relayTokenHex), !relayToken.isEmpty else { + throw NSError(domain: "cmux.remote.relay", code: 7, userInfo: [ + NSLocalizedDescriptionKey: "invalid relay token", + ]) + } + self.localSocketPath = localSocketPath + self.relayID = relayID + self.relayToken = relayToken + } + + func start() throws -> Int { + if let existingPort = queue.sync(execute: { localPort }) { + return existingPort + } + + let listener = try Self.makeLoopbackListener() + let readySemaphore = DispatchSemaphore(value: 0) + let stateLock = NSLock() + var capturedError: Error? + var boundPort: Int? + + listener.newConnectionHandler = { [weak self] connection in + self?.queue.async { + self?.acceptConnectionLocked(connection) + } + } + listener.stateUpdateHandler = { listenerState in + switch listenerState { + case .ready: + stateLock.lock() + boundPort = listener.port.map { Int($0.rawValue) } + stateLock.unlock() + readySemaphore.signal() + case .failed(let error): + stateLock.lock() + capturedError = error + stateLock.unlock() + readySemaphore.signal() + default: + break + } + } + listener.start(queue: queue) + + let waitResult = readySemaphore.wait(timeout: .now() + 5.0) + stateLock.lock() + let startupError = capturedError + let startupPort = boundPort + stateLock.unlock() + + if waitResult != .success { + listener.newConnectionHandler = nil + listener.stateUpdateHandler = nil + listener.cancel() + throw NSError(domain: "cmux.remote.relay", code: 8, userInfo: [ + NSLocalizedDescriptionKey: "timed out waiting for local relay listener", + ]) + } + if let startupError { + listener.newConnectionHandler = nil + listener.stateUpdateHandler = nil + listener.cancel() + throw startupError + } + guard let startupPort, startupPort > 0 else { + listener.newConnectionHandler = nil + listener.stateUpdateHandler = nil + listener.cancel() + throw NSError(domain: "cmux.remote.relay", code: 8, userInfo: [ + NSLocalizedDescriptionKey: "failed to bind local relay listener", + ]) + } + + return queue.sync { + if let localPort { + listener.newConnectionHandler = nil + listener.stateUpdateHandler = nil + listener.cancel() + return localPort + } + self.listener = listener + self.localPort = startupPort + return startupPort + } + } + + func stop() { + queue.sync { + guard !isStopped else { return } + isStopped = true + listener?.newConnectionHandler = nil + listener?.stateUpdateHandler = nil + listener?.cancel() + listener = nil + localPort = nil + let activeSessions = sessions.values + sessions.removeAll() + for session in activeSessions { + session.stop() + } + } + } + + private func acceptConnectionLocked(_ connection: NWConnection) { + guard !isStopped else { + connection.cancel() + return + } + let sessionID = UUID() + let session = Session( + connection: connection, + localSocketPath: localSocketPath, + relayID: relayID, + relayToken: relayToken, + queue: queue + ) { [weak self] in + self?.sessions.removeValue(forKey: sessionID) + } + sessions[sessionID] = session + session.start() + } + + private static func makeLoopbackListener() throws -> NWListener { + let tcpOptions = NWProtocolTCP.Options() + tcpOptions.noDelay = true + let parameters = NWParameters(tls: nil, tcp: tcpOptions) + parameters.allowLocalEndpointReuse = true + parameters.requiredLocalEndpoint = .hostPort(host: NWEndpoint.Host("127.0.0.1"), port: .any) + return try NWListener(using: parameters) + } +} + +final class WorkspaceRemoteSessionController { + private struct CommandResult { + let status: Int32 + let stdout: String + let stderr: String + } + + private struct RemotePlatform { + let goOS: String + let goArch: String + } + + private struct RemoteBootstrapState { + let platform: RemotePlatform + let binaryExists: Bool + } + + private struct DaemonHello { + let name: String + let version: String + let capabilities: [String] + let remotePath: String + } + + private let queue = DispatchQueue(label: "com.cmux.remote-ssh.\(UUID().uuidString)", qos: .utility) + private let queueKey = DispatchSpecificKey<Void>() + private weak var workspace: Workspace? + private let configuration: WorkspaceRemoteConfiguration + private let controllerID: UUID + + private var isStopping = false + private var proxyLease: WorkspaceRemoteProxyBroker.Lease? + private var proxyEndpoint: BrowserProxyEndpoint? + private var daemonReady = false + private var daemonBootstrapVersion: String? + private var daemonRemotePath: String? + private var reverseRelayProcess: Process? + private var cliRelayServer: WorkspaceRemoteCLIRelayServer? + private var reverseRelayStderrPipe: Pipe? + private var reverseRelayRestartWorkItem: DispatchWorkItem? + private var reverseRelayStderrBuffer = "" + private var reconnectRetryCount = 0 + private var reconnectWorkItem: DispatchWorkItem? + private var heartbeatCount: Int = 0 + private var connectionAttemptStartedAt: Date? + + private static let reverseRelayStartupGracePeriod: TimeInterval = 0.5 + + init(workspace: Workspace, configuration: WorkspaceRemoteConfiguration, controllerID: UUID) { + self.workspace = workspace + self.configuration = configuration + self.controllerID = controllerID + queue.setSpecific(key: queueKey, value: ()) + } + + func start() { + debugLog("remote.session.start \(debugConfigSummary())") + queue.async { [weak self] in + guard let self else { return } + guard !self.isStopping else { return } + self.beginConnectionAttemptLocked() + } + } + + func stop() { + if DispatchQueue.getSpecific(key: queueKey) != nil { + stopAllLocked() + return + } + queue.async { [self] in + stopAllLocked() + } + } + + private func stopAllLocked() { + debugLog("remote.session.stop \(debugConfigSummary())") + isStopping = true + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + reconnectRetryCount = 0 + reverseRelayRestartWorkItem?.cancel() + reverseRelayRestartWorkItem = nil + stopReverseRelayLocked() + + proxyLease?.release() + proxyLease = nil + proxyEndpoint = nil + daemonReady = false + daemonBootstrapVersion = nil + daemonRemotePath = nil + publishProxyEndpoint(nil) + publishPortsSnapshotLocked() + } + + private func beginConnectionAttemptLocked() { + guard !isStopping else { return } + + connectionAttemptStartedAt = Date() + debugLog("remote.session.connect.begin retry=\(reconnectRetryCount) \(debugConfigSummary())") + reconnectWorkItem = nil + let connectDetail: String + let bootstrapDetail: String + if reconnectRetryCount > 0 { + connectDetail = "Reconnecting to \(configuration.displayTarget) (retry \(reconnectRetryCount))" + bootstrapDetail = "Bootstrapping remote daemon on \(configuration.displayTarget) (retry \(reconnectRetryCount))" + } else { + connectDetail = "Connecting to \(configuration.displayTarget)" + bootstrapDetail = "Bootstrapping remote daemon on \(configuration.displayTarget)" + } + publishState(.connecting, detail: connectDetail) + publishDaemonStatus(.bootstrapping, detail: bootstrapDetail) + do { + let hello = try bootstrapDaemonLocked() + guard hello.capabilities.contains(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability) else { + throw NSError(domain: "cmux.remote.daemon", code: 43, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon missing required capability \(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability)", + ]) + } + daemonReady = true + daemonBootstrapVersion = hello.version + daemonRemotePath = hello.remotePath + publishDaemonStatus( + .ready, + detail: "Remote daemon ready", + version: hello.version, + name: hello.name, + capabilities: hello.capabilities, + remotePath: hello.remotePath + ) + recordHeartbeatActivityLocked() + startReverseRelayLocked(remotePath: hello.remotePath) + startProxyLocked() + } catch { + daemonReady = false + daemonBootstrapVersion = nil + daemonRemotePath = nil + let nextRetry = scheduleReconnectLocked(delay: 4.0) + let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 4.0) + let detail = "Remote daemon bootstrap failed: \(error.localizedDescription)\(retrySuffix)" + publishDaemonStatus(.error, detail: detail) + publishState(.error, detail: detail) + } + } + + private func startProxyLocked() { + guard !isStopping else { return } + guard daemonReady else { return } + guard proxyLease == nil else { return } + guard let remotePath = daemonRemotePath, + !remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + let nextRetry = scheduleReconnectLocked(delay: 4.0) + let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 4.0) + let detail = "Remote daemon did not provide a valid remote path\(retrySuffix)" + publishDaemonStatus(.error, detail: detail) + publishState(.error, detail: detail) + return + } + + let lease = WorkspaceRemoteProxyBroker.shared.acquire( + configuration: configuration, + remotePath: remotePath + ) { [weak self] update in + self?.queue.async { + self?.handleProxyBrokerUpdateLocked(update) + } + } + proxyLease = lease + } + + private func startReverseRelayLocked(remotePath: String) { + guard !isStopping else { return } + guard daemonReady else { return } + guard let relayPort = configuration.relayPort, relayPort > 0, + let relayID = configuration.relayID?.trimmingCharacters(in: .whitespacesAndNewlines), + !relayID.isEmpty, + let relayToken = configuration.relayToken?.trimmingCharacters(in: .whitespacesAndNewlines), + !relayToken.isEmpty, + let localSocketPath = configuration.localSocketPath? + .trimmingCharacters(in: .whitespacesAndNewlines), + !localSocketPath.isEmpty else { + return + } + guard reverseRelayProcess == nil else { return } + + reverseRelayRestartWorkItem?.cancel() + reverseRelayRestartWorkItem = nil + var relayServer: WorkspaceRemoteCLIRelayServer? + do { + let server = try ensureCLIRelayServerLocked( + localSocketPath: localSocketPath, + relayID: relayID, + relayToken: relayToken + ) + relayServer = server + let localRelayPort = try server.start() + Self.killOrphanedRelayProcesses(relayPort: relayPort, destination: configuration.destination) + + let process = Process() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh") + process.arguments = reverseRelayArguments(relayPort: relayPort, localRelayPort: localRelayPort) + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = stderrPipe + + process.terminationHandler = { [weak self] terminated in + self?.queue.async { + self?.handleReverseRelayTerminationLocked(process: terminated) + } + } + + try process.run() + if let startupFailure = Self.reverseRelayStartupFailureDetail( + process: process, + stderrPipe: stderrPipe + ) { + let retryDelay = 2.0 + let retrySeconds = max(1, Int(retryDelay.rounded())) + debugLog( + "remote.relay.startFailed relayPort=\(relayPort) " + + "error=\(startupFailure)" + ) + relayServer?.stop() + publishDaemonStatus( + .error, + detail: "Remote SSH relay unavailable: \(startupFailure) (retry in \(retrySeconds)s)" + ) + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: retryDelay) + return + } + installReverseRelayStderrHandlerLocked(stderrPipe) + reverseRelayProcess = process + cliRelayServer = relayServer + reverseRelayStderrPipe = stderrPipe + reverseRelayStderrBuffer = "" + do { + try installRemoteRelayMetadataLocked( + remotePath: remotePath, + relayPort: relayPort, + relayID: relayID, + relayToken: relayToken + ) + } catch { + debugLog("remote.relay.metadata.error \(error.localizedDescription)") + stopReverseRelayLocked() + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + return + } + recordHeartbeatActivityLocked() + debugLog( + "remote.relay.start relayPort=\(relayPort) localRelayPort=\(localRelayPort) " + + "target=\(configuration.displayTarget)" + ) + } catch { + debugLog( + "remote.relay.startFailed relayPort=\(relayPort) " + + "error=\(error.localizedDescription)" + ) + relayServer?.stop() + cliRelayServer = nil + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + } + } + + private func installReverseRelayStderrHandlerLocked(_ stderrPipe: Pipe) { + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { + handle.readabilityHandler = nil + return + } + self?.queue.async { + guard let self else { return } + if let chunk = String(data: data, encoding: .utf8), !chunk.isEmpty { + self.reverseRelayStderrBuffer.append(chunk) + if self.reverseRelayStderrBuffer.count > 8192 { + self.reverseRelayStderrBuffer.removeFirst(self.reverseRelayStderrBuffer.count - 8192) + } + } + } + } + } + + private func handleReverseRelayTerminationLocked(process: Process) { + guard reverseRelayProcess === process else { return } + let stderrDetail = Self.bestErrorLine(stderr: reverseRelayStderrBuffer) + reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil + reverseRelayProcess = nil + reverseRelayStderrPipe = nil + + guard !isStopping else { return } + guard let remotePath = daemonRemotePath, + !remotePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + + let detail = stderrDetail ?? "status=\(process.terminationStatus)" + debugLog("remote.relay.exit \(detail)") + scheduleReverseRelayRestartLocked(remotePath: remotePath, delay: 2.0) + } + + private func scheduleReverseRelayRestartLocked(remotePath: String, delay: TimeInterval) { + guard !isStopping else { return } + reverseRelayRestartWorkItem?.cancel() + + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.reverseRelayRestartWorkItem = nil + guard !self.isStopping else { return } + guard self.reverseRelayProcess == nil else { return } + guard self.daemonReady else { return } + self.startReverseRelayLocked(remotePath: self.daemonRemotePath ?? remotePath) + } + reverseRelayRestartWorkItem = workItem + queue.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + private func stopReverseRelayLocked() { + reverseRelayStderrPipe?.fileHandleForReading.readabilityHandler = nil + if let reverseRelayProcess, reverseRelayProcess.isRunning { + reverseRelayProcess.terminate() + } + reverseRelayProcess = nil + reverseRelayStderrPipe = nil + reverseRelayStderrBuffer = "" + cliRelayServer?.stop() + cliRelayServer = nil + removeRemoteRelayMetadataLocked() + } + + private func handleProxyBrokerUpdateLocked(_ update: WorkspaceRemoteProxyBroker.Update) { + guard !isStopping else { return } + switch update { + case .connecting: + debugLog("remote.proxy.connecting \(debugConfigSummary())") + if proxyEndpoint == nil { + publishState(.connecting, detail: "Connecting to \(configuration.displayTarget)") + } + case .ready(let endpoint): + debugLog("remote.proxy.ready host=\(endpoint.host) port=\(endpoint.port) \(debugConfigSummary())") + reconnectWorkItem?.cancel() + reconnectWorkItem = nil + reconnectRetryCount = 0 + guard proxyEndpoint != endpoint else { + recordHeartbeatActivityLocked() + return + } + proxyEndpoint = endpoint + publishProxyEndpoint(endpoint) + publishPortsSnapshotLocked() + publishState( + .connected, + detail: "Connected to \(configuration.displayTarget) via shared local proxy \(endpoint.host):\(endpoint.port)" + ) + recordHeartbeatActivityLocked() + case .error(let detail): + debugLog("remote.proxy.error detail=\(detail) \(debugConfigSummary())") + proxyEndpoint = nil + publishProxyEndpoint(nil) + publishPortsSnapshotLocked() + publishState(.error, detail: "Remote proxy to \(configuration.displayTarget) unavailable: \(detail)") + guard Self.shouldEscalateProxyErrorToBootstrap(detail) else { return } + + proxyLease?.release() + proxyLease = nil + daemonReady = false + daemonBootstrapVersion = nil + daemonRemotePath = nil + + let nextRetry = scheduleReconnectLocked(delay: 2.0) + let retrySuffix = Self.retrySuffix(retry: nextRetry, delay: 2.0) + publishDaemonStatus( + .error, + detail: "Remote daemon transport needs re-bootstrap after proxy failure\(retrySuffix)" + ) + } + } + + @discardableResult + private func scheduleReconnectLocked(delay: TimeInterval) -> Int { + guard !isStopping else { return reconnectRetryCount } + reconnectWorkItem?.cancel() + reconnectRetryCount += 1 + let retryNumber = reconnectRetryCount + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.reconnectWorkItem = nil + guard !self.isStopping else { return } + guard self.proxyLease == nil else { return } + self.beginConnectionAttemptLocked() + } + reconnectWorkItem = workItem + queue.asyncAfter(deadline: .now() + delay, execute: workItem) + return retryNumber + } + + private func publishState(_ state: WorkspaceRemoteConnectionState, detail: String?) { + let controllerID = self.controllerID + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteConnectionStateUpdate( + state, + detail: detail, + target: workspace.remoteDisplayTarget ?? "remote host" + ) + } + } + + private func publishDaemonStatus( + _ state: WorkspaceRemoteDaemonState, + detail: String?, + version: String? = nil, + name: String? = nil, + capabilities: [String] = [], + remotePath: String? = nil + ) { + let controllerID = self.controllerID + let status = WorkspaceRemoteDaemonStatus( + state: state, + detail: detail, + version: version, + name: name, + capabilities: capabilities, + remotePath: remotePath + ) + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteDaemonStatusUpdate( + status, + target: workspace.remoteDisplayTarget ?? "remote host" + ) + } + } + + private func publishProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) { + let controllerID = self.controllerID + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteProxyEndpointUpdate(endpoint) + } + } + + private func publishPortsSnapshotLocked() { + let controllerID = self.controllerID + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemotePortsSnapshot( + detected: [], + forwarded: [], + conflicts: [], + target: workspace.remoteDisplayTarget ?? "remote host" + ) + } + } + + private func recordHeartbeatActivityLocked() { + heartbeatCount += 1 + publishHeartbeat(count: heartbeatCount, at: Date()) + } + + private func publishHeartbeat(count: Int, at date: Date?) { + let controllerID = self.controllerID + DispatchQueue.main.async { [weak workspace] in + guard let workspace else { return } + guard workspace.activeRemoteSessionControllerID == controllerID else { return } + workspace.applyRemoteHeartbeatUpdate(count: count, lastSeenAt: date) + } + } + + private func reverseRelayArguments(relayPort: Int, localRelayPort: Int) -> [String] { + // `-o ControlPath=none` is not enough on macOS OpenSSH, the client can still + // attach to an existing master and exit immediately with its status. + // `-S none` forces a standalone transport for the reverse relay. + var args: [String] = ["-N", "-T", "-S", "none"] + args += sshCommonArguments(batchMode: true) + args += [ + "-o", "ExitOnForwardFailure=yes", + "-o", "RequestTTY=no", + "-R", "127.0.0.1:\(relayPort):127.0.0.1:\(localRelayPort)", + configuration.destination, + ] + return args + } + + private static let remotePlatformProbeOSMarker = "__CMUX_REMOTE_OS__=" + private static let remotePlatformProbeArchMarker = "__CMUX_REMOTE_ARCH__=" + private static let remotePlatformProbeExistsMarker = "__CMUX_REMOTE_EXISTS__=" + + private func sshCommonArguments(batchMode: Bool) -> [String] { + let effectiveSSHOptions: [String] = { + if batchMode { + return backgroundSSHOptions(configuration.sshOptions) + } + return normalizedSSHOptions(configuration.sshOptions) + }() + var args: [String] = [ + "-o", "ConnectTimeout=6", + "-o", "ServerAliveInterval=20", + "-o", "ServerAliveCountMax=2", + ] + if !hasSSHOptionKey(effectiveSSHOptions, key: "StrictHostKeyChecking") { + args += ["-o", "StrictHostKeyChecking=accept-new"] + } + if batchMode { + args += ["-o", "BatchMode=yes"] + args += ["-o", "ControlMaster=no"] + } + if let port = configuration.port { + args += ["-p", String(port)] + } + if let identityFile = configuration.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args += ["-i", identityFile] + } + for option in effectiveSSHOptions { + args += ["-o", option] + } + return args + } + + private func hasSSHOptionKey(_ options: [String], key: String) -> Bool { + let loweredKey = key.lowercased() + for option in options { + let token = sshOptionKey(option) + if token == loweredKey { + return true + } + } + return false + } + + private func normalizedSSHOptions(_ options: [String]) -> [String] { + options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + } + } + + private func backgroundSSHOptions(_ options: [String]) -> [String] { + let batchSSHControlOptionKeys: Set<String> = [ + "controlmaster", + "controlpersist", + ] + return normalizedSSHOptions(options).filter { option in + guard let key = sshOptionKey(option) else { return false } + return !batchSSHControlOptionKeys.contains(key) + } + } + + private func sshOptionKey(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + } + + private func sshExec(arguments: [String], stdin: Data? = nil, timeout: TimeInterval = 15) throws -> CommandResult { + try runProcess( + executable: "/usr/bin/ssh", + arguments: arguments, + stdin: stdin, + timeout: timeout + ) + } + + private func scpExec(arguments: [String], timeout: TimeInterval = 30) throws -> CommandResult { + try runProcess( + executable: "/usr/bin/scp", + arguments: arguments, + stdin: nil, + timeout: timeout + ) + } + + private func runProcess( + executable: String, + arguments: [String], + environment: [String: String]? = nil, + currentDirectory: URL? = nil, + stdin: Data?, + timeout: TimeInterval + ) throws -> CommandResult { + debugLog( + "remote.proc.start exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))" + ) + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + if let environment { + process.environment = environment + } + if let currentDirectory { + process.currentDirectoryURL = currentDirectory + } + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + if stdin != nil { + process.standardInput = Pipe() + } else { + process.standardInput = FileHandle.nullDevice + } + + let stdoutHandle = stdoutPipe.fileHandleForReading + let stderrHandle = stderrPipe.fileHandleForReading + let captureQueue = DispatchQueue(label: "cmux.remote.process.capture") + let exitSemaphore = DispatchSemaphore(value: 0) + var stdoutData = Data() + var stderrData = Data() + let captureGroup = DispatchGroup() + process.terminationHandler = { _ in + exitSemaphore.signal() + } + captureGroup.enter() + DispatchQueue.global(qos: .utility).async { + let data = stdoutHandle.readDataToEndOfFile() + captureQueue.sync { + stdoutData = data + } + captureGroup.leave() + } + captureGroup.enter() + DispatchQueue.global(qos: .utility).async { + let data = stderrHandle.readDataToEndOfFile() + captureQueue.sync { + stderrData = data + } + captureGroup.leave() + } + + do { + try process.run() + } catch { + try? stdoutPipe.fileHandleForWriting.close() + try? stderrPipe.fileHandleForWriting.close() + debugLog( + "remote.proc.launchFailed exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "error=\(error.localizedDescription)" + ) + throw NSError(domain: "cmux.remote.process", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to launch \(URL(fileURLWithPath: executable).lastPathComponent): \(error.localizedDescription)", + ]) + } + try? stdoutPipe.fileHandleForWriting.close() + try? stderrPipe.fileHandleForWriting.close() + + if let stdin, let pipe = process.standardInput as? Pipe { + pipe.fileHandleForWriting.write(stdin) + try? pipe.fileHandleForWriting.close() + } + + let didExitBeforeTimeout = exitSemaphore.wait(timeout: .now() + max(0, timeout)) == .success + if !didExitBeforeTimeout, process.isRunning { + process.terminate() + let terminatedGracefully = exitSemaphore.wait(timeout: .now() + 2.0) == .success + if !terminatedGracefully, process.isRunning { + _ = Darwin.kill(process.processIdentifier, SIGKILL) + process.waitUntilExit() + } + debugLog( + "remote.proc.timeout exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "timeout=\(Int(timeout)) args=\(debugShellCommand(executable: executable, arguments: arguments))" + ) + throw NSError(domain: "cmux.remote.process", code: 2, userInfo: [ + NSLocalizedDescriptionKey: "\(URL(fileURLWithPath: executable).lastPathComponent) timed out after \(Int(timeout))s", + ]) + } + + _ = captureGroup.wait(timeout: .now() + 2.0) + try? stdoutHandle.close() + try? stderrHandle.close() + let stdout = String(data: stdoutData, encoding: .utf8) ?? "" + let stderr = String(data: stderrData, encoding: .utf8) ?? "" + debugLog( + "remote.proc.end exec=\(URL(fileURLWithPath: executable).lastPathComponent) " + + "status=\(process.terminationStatus) stdout=\(Self.debugLogSnippet(stdout)) " + + "stderr=\(Self.debugLogSnippet(stderr))" + ) + return CommandResult(status: process.terminationStatus, stdout: stdout, stderr: stderr) + } + + private func bootstrapDaemonLocked() throws -> DaemonHello { + debugLog("remote.bootstrap.begin \(debugConfigSummary())") + let version = Self.remoteDaemonVersion() + let bootstrapState = try probeRemoteBootstrapStateLocked(version: version) + let platform = bootstrapState.platform + let remotePath = Self.remoteDaemonPath(version: version, goOS: platform.goOS, goArch: platform.goArch) + let explicitOverrideBinary = Self.explicitRemoteDaemonBinaryURL() + let forceExplicitOverrideInstall = explicitOverrideBinary != nil + debugLog( + "remote.bootstrap.platform os=\(platform.goOS) arch=\(platform.goArch) " + + "version=\(version) remotePath=\(remotePath) " + + "allowLocalBuildFallback=\(Self.allowLocalDaemonBuildFallback() ? 1 : 0) " + + "explicitOverride=\(forceExplicitOverrideInstall ? 1 : 0)" + ) + + let hadExistingBinary = bootstrapState.binaryExists + debugLog("remote.bootstrap.binaryExists remotePath=\(remotePath) exists=\(hadExistingBinary ? 1 : 0)") + if forceExplicitOverrideInstall || !hadExistingBinary { + let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) + try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) + } + + var hello: DaemonHello + do { + hello = try helloRemoteDaemonLocked(remotePath: remotePath) + } catch { + guard hadExistingBinary else { + throw error + } + debugLog( + "remote.bootstrap.helloRetry remotePath=\(remotePath) " + + "detail=\(error.localizedDescription)" + ) + let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) + try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) + hello = try helloRemoteDaemonLocked(remotePath: remotePath) + } + if hadExistingBinary, !hello.capabilities.contains(WorkspaceRemoteDaemonRPCClient.requiredProxyStreamCapability) { + debugLog("remote.bootstrap.capabilityMissing remotePath=\(remotePath) capabilities=\(hello.capabilities.joined(separator: ","))") + let localBinary = try buildLocalDaemonBinary(goOS: platform.goOS, goArch: platform.goArch, version: version) + try uploadRemoteDaemonBinaryLocked(localBinary: localBinary, remotePath: remotePath) + hello = try helloRemoteDaemonLocked(remotePath: remotePath) + } + + debugLog( + "remote.bootstrap.ready name=\(hello.name) version=\(hello.version) " + + "capabilities=\(hello.capabilities.joined(separator: ",")) remotePath=\(hello.remotePath)" + ) + if let connectionAttemptStartedAt { + debugLog( + "remote.timing.bootstrap.ready elapsedMs=\(Int(Date().timeIntervalSince(connectionAttemptStartedAt) * 1000)) " + + "\(debugConfigSummary())" + ) + } + return hello + } + + private func ensureCLIRelayServerLocked(localSocketPath: String, relayID: String, relayToken: String) throws -> WorkspaceRemoteCLIRelayServer { + if let cliRelayServer { + return cliRelayServer + } + let relayServer = try WorkspaceRemoteCLIRelayServer( + localSocketPath: localSocketPath, + relayID: relayID, + relayTokenHex: relayToken + ) + cliRelayServer = relayServer + return relayServer + } + + private func installRemoteRelayMetadataLocked( + remotePath: String, + relayPort: Int, + relayID: String, + relayToken: String + ) throws { + let script = Self.remoteRelayMetadataInstallScript( + daemonRemotePath: remotePath, + relayPort: relayPort, + relayID: relayID, + relayToken: relayToken + ) + let command = "sh -c \(Self.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "cmux.remote.relay", code: 70, userInfo: [ + NSLocalizedDescriptionKey: "failed to install remote relay metadata: \(detail)", + ]) + } + } + + private func removeRemoteRelayMetadataLocked() { + guard let relayPort = configuration.relayPort, relayPort > 0 else { return } + let script = Self.remoteRelayMetadataCleanupScript(relayPort: relayPort) + let command = "sh -c \(Self.shellSingleQuoted(script))" + do { + _ = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 8) + } catch { + debugLog("remote.relay.cleanup.error \(error.localizedDescription)") + } + } + + static func remoteRelayMetadataCleanupScript(relayPort: Int) -> String { + """ + relay_socket='127.0.0.1:\(relayPort)' + socket_addr_file="$HOME/.cmux/socket_addr" + if [ -r "$socket_addr_file" ] && [ "$(tr -d '\\r\\n' < "$socket_addr_file")" = "$relay_socket" ]; then + rm -f "$socket_addr_file" + fi + rm -f "$HOME/.cmux/relay/\(relayPort).auth" "$HOME/.cmux/relay/\(relayPort).daemon_path" + """ + } + + private func probeRemoteBootstrapStateLocked(version: String) throws -> RemoteBootstrapState { + let script = """ + cmux_uname_os="$(uname -s)" + cmux_uname_arch="$(uname -m)" + printf '%s%s\\n' '\(Self.remotePlatformProbeOSMarker)' "$cmux_uname_os" + printf '%s%s\\n' '\(Self.remotePlatformProbeArchMarker)' "$cmux_uname_arch" + case "$(printf '%s' "$cmux_uname_os" | tr '[:upper:]' '[:lower:]')" in + linux|darwin|freebsd) cmux_go_os="$(printf '%s' "$cmux_uname_os" | tr '[:upper:]' '[:lower:]')" ;; + *) exit 70 ;; + esac + case "$(printf '%s' "$cmux_uname_arch" | tr '[:upper:]' '[:lower:]')" in + x86_64|amd64) cmux_go_arch=amd64 ;; + aarch64|arm64) cmux_go_arch=arm64 ;; + armv7l) cmux_go_arch=arm ;; + *) exit 71 ;; + esac + cmux_remote_path="$HOME/.cmux/bin/cmuxd-remote/\(version)/${cmux_go_os}-${cmux_go_arch}/cmuxd-remote" + if [ -x "$cmux_remote_path" ]; then + printf '%syes\\n' '\(Self.remotePlatformProbeExistsMarker)' + else + printf '%sno\\n' '\(Self.remotePlatformProbeExistsMarker)' + fi + """ + let command = "sh -c \(Self.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 20) + + let lines = result.stdout + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let unameOS = lines.first { $0.hasPrefix(Self.remotePlatformProbeOSMarker) } + .map { String($0.dropFirst(Self.remotePlatformProbeOSMarker.count)) } + let unameArch = lines.first { $0.hasPrefix(Self.remotePlatformProbeArchMarker) } + .map { String($0.dropFirst(Self.remotePlatformProbeArchMarker.count)) } + guard let unameOS, let unameArch else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "cmux.remote.daemon", code: 11, userInfo: [ + NSLocalizedDescriptionKey: "failed to query remote platform: \(detail)", + ]) + } + + guard let goOS = Self.mapUnameOS(unameOS), + let goArch = Self.mapUnameArch(unameArch) else { + throw NSError(domain: "cmux.remote.daemon", code: 12, userInfo: [ + NSLocalizedDescriptionKey: "unsupported remote platform \(unameOS)/\(unameArch)", + ]) + } + + let binaryExists = lines.first { $0.hasPrefix(Self.remotePlatformProbeExistsMarker) } + .map { String($0.dropFirst(Self.remotePlatformProbeExistsMarker.count)) == "yes" } + if result.status != 0, binaryExists == nil { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "cmux.remote.daemon", code: 13, userInfo: [ + NSLocalizedDescriptionKey: "failed to query remote daemon state: \(detail)", + ]) + } + + return RemoteBootstrapState( + platform: RemotePlatform(goOS: goOS, goArch: goArch), + binaryExists: binaryExists ?? false + ) + } + + static let remoteDaemonManifestInfoKey = "CMUXRemoteDaemonManifestJSON" + + static func remoteDaemonManifest(from infoDictionary: [String: Any]?) -> WorkspaceRemoteDaemonManifest? { + guard let rawManifest = infoDictionary?[remoteDaemonManifestInfoKey] as? String else { return nil } + let trimmed = rawManifest.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard let data = trimmed.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode(WorkspaceRemoteDaemonManifest.self, from: data) + } + + private static func remoteDaemonManifest() -> WorkspaceRemoteDaemonManifest? { + remoteDaemonManifest(from: Bundle.main.infoDictionary) + } + + private static func remoteDaemonCacheRoot(fileManager: FileManager = .default) throws -> URL { + let appSupportRoot = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let cacheRoot = appSupportRoot + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent("remote-daemons", isDirectory: true) + try fileManager.createDirectory(at: cacheRoot, withIntermediateDirectories: true) + return cacheRoot + } + + static func remoteDaemonCachedBinaryURL( + version: String, + goOS: String, + goArch: String, + fileManager: FileManager = .default + ) throws -> URL { + try remoteDaemonCacheRoot(fileManager: fileManager) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("cmuxd-remote", isDirectory: false) + } + + private static func sha256Hex(forFile url: URL) throws -> String { + let data = try Data(contentsOf: url) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private static func allowLocalDaemonBuildFallback(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { + environment["CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD"] == "1" + } + + private static func explicitRemoteDaemonBinaryURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? { + guard allowLocalDaemonBuildFallback(environment: environment) else { return nil } + guard let path = environment["CMUX_REMOTE_DAEMON_BINARY"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty else { + return nil + } + return URL(fileURLWithPath: path, isDirectory: false).standardizedFileURL + } + + private static func versionedRemoteDaemonBuildURL(goOS: String, goArch: String, version: String) -> URL { + URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("cmux-remote-daemon-build", isDirectory: true) + .appendingPathComponent(version, isDirectory: true) + .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) + .appendingPathComponent("cmuxd-remote", isDirectory: false) + } + + private func downloadRemoteDaemonBinaryLocked(entry: WorkspaceRemoteDaemonManifest.Entry, version: String) throws -> URL { + guard let url = URL(string: entry.downloadURL) else { + throw NSError(domain: "cmux.remote.daemon", code: 25, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon manifest has an invalid download URL", + ]) + } + + let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: version, goOS: entry.goOS, goArch: entry.goArch) + let fileManager = FileManager.default + try fileManager.createDirectory(at: cacheURL.deletingLastPathComponent(), withIntermediateDirectories: true) + + let request = NSMutableURLRequest(url: url) + request.timeoutInterval = 60 + request.setValue("cmux/\(version)", forHTTPHeaderField: "User-Agent") + let session = URLSession(configuration: .ephemeral) + + let semaphore = DispatchSemaphore(value: 0) + var downloadedURL: URL? + var downloadError: Error? + session.downloadTask(with: request as URLRequest) { localURL, response, error in + defer { semaphore.signal() } + if let error { + downloadError = error + return + } + if let httpResponse = response as? HTTPURLResponse, + !(200...299).contains(httpResponse.statusCode) { + downloadError = NSError(domain: "cmux.remote.daemon", code: 26, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon download failed with HTTP \(httpResponse.statusCode)", + ]) + return + } + downloadedURL = localURL + }.resume() + _ = semaphore.wait(timeout: .now() + 75.0) + session.finishTasksAndInvalidate() + + if let downloadError { + throw downloadError + } + guard let downloadedURL else { + throw NSError(domain: "cmux.remote.daemon", code: 27, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon download did not produce a file", + ]) + } + + let downloadedSHA = try Self.sha256Hex(forFile: downloadedURL) + guard downloadedSHA == entry.sha256.lowercased() else { + throw NSError(domain: "cmux.remote.daemon", code: 28, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon checksum mismatch for \(entry.assetName)", + ]) + } + + let tempURL = cacheURL.deletingLastPathComponent() + .appendingPathComponent(".\(cacheURL.lastPathComponent).tmp-\(UUID().uuidString)") + try? fileManager.removeItem(at: tempURL) + try fileManager.moveItem(at: downloadedURL, to: tempURL) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tempURL.path) + try? fileManager.removeItem(at: cacheURL) + try fileManager.moveItem(at: tempURL, to: cacheURL) + return cacheURL + } + + private func buildLocalDaemonBinary(goOS: String, goArch: String, version: String) throws -> URL { + if let explicitBinary = Self.explicitRemoteDaemonBinaryURL(), + FileManager.default.isExecutableFile(atPath: explicitBinary.path) { + debugLog("remote.build.explicit path=\(explicitBinary.path)") + return explicitBinary + } + + if let manifest = Self.remoteDaemonManifest(), + manifest.appVersion == version, + let entry = manifest.entry(goOS: goOS, goArch: goArch) { + let cacheURL = try Self.remoteDaemonCachedBinaryURL(version: manifest.appVersion, goOS: goOS, goArch: goArch) + if FileManager.default.fileExists(atPath: cacheURL.path) { + let cachedSHA = try Self.sha256Hex(forFile: cacheURL) + if cachedSHA == entry.sha256.lowercased(), + FileManager.default.isExecutableFile(atPath: cacheURL.path) { + debugLog("remote.build.cached path=\(cacheURL.path)") + return cacheURL + } + try? FileManager.default.removeItem(at: cacheURL) + } + let downloadedURL = try downloadRemoteDaemonBinaryLocked(entry: entry, version: manifest.appVersion) + debugLog("remote.build.downloaded path=\(downloadedURL.path)") + return downloadedURL + } + + guard Self.allowLocalDaemonBuildFallback() else { + throw NSError(domain: "cmux.remote.daemon", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "this build does not include a verified cmuxd-remote manifest for \(goOS)-\(goArch). Use a release/nightly build, or set CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 for a dev-only fallback.", + ]) + } + + guard let repoRoot = Self.findRepoRoot() else { + throw NSError(domain: "cmux.remote.daemon", code: 20, userInfo: [ + NSLocalizedDescriptionKey: "cannot locate cmux repo root for dev-only cmuxd-remote build fallback", + ]) + } + let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true) + let goModPath = daemonRoot.appendingPathComponent("go.mod").path + guard FileManager.default.fileExists(atPath: goModPath) else { + throw NSError(domain: "cmux.remote.daemon", code: 21, userInfo: [ + NSLocalizedDescriptionKey: "missing daemon module at \(goModPath)", + ]) + } + guard let goBinary = Self.which("go") else { + throw NSError(domain: "cmux.remote.daemon", code: 22, userInfo: [ + NSLocalizedDescriptionKey: "go is required for the dev-only cmuxd-remote build fallback", + ]) + } + + let output = Self.versionedRemoteDaemonBuildURL(goOS: goOS, goArch: goArch, version: version) + try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true) + + var env = ProcessInfo.processInfo.environment + env["GOOS"] = goOS + env["GOARCH"] = goArch + env["CGO_ENABLED"] = "0" + let ldflags = "-s -w -X main.version=\(version)" + let result = try runProcess( + executable: goBinary, + arguments: ["build", "-trimpath", "-buildvcs=false", "-ldflags", ldflags, "-o", output.path, "./cmd/cmuxd-remote"], + environment: env, + currentDirectory: daemonRoot, + stdin: nil, + timeout: 90 + ) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "go build failed with status \(result.status)" + throw NSError(domain: "cmux.remote.daemon", code: 23, userInfo: [ + NSLocalizedDescriptionKey: "failed to build cmuxd-remote: \(detail)", + ]) + } + guard FileManager.default.isExecutableFile(atPath: output.path) else { + throw NSError(domain: "cmux.remote.daemon", code: 24, userInfo: [ + NSLocalizedDescriptionKey: "cmuxd-remote build output is not executable", + ]) + } + debugLog("remote.build.output path=\(output.path)") + return output + } + + private func uploadRemoteDaemonBinaryLocked(localBinary: URL, remotePath: String) throws { + let remoteDirectory = (remotePath as NSString).deletingLastPathComponent + let remoteTempPath = "\(remotePath).tmp-\(UUID().uuidString.prefix(8))" + debugLog( + "remote.upload.begin local=\(localBinary.path) remoteTemp=\(remoteTempPath) remote=\(remotePath)" + ) + + let mkdirScript = "mkdir -p \(Self.shellSingleQuoted(remoteDirectory))" + let mkdirCommand = "sh -c \(Self.shellSingleQuoted(mkdirScript))" + let mkdirResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, mkdirCommand], timeout: 12) + guard mkdirResult.status == 0 else { + let detail = Self.bestErrorLine(stderr: mkdirResult.stderr, stdout: mkdirResult.stdout) ?? "ssh exited \(mkdirResult.status)" + throw NSError(domain: "cmux.remote.daemon", code: 30, userInfo: [ + NSLocalizedDescriptionKey: "failed to create remote daemon directory: \(detail)", + ]) + } + + let scpSSHOptions = backgroundSSHOptions(configuration.sshOptions) + var scpArgs: [String] = ["-q"] + if !hasSSHOptionKey(scpSSHOptions, key: "StrictHostKeyChecking") { + scpArgs += ["-o", "StrictHostKeyChecking=accept-new"] + } + scpArgs += ["-o", "ControlMaster=no"] + if let port = configuration.port { + scpArgs += ["-P", String(port)] + } + if let identityFile = configuration.identityFile, + !identityFile.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + scpArgs += ["-i", identityFile] + } + for option in scpSSHOptions { + scpArgs += ["-o", option] + } + scpArgs += [localBinary.path, "\(configuration.destination):\(remoteTempPath)"] + let scpResult = try scpExec(arguments: scpArgs, timeout: 45) + guard scpResult.status == 0 else { + let detail = Self.bestErrorLine(stderr: scpResult.stderr, stdout: scpResult.stdout) ?? "scp exited \(scpResult.status)" + throw NSError(domain: "cmux.remote.daemon", code: 31, userInfo: [ + NSLocalizedDescriptionKey: "failed to upload cmuxd-remote: \(detail)", + ]) + } + + let finalizeScript = """ + chmod 755 \(Self.shellSingleQuoted(remoteTempPath)) && \ + mv \(Self.shellSingleQuoted(remoteTempPath)) \(Self.shellSingleQuoted(remotePath)) + """ + let finalizeCommand = "sh -c \(Self.shellSingleQuoted(finalizeScript))" + let finalizeResult = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, finalizeCommand], timeout: 12) + guard finalizeResult.status == 0 else { + let detail = Self.bestErrorLine(stderr: finalizeResult.stderr, stdout: finalizeResult.stdout) ?? "ssh exited \(finalizeResult.status)" + throw NSError(domain: "cmux.remote.daemon", code: 32, userInfo: [ + NSLocalizedDescriptionKey: "failed to install remote daemon binary: \(detail)", + ]) + } + } + + private func helloRemoteDaemonLocked(remotePath: String) throws -> DaemonHello { + let request = #"{"id":1,"method":"hello","params":{}}"# + let script = "printf '%s\\n' \(Self.shellSingleQuoted(request)) | \(Self.shellSingleQuoted(remotePath)) serve --stdio" + let command = "sh -c \(Self.shellSingleQuoted(script))" + let result = try sshExec(arguments: sshCommonArguments(batchMode: true) + [configuration.destination, command], timeout: 12) + guard result.status == 0 else { + let detail = Self.bestErrorLine(stderr: result.stderr, stdout: result.stdout) ?? "ssh exited \(result.status)" + throw NSError(domain: "cmux.remote.daemon", code: 40, userInfo: [ + NSLocalizedDescriptionKey: "failed to start remote daemon: \(detail)", + ]) + } + + let responseLine = result.stdout + .split(separator: "\n") + .map(String.init) + .first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) ?? "" + guard !responseLine.isEmpty, + let data = responseLine.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { + throw NSError(domain: "cmux.remote.daemon", code: 41, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon hello returned invalid JSON", + ]) + } + + if let ok = payload["ok"] as? Bool, !ok { + let errorMessage: String = { + if let errorObject = payload["error"] as? [String: Any], + let message = errorObject["message"] as? String, + !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return message + } + return "hello call failed" + }() + throw NSError(domain: "cmux.remote.daemon", code: 42, userInfo: [ + NSLocalizedDescriptionKey: "remote daemon hello failed: \(errorMessage)", + ]) + } + + let resultObject = payload["result"] as? [String: Any] ?? [:] + let name = (resultObject["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let version = (resultObject["version"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let capabilities = (resultObject["capabilities"] as? [String]) ?? [] + return DaemonHello( + name: (name?.isEmpty == false ? name! : "cmuxd-remote"), + version: (version?.isEmpty == false ? version! : "dev"), + capabilities: capabilities, + remotePath: remotePath + ) + } + + private func debugLog(_ message: @autoclosure () -> String) { +#if DEBUG + dlog(message()) +#endif + } + + private func debugConfigSummary() -> String { + let controlPath = Self.debugSSHOptionValue(named: "ControlPath", in: configuration.sshOptions) ?? "nil" + return + "target=\(configuration.displayTarget) port=\(configuration.port.map(String.init) ?? "nil") " + + "relayPort=\(configuration.relayPort.map(String.init) ?? "nil") " + + "localSocket=\(configuration.localSocketPath ?? "nil") " + + "controlPath=\(controlPath)" + } + + private func debugShellCommand(executable: String, arguments: [String]) -> String { + ([URL(fileURLWithPath: executable).lastPathComponent] + arguments) + .map(Self.shellSingleQuoted) + .joined(separator: " ") + } + + private static func debugSSHOptionValue(named key: String, in options: [String]) -> String? { + let loweredKey = key.lowercased() + for option in options { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let parts = trimmed.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + if parts.count == 2, + parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == loweredKey { + return parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + } + } + return nil + } + + private static func debugLogSnippet(_ text: String, limit: Int = 160) -> String { + let normalized = text + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return "\"\"" } + if normalized.count <= limit { + return normalized + } + return String(normalized.prefix(limit)) + "..." + } + + private static func shellSingleQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } + + static func remoteCLIWrapperScript() -> String { + """ + #!/usr/bin/env bash + set -euo pipefail + + daemon="$HOME/.cmux/bin/cmuxd-remote-current" + socket_path="${CMUX_SOCKET_PATH:-}" + if [ -z "$socket_path" ] && [ -r "$HOME/.cmux/socket_addr" ]; then + socket_path="$(tr -d '\\r\\n' < "$HOME/.cmux/socket_addr")" + fi + + if [ -n "$socket_path" ] && [ "${socket_path#/}" = "$socket_path" ] && [ "${socket_path#*:}" != "$socket_path" ]; then + relay_port="${socket_path##*:}" + relay_map="$HOME/.cmux/relay/${relay_port}.daemon_path" + if [ -r "$relay_map" ]; then + mapped_daemon="$(tr -d '\\r\\n' < "$relay_map")" + if [ -n "$mapped_daemon" ] && [ -x "$mapped_daemon" ]; then + daemon="$mapped_daemon" + fi + fi + fi + + exec "$daemon" "$@" + """ + } + + static func remoteCLIWrapperInstallScript(daemonRemotePath: String) -> String { + let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) + return """ + mkdir -p "$HOME/.cmux/bin" "$HOME/.cmux/relay" + ln -sf "$HOME/\(trimmedRemotePath)" "$HOME/.cmux/bin/cmuxd-remote-current" + wrapper_tmp="$HOME/.cmux/bin/.cmux-wrapper.tmp.$$" + cat > "$wrapper_tmp" <<'CMUXWRAPPER' + \(remoteCLIWrapperScript()) + CMUXWRAPPER + chmod 755 "$wrapper_tmp" + mv -f "$wrapper_tmp" "$HOME/.cmux/bin/cmux" + """ + } + + static func remoteRelayMetadataInstallScript( + daemonRemotePath: String, + relayPort: Int, + relayID: String, + relayToken: String + ) -> String { + let trimmedRemotePath = daemonRemotePath.trimmingCharacters(in: .whitespacesAndNewlines) + let authPayload = """ + {"relay_id":"\(relayID)","relay_token":"\(relayToken)"} + """ + return """ + umask 077 + mkdir -p "$HOME/.cmux" "$HOME/.cmux/relay" + chmod 700 "$HOME/.cmux/relay" + \(remoteCLIWrapperInstallScript(daemonRemotePath: trimmedRemotePath)) + printf '%s' "$HOME/\(trimmedRemotePath)" > "$HOME/.cmux/relay/\(relayPort).daemon_path" + cat > "$HOME/.cmux/relay/\(relayPort).auth" <<'CMUXRELAYAUTH' + \(authPayload) + CMUXRELAYAUTH + chmod 600 "$HOME/.cmux/relay/\(relayPort).auth" + printf '%s' '127.0.0.1:\(relayPort)' > "$HOME/.cmux/socket_addr" + """ + } + + private static func mapUnameOS(_ raw: String) -> String? { + switch raw.lowercased() { + case "linux": + return "linux" + case "darwin": + return "darwin" + case "freebsd": + return "freebsd" + default: + return nil + } + } + + private static func mapUnameArch(_ raw: String) -> String? { + switch raw.lowercased() { + case "x86_64", "amd64": + return "amd64" + case "aarch64", "arm64": + return "arm64" + case "armv7l": + return "arm" + default: + return nil + } + } + + private static func remoteDaemonVersion() -> String { + let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) + let baseVersion = (bundleVersion?.isEmpty == false) ? bundleVersion! : "dev" + guard allowLocalDaemonBuildFallback(), + let sourceFingerprint = remoteDaemonSourceFingerprint(), + !sourceFingerprint.isEmpty else { + return baseVersion + } + return "\(baseVersion)-dev-\(sourceFingerprint)" + } + + private static let cachedRemoteDaemonSourceFingerprint: String? = computeRemoteDaemonSourceFingerprint() + + private static func remoteDaemonSourceFingerprint() -> String? { + cachedRemoteDaemonSourceFingerprint + } + + private static func computeRemoteDaemonSourceFingerprint(fileManager: FileManager = .default) -> String? { + guard let repoRoot = findRepoRoot() else { return nil } + let daemonRoot = repoRoot.appendingPathComponent("daemon/remote", isDirectory: true) + guard let enumerator = fileManager.enumerator( + at: daemonRoot, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles] + ) else { + return nil + } + + var relativePaths: [String] = [] + for case let fileURL as URL in enumerator { + guard let resourceValues = try? fileURL.resourceValues(forKeys: [.isRegularFileKey]), + resourceValues.isRegularFile == true else { + continue + } + + let relativePath = fileURL.path.replacingOccurrences(of: daemonRoot.path + "/", with: "") + if relativePath == "go.mod" || relativePath == "go.sum" || relativePath.hasSuffix(".go") { + relativePaths.append(relativePath) + } + } + + guard !relativePaths.isEmpty else { return nil } + + let digest = SHA256.hash(data: relativePaths.sorted().reduce(into: Data()) { partialResult, relativePath in + let fileURL = daemonRoot.appendingPathComponent(relativePath, isDirectory: false) + guard let fileData = try? Data(contentsOf: fileURL) else { return } + partialResult.append(Data(relativePath.utf8)) + partialResult.append(0) + partialResult.append(fileData) + partialResult.append(0) + }) + let hex = digest.map { String(format: "%02x", $0) }.joined() + return String(hex.prefix(12)) + } + + private static func remoteDaemonPath(version: String, goOS: String, goArch: String) -> String { + ".cmux/bin/cmuxd-remote/\(version)/\(goOS)-\(goArch)/cmuxd-remote" + } + + private static func killOrphanedRelayProcesses(relayPort: Int, destination: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/pkill") + process.arguments = ["-f", "ssh.*-R.*127\\.0\\.0\\.1:\(relayPort):127\\.0\\.0\\.1:[0-9]+.*\(destination)"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + } catch { + // Best effort cleanup only. + } + } + + private static func which(_ executable: String) -> String? { + let path = ProcessInfo.processInfo.environment["PATH"] ?? "" + for component in path.split(separator: ":") { + let candidate = String(component) + "/" + executable + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + private static func findRepoRoot() -> URL? { + var candidates: [URL] = [] + let compileTimeRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Sources + .deletingLastPathComponent() // repo root + candidates.append(compileTimeRoot) + let environment = ProcessInfo.processInfo.environment + if let envRoot = environment["CMUX_REMOTE_DAEMON_SOURCE_ROOT"], + !envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true)) + } + if let envRoot = environment["CMUXTERM_REPO_ROOT"], + !envRoot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + candidates.append(URL(fileURLWithPath: envRoot, isDirectory: true)) + } + candidates.append(URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true)) + if let executable = Bundle.main.executableURL?.deletingLastPathComponent() { + candidates.append(executable) + candidates.append(executable.deletingLastPathComponent()) + candidates.append(executable.deletingLastPathComponent().deletingLastPathComponent()) + } + + let fm = FileManager.default + for base in candidates { + var cursor = base.standardizedFileURL + for _ in 0..<10 { + let marker = cursor.appendingPathComponent("daemon/remote/go.mod").path + if fm.fileExists(atPath: marker) { + return cursor + } + let parent = cursor.deletingLastPathComponent() + if parent.path == cursor.path { + break + } + cursor = parent + } + } + return nil + } + + private static func bestErrorLine(stderr: String, stdout: String = "") -> String? { + if let stderrLine = meaningfulErrorLine(in: stderr) { + return stderrLine + } + if let stdoutLine = meaningfulErrorLine(in: stdout) { + return stdoutLine + } + return nil + } + + static func reverseRelayStartupFailureDetail( + process: Process, + stderrPipe: Pipe, + gracePeriod: TimeInterval = reverseRelayStartupGracePeriod + ) -> String? { + if process.isRunning { + let originalTerminationHandler = process.terminationHandler + let exitSemaphore = DispatchSemaphore(value: 0) + process.terminationHandler = { terminated in + originalTerminationHandler?(terminated) + exitSemaphore.signal() + } + if !process.isRunning { + exitSemaphore.signal() + } + guard exitSemaphore.wait(timeout: .now() + max(0, gracePeriod)) == .success else { + return nil + } + } + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderr = String(data: stderrData, encoding: .utf8) ?? "" + return bestErrorLine(stderr: stderr) ?? "status=\(process.terminationStatus)" + } + + private static func meaningfulErrorLine(in text: String) -> String? { + let lines = text + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + for line in lines.reversed() where !isNoiseLine(line) { + return line + } + return lines.last + } + + private static func isNoiseLine(_ line: String) -> Bool { + let lowered = line.lowercased() + if lowered.hasPrefix("warning: permanently added") { return true } + if lowered.hasPrefix("debug") { return true } + if lowered.hasPrefix("transferred:") { return true } + if lowered.hasPrefix("openbsd_") { return true } + if lowered.contains("pseudo-terminal will not be allocated") { return true } + return false + } + + private static func retrySuffix(retry: Int, delay: TimeInterval) -> String { + let seconds = max(1, Int(delay.rounded())) + return " (retry \(retry) in \(seconds)s)" + } + + private static func shouldEscalateProxyErrorToBootstrap(_ detail: String) -> Bool { + let lowered = detail.lowercased() + return lowered.contains("remote daemon transport failed") + || lowered.contains("daemon transport closed stdout") + || lowered.contains("daemon transport exited") + || lowered.contains("daemon transport is not connected") + || lowered.contains("daemon transport stopped") + } + +} + enum SidebarLogLevel: String { case info case progress @@ -637,6 +4418,88 @@ struct SidebarGitBranchState { let isDirty: Bool } +enum WorkspaceRemoteConnectionState: String { + case disconnected + case connecting + case connected + case error +} + +enum WorkspaceRemoteDaemonState: String { + case unavailable + case bootstrapping + case ready + case error +} + +struct WorkspaceRemoteDaemonStatus: Equatable { + var state: WorkspaceRemoteDaemonState = .unavailable + var detail: String? + var version: String? + var name: String? + var capabilities: [String] = [] + var remotePath: String? + + func payload() -> [String: Any] { + [ + "state": state.rawValue, + "detail": detail ?? NSNull(), + "version": version ?? NSNull(), + "name": name ?? NSNull(), + "capabilities": capabilities, + "remote_path": remotePath ?? NSNull(), + ] + } +} + +struct WorkspaceRemoteConfiguration: Equatable { + let destination: String + let port: Int? + let identityFile: String? + let sshOptions: [String] + let localProxyPort: Int? + let relayPort: Int? + let relayID: String? + let relayToken: String? + let localSocketPath: String? + let terminalStartupCommand: String? + + var displayTarget: String { + guard let port else { return destination } + return "\(destination):\(port)" + } + + var proxyBrokerTransportKey: String { + let normalizedDestination = destination.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedPort = port.map(String.init) ?? "" + let normalizedIdentity = identityFile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let normalizedLocalProxyPort = localProxyPort.map(String.init) ?? "" + let normalizedOptions = Self.proxyBrokerSSHOptions(sshOptions).joined(separator: "\u{1f}") + return [normalizedDestination, normalizedPort, normalizedIdentity, normalizedOptions, normalizedLocalProxyPort] + .joined(separator: "\u{1e}") + } + + private static func proxyBrokerSSHOptions(_ options: [String]) -> [String] { + options.compactMap { option in + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + }.filter { option in + proxyBrokerSSHOptionKey(option) != "controlpath" + } + } + + private static func proxyBrokerSSHOptionKey(_ option: String) -> String? { + let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed + .split(whereSeparator: { $0 == "=" || $0.isWhitespace }) + .first + .map(String.init)? + .lowercased() + } +} + enum SidebarPullRequestStatus: String { case open case merged @@ -998,14 +4861,58 @@ final class Workspace: Identifiable, ObservableObject { @Published var pullRequest: SidebarPullRequestState? @Published var panelPullRequests: [UUID: SidebarPullRequestState] = [:] @Published var surfaceListeningPorts: [UUID: [Int]] = [:] + @Published var remoteConfiguration: WorkspaceRemoteConfiguration? + @Published var remoteConnectionState: WorkspaceRemoteConnectionState = .disconnected + @Published var remoteConnectionDetail: String? + @Published var remoteDaemonStatus: WorkspaceRemoteDaemonStatus = WorkspaceRemoteDaemonStatus() + @Published var remoteDetectedPorts: [Int] = [] + @Published var remoteForwardedPorts: [Int] = [] + @Published var remotePortConflicts: [Int] = [] + @Published var remoteProxyEndpoint: BrowserProxyEndpoint? + @Published var remoteHeartbeatCount: Int = 0 + @Published var remoteLastHeartbeatAt: Date? @Published var listeningPorts: [Int] = [] + @Published private(set) var activeRemoteTerminalSessionCount: Int = 0 var surfaceTTYNames: [UUID: String] = [:] + private var remoteSessionController: WorkspaceRemoteSessionController? + fileprivate var activeRemoteSessionControllerID: UUID? + private var remoteLastErrorFingerprint: String? + private var remoteLastDaemonErrorFingerprint: String? + private var remoteLastPortConflictFingerprint: String? + private var activeRemoteTerminalSurfaceIds: Set<UUID> = [] + + private static let remoteErrorStatusKey = "remote.error" + private static let remotePortConflictStatusKey = "remote.port_conflicts" + private static let remoteHeartbeatDateFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() private var panelShellActivityStates: [UUID: PanelShellActivityState] = [:] /// PIDs associated with agent status entries (e.g. claude_code), keyed by status key. /// Used for stale-session detection: if the PID is dead, the status entry is cleared. var agentPIDs: [String: pid_t] = [:] private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] + private static func isProxyOnlyRemoteError(_ detail: String) -> Bool { + let lowered = detail.lowercased() + return lowered.contains("remote proxy") + || lowered.contains("proxy_unavailable") + || lowered.contains("local daemon proxy") + || lowered.contains("proxy failure") + || lowered.contains("daemon transport") + } + + private var preservesSSHTerminalConnection: Bool { + activeRemoteTerminalSessionCount > 0 + && remoteConfiguration?.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + } + + private var hasProxyOnlyRemoteSidebarError: Bool { + guard let entry = statusEntries[Self.remoteErrorStatusKey]?.value else { return false } + return entry.lowercased().contains("remote proxy unavailable") + } + var focusedSurfaceId: UUID? { focusedPanelId } var surfaceDirectories: [UUID: String] { get { panelDirectories } @@ -1044,10 +4951,10 @@ final class Workspace: Identifiable, ObservableObject { private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { BonsplitConfiguration.SplitButtonTooltips( - newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip(String(localized: "workspace.tooltip.newTerminal", defaultValue: "New Terminal")), - newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip(String(localized: "workspace.tooltip.newBrowser", defaultValue: "New Browser")), - splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip(String(localized: "workspace.tooltip.splitRight", defaultValue: "Split Right")), - splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip(String(localized: "workspace.tooltip.splitDown", defaultValue: "Split Down")) + newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"), + newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"), + splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"), + splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down") ) } @@ -1118,24 +5025,18 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex if GhosttyApp.shared.backgroundLogEnabled { GhosttyApp.shared.logBackground( - "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil") resultingBorder=\(bonsplitController.configuration.appearance.chromeColors.borderHex ?? "nil")" + "theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")" ) } } - func applyGhosttyChrome(backgroundColor: NSColor, reason: String = "unspecified") { - applyGhosttyChrome( - backgroundColor: backgroundColor, - backgroundOpacity: backgroundColor.alphaComponent, - reason: reason - ) - } - init( title: String = "Terminal", workingDirectory: String? = nil, portOrdinal: Int = 0, - configTemplate: ghostty_surface_config_s? = nil + configTemplate: ghostty_surface_config_s? = nil, + initialTerminalCommand: String? = nil, + initialTerminalEnvironment: [String: String] = [:] ) { self.id = UUID() self.portOrdinal = portOrdinal @@ -1180,7 +5081,9 @@ final class Workspace: Identifiable, ObservableObject { context: GHOSTTY_SURFACE_CONTEXT_TAB, configTemplate: configTemplate, workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, - portOrdinal: portOrdinal + portOrdinal: portOrdinal, + initialCommand: initialTerminalCommand, + initialEnvironmentOverrides: initialTerminalEnvironment ) panels[terminalPanel.id] = terminalPanel panelTitles[terminalPanel.id] = terminalPanel.displayTitle @@ -1234,6 +5137,11 @@ final class Workspace: Identifiable, ObservableObject { } } + deinit { + activeRemoteSessionControllerID = nil + remoteSessionController?.stop() + } + func refreshSplitButtonTooltips() { let tooltips = Self.currentSplitButtonTooltips() var configuration = bonsplitController.configuration @@ -1283,8 +5191,15 @@ final class Workspace: Identifiable, ObservableObject { private var debugLastDidMoveTabTimestamp: TimeInterval = 0 private var debugDidMoveTabEventCount: UInt64 = 0 #endif - private var geometryReconcileScheduled = false - private var geometryReconcileNeedsRerun = false + private var layoutFollowUpObservers: [NSObjectProtocol] = [] + private var layoutFollowUpPanelsCancellable: AnyCancellable? + private var layoutFollowUpTimeoutWorkItem: DispatchWorkItem? + private var layoutFollowUpReason: String? + private var layoutFollowUpTerminalFocusPanelId: UUID? + private var layoutFollowUpBrowserPanelId: UUID? + private var layoutFollowUpBrowserExitFocusPanelId: UUID? + private var layoutFollowUpNeedsGeometryPass = false + private var isAttemptingLayoutFollowUp = false private var isNormalizingPinnedTabOrder = false private var pendingNonFocusSplitFocusReassert: PendingNonFocusSplitFocusReassert? private var nonFocusSplitFocusReassertGeneration: UInt64 = 0 @@ -1404,8 +5319,8 @@ final class Workspace: Identifiable, ObservableObject { .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self, weak markdownPanel] newTitle in - guard let self = self, - let markdownPanel = markdownPanel, + guard let self, + let markdownPanel, let tabId = self.surfaceIdFromPanelId(markdownPanel.id) else { return } guard let existing = self.bonsplitController.tab(tabId) else { return } @@ -1423,6 +5338,24 @@ final class Workspace: Identifiable, ObservableObject { panelSubscriptions[markdownPanel.id] = subscription } + private func browserRemoteWorkspaceStatusSnapshot() -> BrowserRemoteWorkspaceStatus? { + guard let target = remoteDisplayTarget else { return nil } + return BrowserRemoteWorkspaceStatus( + target: target, + connectionState: remoteConnectionState, + heartbeatCount: remoteHeartbeatCount, + lastHeartbeatAt: remoteLastHeartbeatAt + ) + } + + private func applyBrowserRemoteWorkspaceStatusToPanels() { + let snapshot = browserRemoteWorkspaceStatusSnapshot() + for panel in panels.values { + guard let browserPanel = panel as? BrowserPanel else { continue } + browserPanel.setRemoteWorkspaceStatus(snapshot) + } + } + // MARK: - Panel Access func panel(for surfaceId: TabID) -> (any Panel)? { @@ -1879,7 +5812,7 @@ final class Workspace: Identifiable, ObservableObject { } func recomputeListeningPorts() { - let unique = Set(surfaceListeningPorts.values.flatMap { $0 }) + let unique = Set(surfaceListeningPorts.values.flatMap { $0 }).union(remoteForwardedPorts) let next = unique.sorted() if listeningPorts != next { listeningPorts = next @@ -1963,6 +5896,355 @@ final class Workspace: Identifiable, ObservableObject { } } + var isRemoteWorkspace: Bool { + remoteConfiguration != nil + } + + var remoteDisplayTarget: String? { + remoteConfiguration?.displayTarget + } + + var hasActiveRemoteTerminalSessions: Bool { + activeRemoteTerminalSessionCount > 0 + } + + func remoteStatusPayload() -> [String: Any] { + let heartbeatAgeSeconds: Any = { + guard let last = remoteLastHeartbeatAt else { return NSNull() } + return max(0, Date().timeIntervalSince(last)) + }() + let heartbeatTimestamp: Any = { + guard let last = remoteLastHeartbeatAt else { return NSNull() } + return Self.remoteHeartbeatDateFormatter.string(from: last) + }() + var payload: [String: Any] = [ + "enabled": remoteConfiguration != nil, + "state": remoteConnectionState.rawValue, + "connected": remoteConnectionState == .connected, + "active_terminal_sessions": activeRemoteTerminalSessionCount, + "daemon": remoteDaemonStatus.payload(), + "detected_ports": remoteDetectedPorts, + "forwarded_ports": remoteForwardedPorts, + "conflicted_ports": remotePortConflicts, + "detail": remoteConnectionDetail ?? NSNull(), + "heartbeat": [ + "count": remoteHeartbeatCount, + "last_seen_at": heartbeatTimestamp, + "age_seconds": heartbeatAgeSeconds, + ], + ] + if let endpoint = remoteProxyEndpoint { + payload["proxy"] = [ + "state": "ready", + "host": endpoint.host, + "port": endpoint.port, + "schemes": ["socks5", "http_connect"], + "url": "socks5://\(endpoint.host):\(endpoint.port)", + ] + } else { + let proxyState: String + if hasProxyOnlyRemoteSidebarError { + proxyState = "error" + } else { + switch remoteConnectionState { + case .connecting: + proxyState = "connecting" + case .error: + proxyState = "error" + default: + proxyState = "unavailable" + } + } + payload["proxy"] = [ + "state": proxyState, + "host": NSNull(), + "port": NSNull(), + "schemes": ["socks5", "http_connect"], + "url": NSNull(), + "error_code": proxyState == "error" ? "proxy_unavailable" : NSNull(), + ] + } + if let remoteConfiguration { + payload["destination"] = remoteConfiguration.destination + payload["port"] = remoteConfiguration.port ?? NSNull() + payload["has_identity_file"] = remoteConfiguration.identityFile != nil + payload["has_ssh_options"] = !remoteConfiguration.sshOptions.isEmpty + payload["local_proxy_port"] = remoteConfiguration.localProxyPort ?? NSNull() + } else { + payload["destination"] = NSNull() + payload["port"] = NSNull() + payload["has_identity_file"] = false + payload["has_ssh_options"] = false + payload["local_proxy_port"] = NSNull() + } + return payload + } + + func configureRemoteConnection(_ configuration: WorkspaceRemoteConfiguration, autoConnect: Bool = true) { + remoteConfiguration = configuration + seedInitialRemoteTerminalSessionIfNeeded(configuration: configuration) + remoteDetectedPorts = [] + remoteForwardedPorts = [] + remotePortConflicts = [] + remoteProxyEndpoint = nil + remoteHeartbeatCount = 0 + remoteLastHeartbeatAt = nil + remoteConnectionDetail = nil + remoteDaemonStatus = WorkspaceRemoteDaemonStatus() + statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) + statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) + remoteLastErrorFingerprint = nil + remoteLastDaemonErrorFingerprint = nil + remoteLastPortConflictFingerprint = nil + recomputeListeningPorts() + + let previousController = remoteSessionController + activeRemoteSessionControllerID = nil + remoteSessionController = nil + previousController?.stop() + applyRemoteProxyEndpointUpdate(nil) + applyBrowserRemoteWorkspaceStatusToPanels() + + guard autoConnect else { + remoteConnectionState = .disconnected + applyBrowserRemoteWorkspaceStatusToPanels() + return + } + + remoteConnectionState = .connecting + applyBrowserRemoteWorkspaceStatusToPanels() + let controllerID = UUID() + let controller = WorkspaceRemoteSessionController( + workspace: self, + configuration: configuration, + controllerID: controllerID + ) + activeRemoteSessionControllerID = controllerID + remoteSessionController = controller + controller.start() + } + + func reconnectRemoteConnection() { + guard let configuration = remoteConfiguration else { return } + configureRemoteConnection(configuration, autoConnect: true) + } + + func disconnectRemoteConnection(clearConfiguration: Bool = false) { + let previousController = remoteSessionController + activeRemoteSessionControllerID = nil + remoteSessionController = nil + previousController?.stop() + activeRemoteTerminalSurfaceIds.removeAll() + activeRemoteTerminalSessionCount = 0 + remoteDetectedPorts = [] + remoteForwardedPorts = [] + remotePortConflicts = [] + remoteProxyEndpoint = nil + remoteHeartbeatCount = 0 + remoteLastHeartbeatAt = nil + remoteConnectionState = .disconnected + remoteConnectionDetail = nil + remoteDaemonStatus = WorkspaceRemoteDaemonStatus() + statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) + statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) + remoteLastErrorFingerprint = nil + remoteLastDaemonErrorFingerprint = nil + remoteLastPortConflictFingerprint = nil + if clearConfiguration { + remoteConfiguration = nil + } + applyRemoteProxyEndpointUpdate(nil) + applyBrowserRemoteWorkspaceStatusToPanels() + recomputeListeningPorts() + } + + private func clearRemoteConfigurationIfWorkspaceBecameLocal() { + guard panels.isEmpty, remoteConfiguration != nil else { return } + disconnectRemoteConnection(clearConfiguration: true) + } + + private func seedInitialRemoteTerminalSessionIfNeeded(configuration: WorkspaceRemoteConfiguration) { + guard configuration.terminalStartupCommand?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + return + } + guard activeRemoteTerminalSurfaceIds.isEmpty else { return } + let terminalIds = panels.compactMap { panelId, panel in + panel is TerminalPanel ? panelId : nil + } + guard terminalIds.count == 1, let initialPanelId = terminalIds.first else { return } + trackRemoteTerminalSurface(initialPanelId) + } + + private func trackRemoteTerminalSurface(_ panelId: UUID) { + guard activeRemoteTerminalSurfaceIds.insert(panelId).inserted else { return } + activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count + } + + private func untrackRemoteTerminalSurface(_ panelId: UUID) { + guard activeRemoteTerminalSurfaceIds.remove(panelId) != nil else { return } + activeRemoteTerminalSessionCount = activeRemoteTerminalSurfaceIds.count + maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() + } + + private func maybeDemoteRemoteWorkspaceAfterSSHSessionEnded() { + guard activeRemoteTerminalSurfaceIds.isEmpty, remoteConfiguration != nil else { return } + let hasBrowserPanels = panels.values.contains { $0 is BrowserPanel } + if !hasBrowserPanels { + if remoteConnectionState == .error || remoteDaemonStatus.state == .error || remoteConnectionState == .connecting { + return + } + disconnectRemoteConnection(clearConfiguration: true) + } + } + + func markRemoteTerminalSessionEnded(surfaceId: UUID, relayPort: Int?) { + guard let relayPort, + relayPort > 0, + remoteConfiguration?.relayPort == relayPort else { + return + } + untrackRemoteTerminalSurface(surfaceId) + } + + func teardownRemoteConnection() { + disconnectRemoteConnection(clearConfiguration: true) + } + + func applyRemoteConnectionStateUpdate( + _ state: WorkspaceRemoteConnectionState, + detail: String?, + target: String + ) { + let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines) + let proxyOnlyError = trimmedDetail.map(Self.isProxyOnlyRemoteError) ?? false + let preserveConnectedStateForRetry = + state == .connecting && preservesSSHTerminalConnection && hasProxyOnlyRemoteSidebarError + let effectiveState: WorkspaceRemoteConnectionState + if state == .error && proxyOnlyError && preservesSSHTerminalConnection { + effectiveState = .connected + } else if preserveConnectedStateForRetry { + effectiveState = .connected + } else { + effectiveState = state + } + + remoteConnectionState = effectiveState + remoteConnectionDetail = detail + applyBrowserRemoteWorkspaceStatusToPanels() + + if let trimmedDetail, !trimmedDetail.isEmpty, (state == .error || proxyOnlyError) { + let statusPrefix = proxyOnlyError ? "Remote proxy unavailable" : "SSH error" + let statusIcon = proxyOnlyError ? "exclamationmark.triangle.fill" : "network.slash" + let notificationTitle = proxyOnlyError ? "Remote Proxy Unavailable" : "Remote SSH Error" + let logSource = proxyOnlyError ? "remote-proxy" : "remote" + statusEntries[Self.remoteErrorStatusKey] = SidebarStatusEntry( + key: Self.remoteErrorStatusKey, + value: "\(statusPrefix) (\(target)): \(trimmedDetail)", + icon: statusIcon, + color: nil, + timestamp: Date() + ) + + let fingerprint = "connection:\(trimmedDetail)" + if remoteLastErrorFingerprint != fingerprint { + remoteLastErrorFingerprint = fingerprint + appendSidebarLog( + message: "\(statusPrefix) (\(target)): \(trimmedDetail)", + level: .error, + source: logSource + ) + AppDelegate.shared?.notificationStore?.addNotification( + tabId: id, + surfaceId: nil, + title: notificationTitle, + subtitle: target, + body: trimmedDetail + ) + } + return + } + + if !preserveConnectedStateForRetry && state != .error { + statusEntries.removeValue(forKey: Self.remoteErrorStatusKey) + remoteLastErrorFingerprint = nil + } + } + + fileprivate func applyRemoteDaemonStatusUpdate(_ status: WorkspaceRemoteDaemonStatus, target: String) { + remoteDaemonStatus = status + applyBrowserRemoteWorkspaceStatusToPanels() + guard status.state == .error else { + remoteLastDaemonErrorFingerprint = nil + return + } + let trimmedDetail = status.detail?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "remote daemon error" + let fingerprint = "daemon:\(trimmedDetail)" + guard remoteLastDaemonErrorFingerprint != fingerprint else { return } + remoteLastDaemonErrorFingerprint = fingerprint + appendSidebarLog( + message: "Remote daemon error (\(target)): \(trimmedDetail)", + level: .error, + source: "remote-daemon" + ) + } + + fileprivate func applyRemoteProxyEndpointUpdate(_ endpoint: BrowserProxyEndpoint?) { + remoteProxyEndpoint = endpoint + for panel in panels.values { + guard let browserPanel = panel as? BrowserPanel else { continue } + browserPanel.setRemoteProxyEndpoint(endpoint) + } + applyBrowserRemoteWorkspaceStatusToPanels() + } + + fileprivate func applyRemoteHeartbeatUpdate(count: Int, lastSeenAt: Date?) { + remoteHeartbeatCount = max(0, count) + remoteLastHeartbeatAt = lastSeenAt + applyBrowserRemoteWorkspaceStatusToPanels() + } + + fileprivate func applyRemotePortsSnapshot(detected: [Int], forwarded: [Int], conflicts: [Int], target: String) { + remoteDetectedPorts = detected + remoteForwardedPorts = forwarded + remotePortConflicts = conflicts + recomputeListeningPorts() + + if conflicts.isEmpty { + statusEntries.removeValue(forKey: Self.remotePortConflictStatusKey) + remoteLastPortConflictFingerprint = nil + return + } + + let conflictsList = conflicts.map { ":\($0)" }.joined(separator: ", ") + statusEntries[Self.remotePortConflictStatusKey] = SidebarStatusEntry( + key: Self.remotePortConflictStatusKey, + value: "SSH port conflicts (\(target)): \(conflictsList)", + icon: "exclamationmark.triangle.fill", + color: nil, + timestamp: Date() + ) + + let fingerprint = conflicts.map(String.init).joined(separator: ",") + guard remoteLastPortConflictFingerprint != fingerprint else { return } + remoteLastPortConflictFingerprint = fingerprint + appendSidebarLog( + message: "Port conflicts while forwarding \(target): \(conflictsList)", + level: .warning, + source: "remote-forward" + ) + } + + private func appendSidebarLog(message: String, level: SidebarLogLevel, source: String?) { + let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + logEntries.append(SidebarLogEntry(message: trimmed, level: level, source: source, timestamp: Date())) + let configuredLimit = UserDefaults.standard.object(forKey: "sidebarMaxLogEntries") as? Int ?? 50 + let limit = max(1, min(500, configuredLimit)) + if logEntries.count > limit { + logEntries.removeFirst(logEntries.count - limit) + } + } + // MARK: - Panel Operations private func seedTerminalInheritanceFontPoints( @@ -2149,6 +6431,7 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) + let remoteTerminalStartupCommand = remoteTerminalStartupCommand() // Inherit working directory: prefer the source panel's reported cwd, // then its requested startup cwd if shell integration has not reported @@ -2179,10 +6462,14 @@ final class Workspace: Identifiable, ObservableObject { context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, workingDirectory: splitWorkingDirectory, - portOrdinal: portOrdinal + portOrdinal: portOrdinal, + initialCommand: remoteTerminalStartupCommand ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + if remoteTerminalStartupCommand != nil { + trackRemoteTerminalSurface(newPanel.id) + } seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit @@ -2208,6 +6495,9 @@ final class Workspace: Identifiable, ObservableObject { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) + if remoteTerminalStartupCommand != nil { + untrackRemoteTerminalSurface(newPanel.id) + } terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -2248,8 +6538,11 @@ final class Workspace: Identifiable, ObservableObject { startupEnvironment: [String: String] = [:] ) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let previousFocusedPanelId = focusedPanelId + let previousHostedView = focusedTerminalPanel?.hostedView let inheritedConfig = inheritedTerminalConfig(inPane: paneId) + let remoteTerminalStartupCommand = remoteTerminalStartupCommand() // Create new terminal panel let newPanel = TerminalPanel( @@ -2257,11 +6550,15 @@ final class Workspace: Identifiable, ObservableObject { context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, workingDirectory: workingDirectory, - additionalEnvironment: startupEnvironment, - portOrdinal: portOrdinal + portOrdinal: portOrdinal, + initialCommand: remoteTerminalStartupCommand, + additionalEnvironment: startupEnvironment ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + if remoteTerminalStartupCommand != nil { + trackRemoteTerminalSurface(newPanel.id) + } seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit @@ -2275,6 +6572,9 @@ final class Workspace: Identifiable, ObservableObject { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) + if remoteTerminalStartupCommand != nil { + untrackRemoteTerminalSurface(newPanel.id) + } terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -2289,10 +6589,25 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.selectTab(newTabId) newPanel.focus() applyTabSelection(tabId: newTabId, inPane: paneId) + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: newPanel.id, + previousHostedView: previousHostedView + ) } return newPanel } + private func remoteTerminalStartupCommand() -> String? { + guard let command = remoteConfiguration?.terminalStartupCommand? + .trimmingCharacters(in: .whitespacesAndNewlines), + !command.isEmpty else { + return nil + } + return command + } + /// Create a new browser panel split @discardableResult func newBrowserSplit( @@ -2323,7 +6638,10 @@ final class Workspace: Identifiable, ObservableObject { preferredProfileID: preferredProfileID, sourcePanelId: panelId ), - initialURL: url + initialURL: url, + proxyEndpoint: remoteProxyEndpoint, + isRemoteWorkspace: isRemoteWorkspace, + remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle @@ -2369,6 +6687,7 @@ final class Workspace: Identifiable, ObservableObject { } installBrowserPanelSubscription(browserPanel) + browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) return browserPanel } @@ -2388,6 +6707,8 @@ final class Workspace: Identifiable, ObservableObject { ) -> BrowserPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) let sourcePanelId = effectiveSelectedPanelId(inPane: paneId) + let previousFocusedPanelId = focusedPanelId + let previousHostedView = focusedTerminalPanel?.hostedView let browserPanel = BrowserPanel( workspaceId: id, @@ -2396,7 +6717,10 @@ final class Workspace: Identifiable, ObservableObject { sourcePanelId: sourcePanelId ), initialURL: url, - bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce + bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce, + proxyEndpoint: remoteProxyEndpoint, + isRemoteWorkspace: isRemoteWorkspace, + remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil ) panels[browserPanel.id] = browserPanel panelTitles[browserPanel.id] = browserPanel.displayTitle @@ -2430,16 +6754,20 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.selectTab(newTabId) browserPanel.focus() applyTabSelection(tabId: newTabId, inPane: paneId) + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: browserPanel.id, + previousHostedView: previousHostedView + ) } installBrowserPanelSubscription(browserPanel) + browserPanel.setRemoteWorkspaceStatus(browserRemoteWorkspaceStatusSnapshot()) return browserPanel } - // MARK: - Markdown Panel Creation - - /// Create a new markdown panel split from an existing panel. func newMarkdownSplit( from panelId: UUID, orientation: SplitOrientation, @@ -2447,7 +6775,6 @@ final class Workspace: Identifiable, ObservableObject { filePath: String, focus: Bool = true ) -> MarkdownPanel? { - // Find the pane containing the source panel guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } var sourcePaneId: PaneID? for paneId in bonsplitController.allPaneIds { @@ -2460,12 +6787,10 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } - // Create markdown panel let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) panels[markdownPanel.id] = markdownPanel panelTitles[markdownPanel.id] = markdownPanel.displayTitle - // Pre-generate the bonsplit tab ID so the mapping exists before the split lands. let newTab = Bonsplit.Tab( title: markdownPanel.displayTitle, icon: markdownPanel.displayIcon, @@ -2477,8 +6802,6 @@ final class Workspace: Identifiable, ObservableObject { surfaceIdToPanelId[newTab.id] = markdownPanel.id let previousFocusedPanelId = focusedPanelId - // Create the split with the markdown tab already present in the new pane. - // Mark this split as programmatic so didSplitPane doesn't auto-create a terminal. isProgrammaticSplit = true defer { isProgrammaticSplit = false } guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else { @@ -2488,7 +6811,6 @@ final class Workspace: Identifiable, ObservableObject { return nil } - // Suppress old view's becomeFirstResponder during reparenting. let previousHostedView = focusedTerminalPanel?.hostedView if focus { previousHostedView?.suppressReparentFocus() @@ -2505,11 +6827,9 @@ final class Workspace: Identifiable, ObservableObject { } installMarkdownPanelSubscription(markdownPanel) - return markdownPanel } - /// Create a new markdown surface (tab) in the specified pane. @discardableResult func newMarkdownSurface( inPane paneId: PaneID, @@ -2517,6 +6837,8 @@ final class Workspace: Identifiable, ObservableObject { focus: Bool? = nil ) -> MarkdownPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) + let previousFocusedPanelId = focusedPanelId + let previousHostedView = focusedTerminalPanel?.hostedView let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath) panels[markdownPanel.id] = markdownPanel @@ -2537,16 +6859,19 @@ final class Workspace: Identifiable, ObservableObject { } surfaceIdToPanelId[newTabId] = markdownPanel.id - - // Match terminal behavior: enforce deterministic selection + focus. if shouldFocusNewTab { bonsplitController.focusPane(paneId) bonsplitController.selectTab(newTabId) applyTabSelection(tabId: newTabId, inPane: paneId) + } else { + preserveFocusAfterNonFocusSplit( + preferredPanelId: previousFocusedPanelId, + splitPanelId: markdownPanel.id, + previousHostedView: previousHostedView + ) } installMarkdownPanelSubscription(markdownPanel) - return markdownPanel } @@ -2574,29 +6899,12 @@ final class Workspace: Identifiable, ObservableObject { /// Close a panel. /// Returns true when a bonsplit tab close request was issued. func closePanel(_ panelId: UUID, force: Bool = false) -> Bool { -#if DEBUG - let mappedTabIdBeforeClose = surfaceIdFromPanelId(panelId) - dlog( - "surface.close.request panel=\(panelId.uuidString.prefix(5)) " + - "force=\(force ? 1 : 0) mappedTab=\(mappedTabIdBeforeClose.map { String(String(describing: $0).prefix(5)) } ?? "nil") " + - "focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " + - "focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil") " + - "\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" - ) -#endif if let tabId = surfaceIdFromPanelId(panelId) { if force { forceCloseTabIds.insert(tabId) } // Close the tab in bonsplit (this triggers delegate callback) - let closed = bonsplitController.closeTab(tabId) -#if DEBUG - dlog( - "surface.close.request.done panel=\(panelId.uuidString.prefix(5)) " + - "tab=\(String(describing: tabId).prefix(5)) closed=\(closed ? 1 : 0) force=\(force ? 1 : 0)" - ) -#endif - return closed + return bonsplitController.closeTab(tabId) } // Mapping can transiently drift during split-tree mutations. If the target panel is @@ -2628,38 +6936,12 @@ final class Workspace: Identifiable, ObservableObject { dlog( "surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " + "selectedTab=\(String(describing: selected.id).prefix(5)) " + - "closed=\(closed ? 1 : 0) " + - "\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" + "closed=\(closed ? 1 : 0)" ) #endif return closed } -#if DEBUG - private func debugPanelLifecycleState(panelId: UUID, panel: (any Panel)?) -> String { - guard let panel else { return "panelState=missing" } - if let terminal = panel as? TerminalPanel { - let hosted = terminal.hostedView - let frame = String(format: "%.1fx%.1f", hosted.frame.width, hosted.frame.height) - let bounds = String(format: "%.1fx%.1f", hosted.bounds.width, hosted.bounds.height) - let hasRuntimeSurface = terminal.surface.surface != nil ? 1 : 0 - return - "panelState=terminal panel=\(panelId.uuidString.prefix(5)) " + - "surface=\(terminal.id.uuidString.prefix(5)) runtimeSurface=\(hasRuntimeSurface) " + - "inWindow=\(hosted.window != nil ? 1 : 0) hasSuperview=\(hosted.superview != nil ? 1 : 0) " + - "hidden=\(hosted.isHidden ? 1 : 0) frame=\(frame) bounds=\(bounds)" - } - if let browser = panel as? BrowserPanel { - let webView = browser.webView - let frame = String(format: "%.1fx%.1f", webView.frame.width, webView.frame.height) - return - "panelState=browser panel=\(panelId.uuidString.prefix(5)) " + - "webInWindow=\(webView.window != nil ? 1 : 0) webHasSuperview=\(webView.superview != nil ? 1 : 0) frame=\(frame)" - } - return "panelState=\(String(describing: type(of: panel))) panel=\(panelId.uuidString.prefix(5))" - } -#endif - func paneId(forPanelId panelId: UUID) -> PaneID? { guard let tabId = surfaceIdFromPanelId(panelId) else { return nil } return bonsplitController.allPaneIds.first { paneId in @@ -2867,7 +7149,6 @@ final class Workspace: Identifiable, ObservableObject { in: bonsplitController.treeSnapshot() ) let resolvedURL = browserPanel.currentURL - ?? browserPanel.webView.url ?? browserPanel.preferredURLStringForOmnibar().flatMap(URL.init(string:)) pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot( @@ -3066,7 +7347,13 @@ final class Workspace: Identifiable, ObservableObject { if let terminalPanel = detached.panel as? TerminalPanel { terminalPanel.updateWorkspaceId(id) } else if let browserPanel = detached.panel as? BrowserPanel { - browserPanel.updateWorkspaceId(id) + browserPanel.reattachToWorkspace( + id, + isRemoteWorkspace: isRemoteWorkspace, + remoteWebsiteDataStoreIdentifier: isRemoteWorkspace ? id : nil, + proxyEndpoint: remoteProxyEndpoint, + remoteStatus: browserRemoteWorkspaceStatusSnapshot() + ) installBrowserPanelSubscription(browserPanel) } @@ -3326,29 +7613,15 @@ final class Workspace: Identifiable, ObservableObject { ) } + if let browserPanel = panels[panelId] as? BrowserPanel { + maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger) + } + if trigger == .terminalFirstResponder, panels[panelId] is TerminalPanel { - scheduleTerminalFirstResponderReassert(panelId: panelId) - } - } - - /// A terminal click can arrive while AppKit and bonsplit already look converged, which takes - /// the re-entrant focus path and skips the normal explicit `ensureFocus` call. Re-assert focus - /// on the next couple of turns so stale callbacks from split churn can't leave keyboard input - /// attached to the wrong surface (#1147). - private func scheduleTerminalFirstResponderReassert(panelId: UUID, remainingPasses: Int = 2) { - guard remainingPasses > 0 else { return } - DispatchQueue.main.async { [weak self] in - guard let self, - self.focusedPanelId == panelId, - let terminalPanel = self.terminalPanel(for: panelId) else { - return - } - - terminalPanel.hostedView.ensureFocus(for: self.id, surfaceId: panelId) - self.scheduleTerminalFirstResponderReassert( - panelId: panelId, - remainingPasses: remainingPasses - 1 + beginEventDrivenLayoutFollowUp( + reason: "workspace.focusPanel.terminal", + terminalFocusPanelId: panelId ) } } @@ -3469,22 +7742,18 @@ final class Workspace: Identifiable, ObservableObject { focusPanel(panelId) reconcileTerminalPortalVisibilityForCurrentRenderedLayout() reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: "workspace.toggleSplitZoom") - scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(remainingPasses: 4) - scheduleBrowserPortalVisibilityReconcileAfterSplitZoom( - remainingPasses: 4, - reason: "workspace.toggleSplitZoom" - ) - scheduleTerminalGeometryReconcile() if let browserPanel = browserPanel(for: panelId) { browserPanel.preparePortalHostReplacementForNextDistinctClaim( inPane: paneId, reason: "workspace.toggleSplitZoom" ) - scheduleBrowserPortalReconcileAfterSplitZoom(panelId: panelId, remainingPasses: 4) - if wasSplitZoomed && !bonsplitController.isSplitZoomed { - scheduleBrowserSplitZoomExitFocusReassert(panelId: panelId, remainingPasses: 4) - } } + beginEventDrivenLayoutFollowUp( + reason: "workspace.toggleSplitZoom", + browserPanelId: browserPanel(for: panelId) != nil ? panelId : nil, + browserExitFocusPanelId: (wasSplitZoomed && !bonsplitController.isSplitZoomed) ? panelId : nil, + includeGeometry: true + ) return true } @@ -3525,7 +7794,7 @@ final class Workspace: Identifiable, ObservableObject { if requiresSplit && !isSplit { return } - terminalPanel.triggerNotificationDismissFlash() + terminalPanel.triggerFlash() } func triggerDebugFlash(panelId: UUID) { @@ -3545,16 +7814,10 @@ final class Workspace: Identifiable, ObservableObject { } } - /// Hide all browser portal views for this workspace. - /// Called before the workspace is unmounted so a portal-hosted WKWebView - /// cannot remain visible after this workspace stops being selected. func hideAllBrowserPortalViews() { for panel in panels.values { guard let browser = panel as? BrowserPanel else { continue } - BrowserWindowPortalRegistry.hide( - webView: browser.webView, - source: "workspaceRetire" - ) + browser.hideBrowserPortalView(source: "workspaceRetire") } } @@ -3673,6 +7936,243 @@ final class Workspace: Identifiable, ObservableObject { } } + private func beginEventDrivenLayoutFollowUp( + reason: String, + browserPanelId: UUID? = nil, + browserExitFocusPanelId: UUID? = nil, + terminalFocusPanelId: UUID? = nil, + includeGeometry: Bool = false + ) { + layoutFollowUpReason = reason + if let browserPanelId { + layoutFollowUpBrowserPanelId = browserPanelId + } + if let browserExitFocusPanelId { + layoutFollowUpBrowserExitFocusPanelId = browserExitFocusPanelId + } + if let terminalFocusPanelId { + layoutFollowUpTerminalFocusPanelId = terminalFocusPanelId + } + layoutFollowUpNeedsGeometryPass = layoutFollowUpNeedsGeometryPass || includeGeometry + + if layoutFollowUpTimeoutWorkItem == nil { + installLayoutFollowUpObservers() + } + refreshLayoutFollowUpTimeout() + attemptEventDrivenLayoutFollowUp() + } + + private func installLayoutFollowUpObservers() { + guard layoutFollowUpTimeoutWorkItem == nil else { return } + + func enqueueAttempt() { + DispatchQueue.main.async { [weak self] in + self?.attemptEventDrivenLayoutFollowUp() + } + } + + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: NSWindow.didUpdateNotification, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .terminalSurfaceDidBecomeReady, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .terminalSurfaceHostedViewDidMoveToWindow, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .terminalPortalVisibilityDidChange, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .browserPortalRegistryDidChange, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .ghosttyDidBecomeFirstResponderSurface, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpObservers.append(NotificationCenter.default.addObserver( + forName: .browserDidBecomeFirstResponderWebView, + object: nil, + queue: .main + ) { _ in + enqueueAttempt() + }) + layoutFollowUpPanelsCancellable = $panels + .map { _ in () } + .sink { _ in + enqueueAttempt() + } + } + + private func refreshLayoutFollowUpTimeout() { + layoutFollowUpTimeoutWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.clearLayoutFollowUp() + } + layoutFollowUpTimeoutWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: workItem) + } + + private func clearLayoutFollowUp() { + layoutFollowUpTimeoutWorkItem?.cancel() + layoutFollowUpTimeoutWorkItem = nil + layoutFollowUpObservers.forEach { NotificationCenter.default.removeObserver($0) } + layoutFollowUpObservers.removeAll() + layoutFollowUpPanelsCancellable?.cancel() + layoutFollowUpPanelsCancellable = nil + layoutFollowUpReason = nil + layoutFollowUpTerminalFocusPanelId = nil + layoutFollowUpBrowserPanelId = nil + layoutFollowUpBrowserExitFocusPanelId = nil + layoutFollowUpNeedsGeometryPass = false + } + + private func flushWorkspaceWindowLayouts() { + for window in NSApp.windows { + window.contentView?.layoutSubtreeIfNeeded() + window.contentView?.displayIfNeeded() + } + } + + private func browserPortalAnchorReady(for browserPanel: BrowserPanel) -> Bool { + let anchorView = browserPanel.portalAnchorView + return + anchorView.window != nil && + anchorView.superview != nil && + anchorView.bounds.width > 1 && + anchorView.bounds.height > 1 + } + + private func browserPortalReady(for browserPanel: BrowserPanel) -> Bool { + browserPortalAnchorReady(for: browserPanel) && + browserPanel.webView.window != nil && + browserPanel.webView.superview != nil && + BrowserWindowPortalRegistry.isWebView(browserPanel.webView, boundTo: browserPanel.portalAnchorView) + } + + private func browserSplitZoomExitFocusNeedsFollowUp(panelId: UUID) -> Bool { + guard let browserPanel = browserPanel(for: panelId), + let paneId = paneId(forPanelId: panelId), + let tabId = surfaceIdFromPanelId(panelId) else { + return false + } + let selectionConverged = + bonsplitController.focusedPaneId == paneId && + bonsplitController.selectedTab(inPane: paneId)?.id == tabId + return !selectionConverged || !browserPortalAnchorReady(for: browserPanel) + } + + private func attemptEventDrivenLayoutFollowUp() { + guard layoutFollowUpTimeoutWorkItem != nil, !isAttemptingLayoutFollowUp else { return } + isAttemptingLayoutFollowUp = true + defer { isAttemptingLayoutFollowUp = false } + + flushWorkspaceWindowLayouts() + + if layoutFollowUpNeedsGeometryPass { + layoutFollowUpNeedsGeometryPass = reconcileTerminalGeometryPass() + } + + if let terminalFocusPanelId = layoutFollowUpTerminalFocusPanelId { + if let terminalPanel = terminalPanel(for: terminalFocusPanelId), + focusedPanelId == terminalFocusPanelId { + terminalPanel.hostedView.ensureFocus(for: id, surfaceId: terminalFocusPanelId) + if terminalPanel.hostedView.isSurfaceViewFirstResponder() { + layoutFollowUpTerminalFocusPanelId = nil + } + } else if terminalPanel(for: terminalFocusPanelId) == nil { + layoutFollowUpTerminalFocusPanelId = nil + } + } + + reconcileTerminalPortalVisibilityForCurrentRenderedLayout() + let terminalPortalPending = terminalPortalVisibilityNeedsFollowUp() + + let reason = layoutFollowUpReason ?? "workspace.layout" + reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) + let browserVisibilityPending = browserPortalVisibilityNeedsFollowUp() + + if let browserPanelId = layoutFollowUpBrowserPanelId { + if let browserPanel = browserPanel(for: browserPanelId) { + if browserPortalAnchorReady(for: browserPanel) { + BrowserWindowPortalRegistry.synchronizeForAnchor(browserPanel.portalAnchorView) + BrowserWindowPortalRegistry.refresh( + webView: browserPanel.webView, + reason: reason + ) + } + if browserPortalReady(for: browserPanel) { + layoutFollowUpBrowserPanelId = nil + } + } else { + layoutFollowUpBrowserPanelId = nil + } + } + + if let browserExitFocusPanelId = layoutFollowUpBrowserExitFocusPanelId { + if browserSplitZoomExitFocusNeedsFollowUp(panelId: browserExitFocusPanelId) { + if browserPanel(for: browserExitFocusPanelId) != nil { + focusPanel(browserExitFocusPanelId) + scheduleFocusReconcile() + } else { + layoutFollowUpBrowserExitFocusPanelId = nil + } + } else { + layoutFollowUpBrowserExitFocusPanelId = nil + } + } + + let terminalFocusPending: Bool = { + guard let panelId = layoutFollowUpTerminalFocusPanelId, + let terminalPanel = terminalPanel(for: panelId) else { + return false + } + return focusedPanelId != panelId || !terminalPanel.hostedView.isSurfaceViewFirstResponder() + }() + let browserPanelPending: Bool = { + guard let panelId = layoutFollowUpBrowserPanelId, + let browserPanel = browserPanel(for: panelId) else { + return false + } + return !browserPortalReady(for: browserPanel) + }() + let browserExitPending = layoutFollowUpBrowserExitFocusPanelId != nil + let needsMoreWork = + layoutFollowUpNeedsGeometryPass || + terminalPortalPending || + browserVisibilityPending || + terminalFocusPending || + browserPanelPending || + browserExitPending + + if !needsMoreWork { + clearLayoutFollowUp() + } + } + /// Reconcile remaining terminal view geometries after split topology changes. /// This keeps AppKit bounds and Ghostty surface sizes in sync in the next runloop turn. private func reconcileTerminalGeometryPass() -> Bool { @@ -3697,11 +8197,11 @@ final class Workspace: Identifiable, ObservableObject { needsFollowUpPass = true } - let geometryChanged = hostedView.reconcileGeometryNow() + hostedView.reconcileGeometryNow() // Re-check surface after reconcileGeometryNow() which can trigger AppKit // layout and view lifecycle changes that free surfaces (#432). - if geometryChanged, terminalPanel.surface.surface != nil { - terminalPanel.surface.forceRefresh(reason: "workspace.geometryReconcile") + if terminalPanel.surface.surface != nil { + terminalPanel.surface.forceRefresh() } if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds { terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() @@ -3712,39 +8212,11 @@ final class Workspace: Identifiable, ObservableObject { return needsFollowUpPass } - private func runScheduledTerminalGeometryReconcile(remainingPasses: Int) { - guard remainingPasses > 0 else { - geometryReconcileScheduled = false - geometryReconcileNeedsRerun = false - return - } - - let needsFollowUpPass = reconcileTerminalGeometryPass() - let shouldRunAgain = geometryReconcileNeedsRerun || needsFollowUpPass - - if shouldRunAgain, remainingPasses > 1 { - geometryReconcileNeedsRerun = false - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.runScheduledTerminalGeometryReconcile(remainingPasses: remainingPasses - 1) - } - return - } - - geometryReconcileScheduled = false - geometryReconcileNeedsRerun = false - } - private func scheduleTerminalGeometryReconcile() { - guard !geometryReconcileScheduled else { - geometryReconcileNeedsRerun = true - return - } - geometryReconcileScheduled = true - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.runScheduledTerminalGeometryReconcile(remainingPasses: 4) - } + beginEventDrivenLayoutFollowUp( + reason: "workspace.geometry", + includeGeometry: true + ) } private func renderedVisiblePanelIdsForCurrentLayout() -> Set<UUID> { @@ -3806,26 +8278,6 @@ final class Workspace: Identifiable, ObservableObject { return false } - private func scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(remainingPasses: Int) { - guard remainingPasses > 0 else { return } - DispatchQueue.main.async { [weak self] in - guard let self else { return } - - for window in NSApp.windows { - window.contentView?.layoutSubtreeIfNeeded() - window.contentView?.displayIfNeeded() - } - - self.reconcileTerminalPortalVisibilityForCurrentRenderedLayout() - - if self.terminalPortalVisibilityNeedsFollowUp(), remainingPasses > 1 { - self.scheduleTerminalPortalVisibilityReconcileAfterSplitZoom( - remainingPasses: remainingPasses - 1 - ) - } - } - } - private func reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: String) { let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout() @@ -3888,107 +8340,6 @@ final class Workspace: Identifiable, ObservableObject { return false } - private func scheduleBrowserPortalVisibilityReconcileAfterSplitZoom( - remainingPasses: Int, - reason: String - ) { - guard remainingPasses > 0 else { return } - DispatchQueue.main.async { [weak self] in - guard let self else { return } - - for window in NSApp.windows { - window.contentView?.layoutSubtreeIfNeeded() - window.contentView?.displayIfNeeded() - } - - self.reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason) - - if self.browserPortalVisibilityNeedsFollowUp(), remainingPasses > 1 { - self.scheduleBrowserPortalVisibilityReconcileAfterSplitZoom( - remainingPasses: remainingPasses - 1, - reason: reason - ) - } - } - } - - // Browser panes host WKWebView in the window portal. After pane zoom toggles, - // force a few post-layout sync passes so the portal does not outlive the omnibar chrome. - private func scheduleBrowserPortalReconcileAfterSplitZoom(panelId: UUID, remainingPasses: Int) { - guard remainingPasses > 0 else { return } - DispatchQueue.main.async { [weak self] in - guard let self, let browserPanel = self.browserPanel(for: panelId) else { return } - - for window in NSApp.windows { - window.contentView?.layoutSubtreeIfNeeded() - window.contentView?.displayIfNeeded() - } - - let anchorView = browserPanel.portalAnchorView - let anchorReady = - anchorView.window != nil && - anchorView.superview != nil && - anchorView.bounds.width > 1 && - anchorView.bounds.height > 1 - - if anchorReady { - BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView) - BrowserWindowPortalRegistry.refresh( - webView: browserPanel.webView, - reason: "workspace.toggleSplitZoom" - ) - } - - let portalNeedsFollowUpPass = - !anchorReady || - browserPanel.webView.window == nil || - browserPanel.webView.superview == nil - if portalNeedsFollowUpPass { - self.scheduleBrowserPortalReconcileAfterSplitZoom( - panelId: panelId, - remainingPasses: remainingPasses - 1 - ) - } - } - } - - // Browser panes can briefly keep the portal-hosted WKWebView visible while Bonsplit is - // still rebuilding the unzoomed pane host. Reassert pane/tab selection after layout settles - // so the SwiftUI chrome does not remain hidden until another browser focus command runs. - private func scheduleBrowserSplitZoomExitFocusReassert(panelId: UUID, remainingPasses: Int) { - guard remainingPasses > 0 else { return } - DispatchQueue.main.async { [weak self] in - guard let self, self.browserPanel(for: panelId) != nil else { return } - guard let paneId = self.paneId(forPanelId: panelId), - let tabId = self.surfaceIdFromPanelId(panelId) else { return } - - let selectionConverged = - self.bonsplitController.focusedPaneId == paneId && - self.bonsplitController.selectedTab(inPane: paneId)?.id == tabId - let anchorReady: Bool = { - guard let browserPanel = self.browserPanel(for: panelId) else { return false } - let anchorView = browserPanel.portalAnchorView - return - anchorView.window != nil && - anchorView.superview != nil && - anchorView.bounds.width > 1 && - anchorView.bounds.height > 1 - }() - - if !selectionConverged { - self.focusPanel(panelId) - self.scheduleFocusReconcile() - } - - if !selectionConverged || !anchorReady { - self.scheduleBrowserSplitZoomExitFocusReassert( - panelId: panelId, - remainingPasses: remainingPasses - 1 - ) - } - } - } - private func scheduleMovedTerminalRefresh(panelId: UUID) { guard terminalPanel(for: panelId) != nil else { return } @@ -3999,9 +8350,9 @@ final class Workspace: Identifiable, ObservableObject { let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in DispatchQueue.main.asyncAfter(deadline: .now() + delay) { guard let self, let panel = self.terminalPanel(for: panelId) else { return } - let geometryChanged = panel.hostedView.reconcileGeometryNow() - if geometryChanged, panel.surface.surface != nil { - panel.surface.forceRefresh(reason: "workspace.movedTerminalRefresh") + panel.hostedView.reconcileGeometryNow() + if panel.surface.surface != nil { + panel.surface.forceRefresh() } if panel.surface.surface == nil { panel.surface.requestBackgroundSurfaceStartIfNeeded() @@ -4081,15 +8432,15 @@ final class Workspace: Identifiable, ObservableObject { let panel = panels[panelId] else { return } let alert = NSAlert() - alert.messageText = String(localized: "dialog.renameTab.title", defaultValue: "Rename Tab") - alert.informativeText = String(localized: "dialog.renameTab.message", defaultValue: "Enter a custom name for this tab.") + alert.messageText = "Rename Tab" + alert.informativeText = "Enter a custom name for this tab." let currentTitle = panelCustomTitles[panelId] ?? panelTitles[panelId] ?? panel.displayTitle let input = NSTextField(string: currentTitle) - input.placeholderString = String(localized: "dialog.renameTab.placeholder", defaultValue: "Tab name") + input.placeholderString = "Tab name" input.frame = NSRect(x: 0, y: 0, width: 240, height: 22) alert.accessoryView = input - alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.addButton(withTitle: "Rename") + alert.addButton(withTitle: "Cancel") let alertWindow = alert.window alertWindow.initialFirstResponder = input DispatchQueue.main.async { @@ -4118,24 +8469,24 @@ final class Workspace: Identifiable, ObservableObject { ) var options: [(title: String, destination: PanelMoveDestination)] = [ - (String(localized: "dialog.moveTab.newWorkspaceCurrentWindow", defaultValue: "New Workspace in Current Window"), .newWorkspaceInCurrentWindow), - (String(localized: "dialog.moveTab.selectedWorkspaceNewWindow", defaultValue: "Selected Workspace in New Window"), .selectedWorkspaceInNewWindow), + ("New Workspace in Current Window", .newWorkspaceInCurrentWindow), + ("Selected Workspace in New Window", .selectedWorkspaceInNewWindow), ] options.append(contentsOf: workspaceTargets.map { target in (target.label, .existingWorkspace(target.workspaceId)) }) let alert = NSAlert() - alert.messageText = String(localized: "dialog.moveTab.title", defaultValue: "Move Tab") - alert.informativeText = String(localized: "dialog.moveTab.message", defaultValue: "Choose a destination for this tab.") + alert.messageText = "Move Tab" + alert.informativeText = "Choose a destination for this tab." let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 320, height: 26), pullsDown: false) for option in options { popup.addItem(withTitle: option.title) } popup.selectItem(at: 0) alert.accessoryView = popup - alert.addButton(withTitle: String(localized: "dialog.moveTab.move", defaultValue: "Move")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.addButton(withTitle: "Move") + alert.addButton(withTitle: "Cancel") guard alert.runModal() == .alertFirstButtonReturn else { return } let selectedIndex = max(0, min(popup.indexOfSelectedItem, options.count - 1)) @@ -4181,9 +8532,9 @@ final class Workspace: Identifiable, ObservableObject { if !moved { let failure = NSAlert() failure.alertStyle = .warning - failure.messageText = String(localized: "dialog.moveFailed.title", defaultValue: "Move Failed") - failure.informativeText = String(localized: "dialog.moveFailed.message", defaultValue: "cmux could not move this tab to the selected destination.") - failure.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + failure.messageText = "Move Failed" + failure.informativeText = "cmux could not move this tab to the selected destination." + failure.addButton(withTitle: "OK") _ = failure.runModal() } } @@ -4266,7 +8617,7 @@ extension Workspace: BonsplitDelegate { alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.") alert.alertStyle = .warning alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close")) - alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.addButton(withTitle: String(localized: "dialog.closeTab.cancel", defaultValue: "Cancel")) if let closeButton = alert.buttons.first { closeButton.keyEquivalent = "\r" @@ -4744,11 +9095,7 @@ extension Workspace: BonsplitDelegate { // Clean up our panel guard let panelId = panelIdFromSurfaceId(tabId) else { #if DEBUG - dlog( - "surface.didCloseTab.skip tab=\(String(describing: tabId).prefix(5)) " + - "pane=\(pane.id.uuidString.prefix(5)) reason=missingPanelMapping " + - "panels=\(panels.count) panes=\(controller.allPaneIds.count)" - ) + NSLog("[Workspace] didCloseTab: no panelId for tabId") #endif scheduleTerminalGeometryReconcile() if !isDetaching { @@ -4757,15 +9104,11 @@ extension Workspace: BonsplitDelegate { return } + #if DEBUG + NSLog("[Workspace] didCloseTab panelId=\(panelId) remainingPanels=\(panels.count - 1) remainingPanes=\(controller.allPaneIds.count)") + #endif + let panel = panels[panelId] -#if DEBUG - dlog( - "surface.didCloseTab.begin tab=\(String(describing: tabId).prefix(5)) " + - "pane=\(pane.id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + - "isDetaching=\(isDetaching ? 1 : 0) selectAfter=\(selectTabId.map { String(String(describing: $0).prefix(5)) } ?? "nil") " + - "\(debugPanelLifecycleState(panelId: panelId, panel: panel))" - ) -#endif if isDetaching, let panel { let browserPanel = panel as? BrowserPanel @@ -4793,6 +9136,7 @@ extension Workspace: BonsplitDelegate { } panels.removeValue(forKey: panelId) + untrackRemoteTerminalSurface(panelId) surfaceIdToPanelId.removeValue(forKey: tabId) panelDirectories.removeValue(forKey: panelId) panelGitBranches.removeValue(forKey: panelId) @@ -4811,6 +9155,7 @@ extension Workspace: BonsplitDelegate { if lastTerminalConfigInheritancePanelId == panelId { lastTerminalConfigInheritancePanelId = nil } + clearRemoteConfigurationIfWorkspaceBecameLocal() AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId) // Keep the workspace invariant for normal close paths. @@ -4818,12 +9163,6 @@ extension Workspace: BonsplitDelegate { // prune the source workspace/window after the tab is attached elsewhere. if panels.isEmpty { if isDetaching { -#if DEBUG - dlog( - "surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) mode=detachingEmptyWorkspace" - ) -#endif scheduleTerminalGeometryReconcile() return } @@ -4837,13 +9176,6 @@ extension Workspace: BonsplitDelegate { } scheduleTerminalGeometryReconcile() scheduleFocusReconcile() -#if DEBUG - dlog( - "surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) mode=replacementCreated " + - "replacement=\(replacement.id.uuidString.prefix(5)) panels=\(panels.count)" - ) -#endif return } @@ -4865,15 +9197,6 @@ extension Workspace: BonsplitDelegate { if bonsplitController.allPaneIds.contains(pane) { normalizePinnedTabs(in: pane) } -#if DEBUG - let focusedPaneAfter = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil" - let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil" - dlog( - "surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) panels=\(panels.count) panes=\(controller.allPaneIds.count) " + - "focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter)" - ) -#endif scheduleTerminalGeometryReconcile() if !isDetaching { scheduleFocusReconcile() @@ -4958,23 +9281,12 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) { let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? [] let shouldScheduleFocusReconcile = !isDetachingCloseTransaction -#if DEBUG - dlog( - "surface.didClosePane.begin pane=\(paneId.id.uuidString.prefix(5)) " + - "closedPanels=\(closedPanelIds.count) detaching=\(isDetachingCloseTransaction ? 1 : 0)" - ) -#endif if !closedPanelIds.isEmpty { for panelId in closedPanelIds { -#if DEBUG - dlog( - "surface.didClosePane.panel pane=\(paneId.id.uuidString.prefix(5)) " + - "panel=\(panelId.uuidString.prefix(5)) \(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))" - ) -#endif panels[panelId]?.close() panels.removeValue(forKey: panelId) + untrackRemoteTerminalSurface(panelId) panelDirectories.removeValue(forKey: panelId) panelGitBranches.removeValue(forKey: panelId) panelPullRequests.removeValue(forKey: panelId) @@ -4993,6 +9305,7 @@ extension Workspace: BonsplitDelegate { let closedSet = Set(closedPanelIds) surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) } recomputeListeningPorts() + clearRemoteConfigurationIfWorkspaceBecameLocal() if let focusedPane = bonsplitController.focusedPaneId, let focusedTabId = bonsplitController.selectedTab(inPane: focusedPane)?.id { @@ -5006,12 +9319,6 @@ extension Workspace: BonsplitDelegate { if shouldScheduleFocusReconcile { scheduleFocusReconcile() } -#if DEBUG - dlog( - "surface.didClosePane.end pane=\(paneId.id.uuidString.prefix(5)) " + - "remainingPanels=\(panels.count) remainingPanes=\(bonsplitController.allPaneIds.count)" - ) -#endif } func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 343ca118..c0700045 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3607,6 +3607,7 @@ struct SettingsView: View { private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser @AppStorage(ShortcutHintDebugSettings.showHintsOnCommandHoldKey) private var showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold + @AppStorage("sidebarShowSSH") private var sidebarShowSSH = true @AppStorage("sidebarShowPorts") private var sidebarShowPorts = true @AppStorage("sidebarShowLog") private var sidebarShowLog = true @AppStorage("sidebarShowProgress") private var sidebarShowProgress = true @@ -4376,6 +4377,17 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + String(localized: "settings.app.showSSH", defaultValue: "Show SSH in Sidebar"), + subtitle: String(localized: "settings.app.showSSH.subtitle", defaultValue: "Display the SSH target for remote workspaces in its own row.") + ) { + Toggle("", isOn: $sidebarShowSSH) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + SettingsCardRow( String(localized: "settings.app.showPorts", defaultValue: "Show Listening Ports in Sidebar"), subtitle: String(localized: "settings.app.showPorts.subtitle", defaultValue: "Display detected listening ports for the active workspace.") @@ -5225,6 +5237,7 @@ struct SettingsView: View { sidebarShowPullRequest = true openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold + sidebarShowSSH = true sidebarShowPorts = true sidebarShowLog = true sidebarShowProgress = true diff --git a/TODO.md b/TODO.md index 7538404a..5453b8f5 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,18 @@ # TODO +## Issue 151: Remote SSH (Living Execution) +- [x] `cmux ssh` creates remote workspace metadata and does not require `--name` +- [x] Remote daemon bootstrap/upload/start path with `cmuxd-remote serve --stdio` +- [x] Reconnect/disconnect controls (CLI/API/context menu) + improved error surfacing +- [x] Retry count/time surfaced in remote daemon/probe error details +- [ ] Remove automatic remote service port mirroring (`ssh -L` from detected remote listening ports) +- [ ] Add transport-scoped proxy broker (SOCKS5 + HTTP CONNECT) for remote traffic +- [ ] Extend `cmuxd-remote` RPC beyond `hello/ping` with proxy stream methods (`proxy.open|close`) +- [ ] Auto-wire WKWebView in remote workspaces to proxy via `WKWebsiteDataStore.proxyConfigurations` +- [ ] Add browser proxy e2e tests (remote egress IP, websocket, reconnect continuity) +- [ ] Implement PTY resize coordinator with tmux semantics (`smallest screen wins`) +- [ ] Add resize tests for multi-attachment sessions (attach/detach/reconnect transitions) + ## Socket API / Agent - [x] Add window handles + `window.list/current/focus/create/close` for multi-window socket control (v2) + v1 equivalents (`list_windows`, etc) + CLI support. - [x] Add surface move/reorder commands (move between panes, reorder within pane, move across workspaces/windows). @@ -41,7 +54,7 @@ - [ ] OpenCode integration ## Browser -- [ ] Per-WKWebView local proxy for full network request/response inspection (URL, method, headers, body, status, timing) +- [ ] Per-WKWebView proxy observability/inspection once remote proxy path is shipped (URL, method, headers, body, status, timing) ## Bugs - [ ] **P0** Terminal title updates are suppressed when workspace is not focused (e.g. Claude Code loading indicator doesn't update in sidebar until you switch to that tab) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 8b569de5..4c320f12 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -452,6 +452,149 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window") } + func testCmdDRoutesSplitToEventWindowWhenKeyWindowIsDifferent() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let firstWindowId = appDelegate.createMainWindow() + let secondWindowId = appDelegate.createMainWindow() + + defer { + closeWindow(withId: firstWindowId) + closeWindow(withId: secondWindowId) + } + + guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId), + let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId), + let firstWindow = window(withId: firstWindowId), + let secondWindow = window(withId: secondWindowId), + let firstWorkspace = firstManager.selectedWorkspace, + let secondWorkspace = secondManager.selectedWorkspace else { + XCTFail("Expected both window contexts to exist") + return + } + + firstWindow.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + let firstSurfaceCount = firstWorkspace.panels.count + let secondSurfaceCount = secondWorkspace.panels.count + + appDelegate.tabManager = firstManager + XCTAssertTrue(appDelegate.tabManager === firstManager) + + guard let event = makeKeyDownEvent( + key: "d", + modifiers: [.command], + keyCode: 2, // kVK_ANSI_D + windowNumber: secondWindow.windowNumber + ) else { + XCTFail("Failed to construct Cmd+D event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event)) +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertEqual(firstWorkspace.panels.count, firstSurfaceCount, "Cmd+D must not create a split in the stale key window") + XCTAssertEqual(secondWorkspace.panels.count, secondSurfaceCount + 1, "Cmd+D should create a split in the event window") + XCTAssertTrue(appDelegate.tabManager === secondManager, "Split shortcut routing should keep the event window active") + } + + func testPerformSplitShortcutSplitsFocusedTerminalSurfaceWhenSelectedWorkspaceIsStale() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let leftPanel = workspace.terminalPanel(for: leftPanelId) else { + XCTFail("Expected split terminal panels") + return + } + + let originalPanelIds = Set(workspace.panels.keys) + + guard let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else { + XCTFail("Expected split terminal panels") + return + } + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + guard let leftPaneBefore = workspace.paneId(forPanelId: leftPanel.id), + let rightPaneBefore = workspace.paneId(forPanelId: rightPanel.id) else { + XCTFail("Expected split pane IDs") + return + } + let layoutBefore = workspace.bonsplitController.layoutSnapshot() + guard let leftPaneBeforeFrame = layoutBefore.panes.first(where: { $0.paneId == leftPaneBefore.id.uuidString })?.frame, + let rightPaneBeforeFrame = layoutBefore.panes.first(where: { $0.paneId == rightPaneBefore.id.uuidString })?.frame else { + XCTFail("Expected pane frames before shortcut split") + return + } + XCTAssertLessThan(leftPaneBeforeFrame.x, rightPaneBeforeFrame.x, "Expected baseline layout to start left-to-right") + + guard let leftSurfaceView = surfaceView(in: leftPanel.hostedView) else { + XCTFail("Expected left terminal surface view") + return + } + + window.makeKeyAndOrderFront(nil) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + workspace.focusPanel(rightPanel.id) + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected Bonsplit selection to stay on the right pane") + leftPanel.hostedView.suppressReparentFocus() + XCTAssertTrue(window.makeFirstResponder(leftSurfaceView)) + leftPanel.hostedView.clearSuppressReparentFocus() + XCTAssertTrue(window.firstResponder === leftSurfaceView, "Expected left Ghostty surface to stay first responder") + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected selected pane to stay stale after first-responder change") + XCTAssertEqual(leftSurfaceView.tabId, workspace.id, "Expected focused Ghostty view to keep its workspace ID") + XCTAssertEqual(leftSurfaceView.terminalSurface?.id, leftPanel.id, "Expected focused Ghostty view to keep its surface ID") + + XCTAssertTrue( + appDelegate.performSplitShortcut(direction: .right, preferredWindow: window), + "Split shortcut should use the focused terminal surface even when selectedTabId is stale" + ) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.15)) + + let newPanelIds = Set(workspace.panels.keys) + .subtracting(originalPanelIds) + .subtracting([rightPanel.id]) + guard newPanelIds.count == 1, let newPanelId = newPanelIds.first else { + XCTFail("Expected exactly one shortcut-created split panel") + return + } + guard let newPaneId = workspace.paneId(forPanelId: newPanelId), + let rightPaneAfter = workspace.paneId(forPanelId: rightPanel.id) else { + XCTFail("Expected pane IDs after shortcut split") + return + } + let layoutAfter = workspace.bonsplitController.layoutSnapshot() + guard let newPaneFrame = layoutAfter.panes.first(where: { $0.paneId == newPaneId.id.uuidString })?.frame, + let rightPaneAfterFrame = layoutAfter.panes.first(where: { $0.paneId == rightPaneAfter.id.uuidString })?.frame else { + XCTFail("Expected pane frames after shortcut split") + return + } + XCTAssertEqual(layoutAfter.panes.count, 3, "Cmd+D should create a third pane") + XCTAssertLessThan( + newPaneFrame.x, + rightPaneAfterFrame.x, + "Cmd+D should split the focused left terminal pane, not the stale selected right pane" + ) + } + func testCmdCtrlWPromptsBeforeClosingWindow() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") @@ -2690,6 +2833,17 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) } + private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> GhosttyNSView? { + var stack: [NSView] = [hostedView] + while let current = stack.popLast() { + if let surfaceView = current as? GhosttyNSView { + return surfaceView + } + stack.append(contentsOf: current.subviews) + } + return nil + } + private func mainWindowIds() -> Set<UUID> { Set(NSApp.windows.compactMap { window in guard let raw = window.identifier?.rawValue, diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index a422cedd..71a728d2 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -5249,6 +5249,105 @@ final class UpdateChannelSettingsTests: XCTestCase { } } +final class UpdateSettingsTests: XCTestCase { + func testApplyEnablesAutomaticChecksAndDailySchedule() { + let defaults = makeDefaults() + UpdateSettings.apply(to: defaults) + + XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticChecksKey)) + XCTAssertEqual(defaults.double(forKey: UpdateSettings.scheduledCheckIntervalKey), UpdateSettings.scheduledCheckInterval) + XCTAssertFalse(defaults.bool(forKey: UpdateSettings.automaticallyUpdateKey)) + XCTAssertFalse(defaults.bool(forKey: UpdateSettings.sendProfileInfoKey)) + XCTAssertTrue(defaults.bool(forKey: UpdateSettings.migrationKey)) + } + + func testApplyRepairsLegacyDisabledAutomaticChecksOnce() { + let defaults = makeDefaults() + defaults.set(false, forKey: UpdateSettings.automaticChecksKey) + defaults.set(0, forKey: UpdateSettings.scheduledCheckIntervalKey) + defaults.set(true, forKey: UpdateSettings.automaticallyUpdateKey) + + UpdateSettings.apply(to: defaults) + + XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticChecksKey)) + XCTAssertEqual(defaults.double(forKey: UpdateSettings.scheduledCheckIntervalKey), UpdateSettings.scheduledCheckInterval) + XCTAssertTrue(defaults.bool(forKey: UpdateSettings.automaticallyUpdateKey)) + + defaults.set(false, forKey: UpdateSettings.automaticChecksKey) + UpdateSettings.apply(to: defaults) + + XCTAssertFalse(defaults.bool(forKey: UpdateSettings.automaticChecksKey)) + } + + private func makeDefaults() -> UserDefaults { + let suiteName = "UpdateSettingsTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create isolated UserDefaults suite") + } + defaults.removePersistentDomain(forName: suiteName) + return defaults + } +} + +final class SidebarRemoteErrorCopySupportTests: XCTestCase { + func testMenuLabelIsNilWhenThereAreNoErrors() { + XCTAssertNil(SidebarRemoteErrorCopySupport.menuLabel(for: [])) + XCTAssertNil(SidebarRemoteErrorCopySupport.clipboardText(for: [])) + } + + func testSingleErrorUsesCopyErrorLabelAndSingleLinePayload() { + let entries = [ + SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox:22", + detail: "failed to start reverse relay" + ) + ] + + XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Error") + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: entries), + "SSH error (devbox:22): failed to start reverse relay" + ) + } + + func testMultipleErrorsUseCopyErrorsLabelAndEnumeratedPayload() { + let entries = [ + SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox-a:22", + detail: "connection timed out" + ), + SidebarRemoteErrorCopyEntry( + workspaceTitle: "beta", + target: "devbox-b:22", + detail: "permission denied" + ), + ] + + XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Errors") + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: entries), + """ + 1. alpha (devbox-a:22): connection timed out + 2. beta (devbox-b:22): permission denied + """ + ) + } + + func testClipboardTextSingleEntryUsesStructuredEntryFields() { + let entry = SidebarRemoteErrorCopyEntry( + workspaceTitle: "alpha", + target: "devbox:22", + detail: "failed to bootstrap daemon" + ) + XCTAssertEqual( + SidebarRemoteErrorCopySupport.clipboardText(for: [entry]), + "SSH error (devbox:22): failed to bootstrap daemon" + ) + } +} + final class WorkspaceReorderTests: XCTestCase { @MainActor func testReorderWorkspaceMovesWorkspaceToRequestedIndex() { @@ -6982,6 +7081,66 @@ final class WorkspacePanelGitBranchTests: XCTestCase { ) } + func testNewTerminalSurfaceWithFocusFalsePreservesFocusedPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId, + let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else { + XCTFail("Expected initial focused panel and pane") + return + } + + guard let newPanel = workspace.newTerminalSurface(inPane: originalPaneId, focus: false) else { + XCTFail("Expected terminal surface to be created") + return + } + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertNotEqual(newPanel.id, originalFocusedPanelId) + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus terminal surface creation to preserve the existing focused panel" + ) + XCTAssertEqual( + workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, + workspace.surfaceIdFromPanelId(originalFocusedPanelId), + "Expected selected tab to stay on the original focused panel" + ) + } + + func testNewBrowserSurfaceWithFocusFalsePreservesFocusedPanel() { + let workspace = Workspace() + guard let originalFocusedPanelId = workspace.focusedPanelId, + let originalPaneId = workspace.paneId(forPanelId: originalFocusedPanelId) else { + XCTFail("Expected initial focused panel and pane") + return + } + + guard let newPanel = workspace.newBrowserSurface(inPane: originalPaneId, focus: false) else { + XCTFail("Expected browser surface to be created") + return + } + + drainMainQueue() + drainMainQueue() + drainMainQueue() + + XCTAssertNotEqual(newPanel.id, originalFocusedPanelId) + XCTAssertEqual( + workspace.focusedPanelId, + originalFocusedPanelId, + "Expected non-focus browser surface creation to preserve the existing focused panel" + ) + XCTAssertEqual( + workspace.bonsplitController.selectedTab(inPane: originalPaneId)?.id, + workspace.surfaceIdFromPanelId(originalFocusedPanelId), + "Expected selected tab to stay on the original focused panel" + ) + } + func testClosingFocusedSplitRestoresBranchForRemainingFocusedPanel() { let workspace = Workspace() guard let firstPanelId = workspace.focusedPanelId else { @@ -13345,6 +13504,89 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase { "The scheduled external geometry sync should move the portal-hosted terminal to the anchor's new window position" ) } + + func testScheduledExternalGeometrySyncWaitsForQueuedLayoutShift() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 700, height: 420), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { + NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window) + window.orderOut(nil) + } + + let surface = TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + workingDirectory: nil + ) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let shiftedContainer = NSView(frame: NSRect(x: 40, y: 60, width: 260, height: 180)) + contentView.addSubview(shiftedContainer) + let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 180)) + shiftedContainer.addSubview(anchor) + let hosted = surface.hostedView + TerminalWindowPortalRegistry.bind( + hostedView: hosted, + to: anchor, + visibleInUI: true, + expectedSurfaceId: surface.id, + expectedGeneration: surface.portalBindingGeneration() + ) + TerminalWindowPortalRegistry.synchronizeForAnchor(anchor) + + let anchorCenter = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY) + let originalWindowPoint = anchor.convert(anchorCenter, to: nil) + let originalAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(originalWindowPoint, in: window), + "Initial hit-testing should resolve the portal-hosted terminal at its original window position" + ) + + TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronizeForAllWindows() + DispatchQueue.main.async { + shiftedContainer.frame.origin.x += 72 + contentView.layoutSubtreeIfNeeded() + window.displayIfNeeded() + } + + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + let shiftedAnchorFrameInWindow = anchor.convert(anchor.bounds, to: nil) + XCTAssertGreaterThan( + shiftedAnchorFrameInWindow.minX, + originalAnchorFrameInWindow.minX + 1, + "The queued layout shift should move the anchor to the right" + ) + XCTAssertGreaterThan( + shiftedAnchorFrameInWindow.maxX, + originalAnchorFrameInWindow.maxX + 1, + "The shifted anchor should expose a new trailing region outside the stale portal frame" + ) + let retiredStaleWindowPoint = NSPoint( + x: (originalAnchorFrameInWindow.minX + shiftedAnchorFrameInWindow.minX) / 2, + y: shiftedAnchorFrameInWindow.midY + ) + let shiftedWindowPoint = NSPoint( + x: (originalAnchorFrameInWindow.maxX + shiftedAnchorFrameInWindow.maxX) / 2, + y: shiftedAnchorFrameInWindow.midY + ) + XCTAssertNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(retiredStaleWindowPoint, in: window), + "The queued external sync should wait until the later layout shift settles, clearing the stale portal location" + ) + XCTAssertNotNil( + TerminalWindowPortalRegistry.terminalViewAtWindowPoint(shiftedWindowPoint, in: window), + "The delayed external sync should move the portal-hosted terminal to the queued layout shift position" + ) + } } @MainActor @@ -15256,6 +15498,32 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase { return fd } + private func acceptSingleClient( + on listenerFD: Int32, + handler: @escaping (_ clientFD: Int32) -> Void + ) -> XCTestExpectation { + let handled = expectation(description: "socket client handled") + DispatchQueue.global(qos: .userInitiated).async { + var clientAddr = sockaddr_un() + var clientAddrLen = socklen_t(MemoryLayout<sockaddr_un>.size) + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.accept(listenerFD, sockaddrPtr, &clientAddrLen) + } + } + guard clientFD >= 0 else { + handled.fulfill() + return + } + defer { + Darwin.close(clientFD) + handled.fulfill() + } + handler(clientFD) + } + return handled + } + @MainActor func testSocketListenerHealthRecognizesSocketPath() throws { let path = makeTempSocketPath() @@ -15282,21 +15550,64 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase { XCTAssertFalse(health.isHealthy) } + func testProbeSocketCommandReturnsFirstLineResponse() throws { + let path = makeTempSocketPath() + let listenerFD = try bindUnixSocket(at: path) + defer { + Darwin.close(listenerFD) + unlink(path) + } + + let handled = acceptSingleClient(on: listenerFD) { clientFD in + var buffer = [UInt8](repeating: 0, count: 256) + _ = read(clientFD, &buffer, buffer.count) + let response = "PONG\nextra\n" + _ = response.withCString { ptr in + write(clientFD, ptr, strlen(ptr)) + } + } + + let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.5) + + XCTAssertEqual(response, "PONG") + wait(for: [handled], timeout: 1.0) + } + + func testProbeSocketCommandTimesOutWithoutPollingUntilServerResponds() throws { + let path = makeTempSocketPath() + let listenerFD = try bindUnixSocket(at: path) + defer { + Darwin.close(listenerFD) + unlink(path) + } + + let releaseServer = DispatchSemaphore(value: 0) + let handled = acceptSingleClient(on: listenerFD) { clientFD in + var buffer = [UInt8](repeating: 0, count: 256) + _ = read(clientFD, &buffer, buffer.count) + _ = releaseServer.wait(timeout: .now() + 1.0) + } + + let startedAt = Date() + let response = TerminalController.probeSocketCommand("ping", at: path, timeout: 0.2) + let elapsed = Date().timeIntervalSince(startedAt) + releaseServer.signal() + + XCTAssertNil(response) + XCTAssertGreaterThanOrEqual(elapsed, 0.18) + XCTAssertLessThan(elapsed, 0.8) + wait(for: [handled], timeout: 1.0) + } + func testSocketListenerHealthFailureSignalsAreEmptyWhenHealthy() { let health = TerminalController.SocketListenerHealth( isRunning: true, acceptLoopAlive: true, socketPathMatches: true, - socketPathExists: true, - socketProbePerformed: true, - socketConnectable: true, - socketConnectErrno: nil + socketPathExists: true ) XCTAssertTrue(health.isHealthy) XCTAssertTrue(health.failureSignals.isEmpty) - XCTAssertTrue(health.socketProbePerformed) - XCTAssertEqual(health.socketConnectable, true) - XCTAssertNil(health.socketConnectErrno) } func testSocketListenerHealthFailureSignalsIncludeAllDetectedProblems() { @@ -15304,15 +15615,9 @@ final class TerminalControllerSocketListenerHealthTests: XCTestCase { isRunning: false, acceptLoopAlive: false, socketPathMatches: false, - socketPathExists: false, - socketProbePerformed: false, - socketConnectable: nil, - socketConnectErrno: nil + socketPathExists: false ) XCTAssertFalse(health.isHealthy) - XCTAssertFalse(health.socketProbePerformed) - XCTAssertNil(health.socketConnectable) - XCTAssertNil(health.socketConnectErrno) XCTAssertEqual( health.failureSignals, ["not_running", "accept_loop_dead", "socket_path_mismatch", "socket_missing"] diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 4ceb61d1..5cca92b3 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -1,5 +1,6 @@ import XCTest import AppKit +import WebKit #if canImport(cmux_DEV) @testable import cmux_DEV @@ -772,6 +773,505 @@ final class WindowTransparencyDecisionTests: XCTestCase { } } +final class WorkspaceRemoteDaemonManifestTests: XCTestCase { + func testParsesEmbeddedRemoteDaemonManifestJSON() throws { + let manifestJSON = """ + { + "schemaVersion": 1, + "appVersion": "0.62.0", + "releaseTag": "v0.62.0", + "releaseURL": "https://github.com/manaflow-ai/cmux/releases/tag/v0.62.0", + "checksumsAssetName": "cmuxd-remote-checksums.txt", + "checksumsURL": "https://github.com/manaflow-ai/cmux/releases/download/v0.62.0/cmuxd-remote-checksums.txt", + "entries": [ + { + "goOS": "linux", + "goArch": "amd64", + "assetName": "cmuxd-remote-linux-amd64", + "downloadURL": "https://github.com/manaflow-ai/cmux/releases/download/v0.62.0/cmuxd-remote-linux-amd64", + "sha256": "abc123" + } + ] + } + """ + + let manifest = Workspace.remoteDaemonManifest(from: [ + Workspace.remoteDaemonManifestInfoKey: manifestJSON, + ]) + + XCTAssertEqual(manifest?.releaseTag, "v0.62.0") + XCTAssertEqual(manifest?.entry(goOS: "linux", goArch: "amd64")?.assetName, "cmuxd-remote-linux-amd64") + } + + func testRemoteDaemonCachePathIsVersionedByPlatform() throws { + let url = try Workspace.remoteDaemonCachedBinaryURL( + version: "0.62.0", + goOS: "linux", + goArch: "arm64" + ) + + XCTAssertTrue(url.path.contains("/Application Support/cmux/remote-daemons/0.62.0/linux-arm64/")) + XCTAssertEqual(url.lastPathComponent, "cmuxd-remote") + } +} + +final class RemoteLoopbackHTTPRequestRewriterTests: XCTestCase { + func testRewritesLoopbackAliasHostHeadersToLocalhost() { + let original = Data( + ( + "GET /demo HTTP/1.1\r\n" + + "Host: cmux-loopback.localtest.me:3000\r\n" + + "Origin: http://cmux-loopback.localtest.me:3000\r\n" + + "Referer: http://cmux-loopback.localtest.me:3000/app\r\n" + + "\r\n" + ).utf8 + ) + + let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: original, + aliasHost: "cmux-loopback.localtest.me" + ) + + let text = String(decoding: rewritten, as: UTF8.self) + XCTAssertTrue(text.contains("Host: localhost:3000")) + XCTAssertTrue(text.contains("Origin: http://localhost:3000")) + XCTAssertTrue(text.contains("Referer: http://localhost:3000/app")) + XCTAssertFalse(text.contains("cmux-loopback.localtest.me")) + } + + func testRewritesAbsoluteFormRequestLineForLoopbackAlias() { + let original = Data( + ( + "GET http://cmux-loopback.localtest.me:3000/demo HTTP/1.1\r\n" + + "Host: cmux-loopback.localtest.me:3000\r\n" + + "\r\n" + ).utf8 + ) + + let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: original, + aliasHost: "cmux-loopback.localtest.me" + ) + + let text = String(decoding: rewritten, as: UTF8.self) + XCTAssertTrue(text.hasPrefix("GET http://localhost:3000/demo HTTP/1.1\r\n")) + XCTAssertTrue(text.contains("Host: localhost:3000")) + } + + func testLeavesNonHTTPPayloadUntouched() { + let original = Data([0x16, 0x03, 0x01, 0x00, 0x2a, 0x01, 0x00]) + let rewritten = RemoteLoopbackHTTPRequestRewriter.rewriteIfNeeded( + data: original, + aliasHost: "cmux-loopback.localtest.me" + ) + XCTAssertEqual(rewritten, original) + } + + func testBuffersSplitLoopbackAliasHeadersUntilFullRequestArrives() { + var streamRewriter = RemoteLoopbackHTTPRequestStreamRewriter( + aliasHost: "cmux-loopback.localtest.me" + ) + + let firstChunk = Data( + ( + "GET /demo HTTP/1.1\r\n" + + "Host: cmux-loop" + ).utf8 + ) + let secondChunk = Data( + ( + "back.localtest.me:3000\r\n" + + "Origin: http://cmux-loopback.localtest.me:3000\r\n" + + "Referer: http://cmux-loopback.localtest.me:3000/app\r\n" + + "\r\n" + + "body=1" + ).utf8 + ) + + let firstOutput = streamRewriter.rewriteNextChunk(firstChunk, eof: false) + let secondOutput = streamRewriter.rewriteNextChunk(secondChunk, eof: false) + + XCTAssertTrue(firstOutput.isEmpty) + + let text = String(decoding: secondOutput, as: UTF8.self) + XCTAssertTrue(text.contains("Host: localhost:3000")) + XCTAssertTrue(text.contains("Origin: http://localhost:3000")) + XCTAssertTrue(text.contains("Referer: http://localhost:3000/app")) + XCTAssertTrue(text.hasSuffix("\r\n\r\nbody=1")) + XCTAssertFalse(text.contains("cmux-loopback.localtest.me")) + } + + func testFlushesBufferedLoopbackAliasHeadersOnEOFWhenHeadersRemainIncomplete() { + var streamRewriter = RemoteLoopbackHTTPRequestStreamRewriter( + aliasHost: "cmux-loopback.localtest.me" + ) + + let firstChunk = Data( + ( + "GET /demo HTTP/1.1\r\n" + + "Host: cmux-loop" + ).utf8 + ) + let secondChunk = Data( + ( + "back.localtest.me:3000\r\n" + + "Origin: http://cmux-loopback.localtest.me:3000\r\n" + + "Referer: http://cmux-loopback.localtest.me:3000/app\r\n" + + "body=1" + ).utf8 + ) + + let firstOutput = streamRewriter.rewriteNextChunk(firstChunk, eof: false) + let secondOutput = streamRewriter.rewriteNextChunk(secondChunk, eof: true) + let thirdOutput = streamRewriter.rewriteNextChunk(Data(), eof: true) + + XCTAssertTrue(firstOutput.isEmpty) + + let text = String(decoding: secondOutput, as: UTF8.self) + XCTAssertTrue(text.contains("Host: localhost:3000")) + XCTAssertTrue(text.contains("Origin: http://localhost:3000")) + XCTAssertTrue(text.contains("Referer: http://localhost:3000/app")) + XCTAssertTrue(text.hasSuffix("\r\nbody=1")) + XCTAssertFalse(text.contains("cmux-loopback.localtest.me")) + XCTAssertTrue(thirdOutput.isEmpty) + } + + func testRewritesLoopbackResponseHeadersBackToAlias() { + let original = Data( + ( + "HTTP/1.1 302 Found\r\n" + + "Location: http://localhost:3000/login\r\n" + + "Access-Control-Allow-Origin: http://localhost:3000\r\n" + + "Set-Cookie: sid=1; Domain=localhost; Path=/\r\n" + + "\r\n" + ).utf8 + ) + + let rewritten = RemoteLoopbackHTTPResponseRewriter.rewriteIfNeeded( + data: original, + aliasHost: "cmux-loopback.localtest.me" + ) + + let text = String(decoding: rewritten, as: UTF8.self) + XCTAssertTrue(text.contains("Location: http://cmux-loopback.localtest.me:3000/login")) + XCTAssertTrue(text.contains("Access-Control-Allow-Origin: http://cmux-loopback.localtest.me:3000")) + XCTAssertTrue(text.contains("Set-Cookie: sid=1; Domain=cmux-loopback.localtest.me; Path=/")) + } +} + +final class GhosttyTerminalStartupEnvironmentTests: XCTestCase { + func testMergedStartupEnvironmentAllowsSessionReplayAndInitialEnvCMUXKeys() { + let replayPath = "/tmp/cmux-replay-\(UUID().uuidString)" + let merged = TerminalSurface.mergedStartupEnvironment( + base: [ + "PATH": "/usr/bin", + "CMUX_SURFACE_ID": "managed-surface" + ], + protectedKeys: ["PATH", "CMUX_SURFACE_ID"], + additionalEnvironment: [ + SessionScrollbackReplayStore.environmentKey: replayPath + ], + initialEnvironmentOverrides: [ + "CMUX_INITIAL_ENV_TOKEN": "token-123" + ] + ) + + XCTAssertEqual(merged[SessionScrollbackReplayStore.environmentKey], replayPath) + XCTAssertEqual(merged["CMUX_INITIAL_ENV_TOKEN"], "token-123") + } + + func testMergedStartupEnvironmentProtectsManagedKeysOnly() { + let merged = TerminalSurface.mergedStartupEnvironment( + base: [ + "PATH": "/usr/bin", + "CMUX_SURFACE_ID": "managed-surface" + ], + protectedKeys: ["PATH", "CMUX_SURFACE_ID"], + additionalEnvironment: [ + "CMUX_SURFACE_ID": "user-surface", + "CUSTOM_FLAG": "1" + ], + initialEnvironmentOverrides: [ + "PATH": "/tmp/bin", + "CMUX_SURFACE_ID": "override-surface" + ] + ) + + XCTAssertEqual(merged["PATH"], "/usr/bin") + XCTAssertEqual(merged["CMUX_SURFACE_ID"], "managed-surface") + XCTAssertEqual(merged["CUSTOM_FLAG"], "1") + } +} + +@MainActor +final class BrowserPanelRemoteStoreTests: XCTestCase { + func testRemoteWorkspacePanelsShareWorkspaceScopedWebsiteDataStore() { + let localPanel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: false) + let remoteWorkspaceId = UUID() + let firstRemotePanel = BrowserPanel( + workspaceId: remoteWorkspaceId, + isRemoteWorkspace: true, + remoteWebsiteDataStoreIdentifier: remoteWorkspaceId + ) + let secondRemotePanel = BrowserPanel( + workspaceId: remoteWorkspaceId, + isRemoteWorkspace: true, + remoteWebsiteDataStoreIdentifier: remoteWorkspaceId + ) + + XCTAssertTrue(localPanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default()) + XCTAssertFalse(firstRemotePanel.webView.configuration.websiteDataStore === WKWebsiteDataStore.default()) + XCTAssertTrue( + firstRemotePanel.webView.configuration.websiteDataStore === + secondRemotePanel.webView.configuration.websiteDataStore + ) + } + + func testRemoteWorkspaceDefersInitialNavigationUntilProxyEndpointIsReady() { + let remoteWorkspaceId = UUID() + let url = URL(string: "http://localhost:3000/demo")! + let panel = BrowserPanel( + workspaceId: remoteWorkspaceId, + initialURL: url, + isRemoteWorkspace: true, + remoteWebsiteDataStoreIdentifier: remoteWorkspaceId + ) + + XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString) + XCTAssertNil(panel.webView.url) + + panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876)) + + let deadline = Date().addingTimeInterval(1.0) + while panel.webView.url == nil, RunLoop.main.run(mode: .default, before: deadline), Date() < deadline {} + + XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString) + XCTAssertEqual(panel.webView.url?.host, "cmux-loopback.localtest.me") + } + + func testRemoteWorkspaceKeepsHTTPSLoopbackUnaliased() { + let remoteWorkspaceId = UUID() + let url = URL(string: "https://localhost:3443/demo")! + let panel = BrowserPanel( + workspaceId: remoteWorkspaceId, + initialURL: url, + isRemoteWorkspace: true, + remoteWebsiteDataStoreIdentifier: remoteWorkspaceId + ) + + XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString) + XCTAssertNil(panel.webView.url) + + panel.setRemoteProxyEndpoint(BrowserProxyEndpoint(host: "127.0.0.1", port: 9876)) + + let deadline = Date().addingTimeInterval(1.0) + while panel.webView.url == nil, RunLoop.main.run(mode: .default, before: deadline), Date() < deadline {} + + XCTAssertEqual(panel.preferredURLStringForOmnibar(), url.absoluteString) + XCTAssertEqual(panel.webView.url?.host, "localhost") + } + + func testBrowserMoveIntoRemoteWorkspaceRebuildsWebsiteDataStoreScope() throws { + let source = Workspace() + let sourcePaneId = try XCTUnwrap(source.bonsplitController.allPaneIds.first) + let sourceBrowser = try XCTUnwrap(source.newBrowserSurface(inPane: sourcePaneId, focus: false)) + let localStore = sourceBrowser.webView.configuration.websiteDataStore + XCTAssertTrue(localStore === WKWebsiteDataStore.default()) + + let destination = Workspace() + destination.configureRemoteConnection( + WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 22, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64001, + relayID: "relay-store-dest", + relayToken: String(repeating: "a", count: 64), + localSocketPath: "/tmp/cmux-store-dest.sock", + terminalStartupCommand: "ssh cmux-macmini" + ), + autoConnect: false + ) + let destinationPaneId = try XCTUnwrap(destination.bonsplitController.allPaneIds.first) + let destinationBrowser = try XCTUnwrap(destination.newBrowserSurface(inPane: destinationPaneId, focus: false)) + let destinationStore = destinationBrowser.webView.configuration.websiteDataStore + XCTAssertFalse(destinationStore === WKWebsiteDataStore.default()) + + let detached = try XCTUnwrap(source.detachSurface(panelId: sourceBrowser.id)) + let attachedPanelId = try XCTUnwrap( + destination.attachDetachedSurface(detached, inPane: destinationPaneId, focus: false) + ) + let movedBrowser = try XCTUnwrap(destination.panels[attachedPanelId] as? BrowserPanel) + + XCTAssertTrue(movedBrowser.webView.configuration.websiteDataStore === destinationStore) + XCTAssertFalse(movedBrowser.webView.configuration.websiteDataStore === localStore) + } + + func testBrowserMoveOutOfRemoteWorkspaceRestoresDefaultWebsiteDataStore() throws { + let source = Workspace() + source.configureRemoteConnection( + WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 22, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64002, + relayID: "relay-store-source", + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-store-source.sock", + terminalStartupCommand: "ssh cmux-macmini" + ), + autoConnect: false + ) + let sourcePaneId = try XCTUnwrap(source.bonsplitController.allPaneIds.first) + let movedBrowser = try XCTUnwrap(source.newBrowserSurface(inPane: sourcePaneId, focus: false)) + let remainingRemoteBrowser = try XCTUnwrap(source.newBrowserSurface(inPane: sourcePaneId, focus: false)) + let remoteStore = remainingRemoteBrowser.webView.configuration.websiteDataStore + XCTAssertFalse(remoteStore === WKWebsiteDataStore.default()) + + let destination = Workspace() + let destinationPaneId = try XCTUnwrap(destination.bonsplitController.allPaneIds.first) + let detached = try XCTUnwrap(source.detachSurface(panelId: movedBrowser.id)) + let attachedPanelId = try XCTUnwrap( + destination.attachDetachedSurface(detached, inPane: destinationPaneId, focus: false) + ) + let attachedBrowser = try XCTUnwrap(destination.panels[attachedPanelId] as? BrowserPanel) + + XCTAssertTrue(attachedBrowser.webView.configuration.websiteDataStore === WKWebsiteDataStore.default()) + XCTAssertTrue(remainingRemoteBrowser.webView.configuration.websiteDataStore === remoteStore) + XCTAssertFalse(remainingRemoteBrowser.webView.configuration.websiteDataStore === attachedBrowser.webView.configuration.websiteDataStore) + } + + func testNewTerminalSurfaceStaysRemoteWhileBrowserPanelsKeepWorkspaceRemote() throws { + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) + let initialTerminalId = try XCTUnwrap(workspace.focusedPanelId) + let configuration = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64000, + relayID: "relay-test", + relayToken: String(repeating: "a", count: 64), + localSocketPath: "/tmp/cmux-test.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + + workspace.configureRemoteConnection(configuration, autoConnect: false) + _ = workspace.newBrowserSurface(inPane: paneId, url: URL(string: "https://example.com"), focus: false) + + workspace.markRemoteTerminalSessionEnded(surfaceId: initialTerminalId, relayPort: configuration.relayPort) + + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) + + _ = try XCTUnwrap(workspace.newTerminalSurface(inPane: paneId, focus: false)) + + XCTAssertTrue(workspace.isRemoteWorkspace) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1) + } +} + +final class WorkspaceRemoteConfigurationTransportKeyTests: XCTestCase { + func testProxyBrokerTransportKeyIgnoresControlPath() { + let first = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 22, + identityFile: "~/.ssh/id_ed25519", + sshOptions: [ + "Compression=yes", + "ControlMaster=auto", + "ControlPath=/tmp/cmux-ssh-501-64000-%C", + ], + localProxyPort: 9000, + relayPort: 64000, + relayID: "relay-a", + relayToken: "token-a", + localSocketPath: "/tmp/cmux-a.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + let second = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: 22, + identityFile: "~/.ssh/id_ed25519", + sshOptions: [ + "Compression=yes", + "ControlMaster=auto", + "ControlPath=/tmp/cmux-ssh-501-64001-%C", + ], + localProxyPort: 9000, + relayPort: 64001, + relayID: "relay-b", + relayToken: "token-b", + localSocketPath: "/tmp/cmux-b.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + + XCTAssertEqual(first.proxyBrokerTransportKey, second.proxyBrokerTransportKey) + } +} + +final class WorkspaceRemoteDaemonPendingCallRegistryTests: XCTestCase { + func testSupportsMultiplePendingCallsResolvedOutOfOrder() { + let registry = WorkspaceRemoteDaemonPendingCallRegistry() + let first = registry.register() + let second = registry.register() + + XCTAssertTrue(registry.resolve(id: second.id, payload: [ + "ok": true, + "result": ["stream_id": "second"], + ])) + + switch registry.wait(for: second, timeout: 0.1) { + case .response(let response): + XCTAssertEqual(response["ok"] as? Bool, true) + XCTAssertEqual((response["result"] as? [String: String])?["stream_id"], "second") + default: + XCTFail("second pending call should complete independently") + } + + XCTAssertTrue(registry.resolve(id: first.id, payload: [ + "ok": true, + "result": ["stream_id": "first"], + ])) + + switch registry.wait(for: first, timeout: 0.1) { + case .response(let response): + XCTAssertEqual(response["ok"] as? Bool, true) + XCTAssertEqual((response["result"] as? [String: String])?["stream_id"], "first") + default: + XCTFail("first pending call should remain pending until its own response arrives") + } + } + + func testFailAllSignalsEveryPendingCall() { + let registry = WorkspaceRemoteDaemonPendingCallRegistry() + let first = registry.register() + let second = registry.register() + + registry.failAll("daemon transport stopped") + + switch registry.wait(for: first, timeout: 0.1) { + case .failure(let message): + XCTAssertEqual(message, "daemon transport stopped") + default: + XCTFail("first pending call should receive shared failure") + } + + switch registry.wait(for: second, timeout: 0.1) { + case .failure(let message): + XCTAssertEqual(message, "daemon transport stopped") + default: + XCTFail("second pending call should receive shared failure") + } + } +} + final class WindowBackgroundSelectionGateTests: XCTestCase { func testShouldApplyWindowBackgroundUsesOwningWindowSelectionWhenAvailable() { let tabId = UUID() @@ -1782,7 +2282,39 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { XCTAssertTrue(output.contains("PREEXEC=0"), output) } + func testGhosttySemanticPatchRetriesAfterDeferredInitCreatesLiveHooks() throws { + let output = try runInteractiveZsh( + cmuxLoadGhosttyIntegration: true, + cmuxLoadShellIntegration: true, + command: """ + _cmux_patch_ghostty_semantic_redraw + (( $+functions[_ghostty_deferred_init] )) && _ghostty_deferred_init >/dev/null 2>&1 + _cmux_patch_ghostty_semantic_redraw + print -r -- "PRECMD_BODY=${functions[_ghostty_precmd]}" + print -r -- "PREEXEC_BODY=${functions[_ghostty_preexec]}" + """ + ) + + XCTAssertTrue(output.contains("PRECMD_BODY="), output) + XCTAssertTrue(output.contains("PREEXEC_BODY="), output) + XCTAssertTrue(output.contains("133;A;redraw=last;cl=line"), output) + } + private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String { + try runInteractiveZsh( + cmuxLoadGhosttyIntegration: cmuxLoadGhosttyIntegration, + cmuxLoadShellIntegration: false, + command: "(( $+functions[_ghostty_deferred_init] )) && _ghostty_deferred_init >/dev/null 2>&1; " + + "print -r -- \"PRECMD=${+functions[_ghostty_precmd]} " + + "PREEXEC=${+functions[_ghostty_preexec]} PRECMDS=${(j:,:)precmd_functions}\"" + ) + } + + private func runInteractiveZsh( + cmuxLoadGhosttyIntegration: Bool, + cmuxLoadShellIntegration: Bool, + command: String + ) throws -> String { let fileManager = FileManager.default let root = fileManager.temporaryDirectory .appendingPathComponent("cmux-zsh-shell-integration-\(UUID().uuidString)") @@ -1803,10 +2335,7 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { process.executableURL = URL(fileURLWithPath: "/bin/zsh") process.arguments = [ "-i", - "-c", - "(( $+functions[_ghostty_deferred_init] )) && _ghostty_deferred_init >/dev/null 2>&1; " + - "print -r -- \"PRECMD=${+functions[_ghostty_precmd]} " + - "PREEXEC=${+functions[_ghostty_preexec]} PRECMDS=${(j:,:)precmd_functions}\"" + "-c", command ] process.environment = [ "HOME": root.path, @@ -1821,6 +2350,13 @@ final class ZshShellIntegrationHandoffTests: XCTestCase { if cmuxLoadGhosttyIntegration { process.environment?["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1" } + if cmuxLoadShellIntegration { + process.environment?["CMUX_SHELL_INTEGRATION"] = "1" + process.environment?["CMUX_SHELL_INTEGRATION_DIR"] = cmuxZdotdir.path + process.environment?["CMUX_SOCKET_PATH"] = root.appendingPathComponent("cmux-test.sock").path + process.environment?["CMUX_TAB_ID"] = "tab-test" + process.environment?["CMUX_PANEL_ID"] = "panel-test" + } let stdout = Pipe() let stderr = Pipe() diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 6f5c7b1d..7d04db1d 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -7,6 +7,40 @@ import XCTest #endif final class SessionPersistenceTests: XCTestCase { + @MainActor + func testWorkspaceSessionSnapshotRestoresMarkdownPanel() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-markdown-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let markdownURL = root.appendingPathComponent("note.md") + try "# hello\n".write(to: markdownURL, atomically: true, encoding: .utf8) + + let workspace = Workspace() + let paneId = try XCTUnwrap(workspace.bonsplitController.allPaneIds.first) + let panel = try XCTUnwrap( + workspace.newMarkdownSurface( + inPane: paneId, + filePath: markdownURL.path, + focus: true + ) + ) + workspace.setCustomTitle("Docs") + workspace.setPanelCustomTitle(panelId: panel.id, title: "Readme") + + let snapshot = workspace.sessionSnapshot(includeScrollback: false) + + let restored = Workspace() + restored.restoreSessionSnapshot(snapshot) + + let restoredPanelId = try XCTUnwrap(restored.focusedPanelId) + let restoredPanel = try XCTUnwrap(restored.markdownPanel(for: restoredPanelId)) + XCTAssertEqual(restoredPanel.filePath, markdownURL.path) + XCTAssertEqual(restored.customTitle, "Docs") + XCTAssertEqual(restored.panelTitle(panelId: restoredPanelId), "Readme") + } + func testSaveAndLoadRoundTripWithCustomSnapshotPath() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true) @@ -840,6 +874,40 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { ) } + func testAcceptFailureRecoveryActionResumesAfterDelayForTransientErrors() { + XCTAssertEqual( + TerminalController.acceptFailureRecoveryAction( + errnoCode: EPROTO, + consecutiveFailures: 1 + ), + .resumeAfterDelay(delayMs: 10) + ) + XCTAssertEqual( + TerminalController.acceptFailureRecoveryAction( + errnoCode: EMFILE, + consecutiveFailures: 3 + ), + .resumeAfterDelay(delayMs: 40) + ) + } + + func testAcceptFailureRecoveryActionRearmsForFatalAndPersistentFailures() { + XCTAssertEqual( + TerminalController.acceptFailureRecoveryAction( + errnoCode: EBADF, + consecutiveFailures: 1 + ), + .rearmAfterDelay(delayMs: 100) + ) + XCTAssertEqual( + TerminalController.acceptFailureRecoveryAction( + errnoCode: EPROTO, + consecutiveFailures: 50 + ), + .rearmAfterDelay(delayMs: 5_000) + ) + } + func testAcceptFailureBreadcrumbSamplingPrefersEarlyAndPowerOfTwoMilestones() { XCTAssertTrue(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 1)) XCTAssertTrue(TerminalController.shouldEmitAcceptFailureBreadcrumb(consecutiveFailures: 2)) @@ -885,3 +953,31 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { ) } } + +final class SidebarDragFailsafePolicyTests: XCTestCase { + func testRequestsClearWhenMonitorStartsAfterMouseRelease() { + XCTAssertTrue( + SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts( + isLeftMouseButtonDown: false + ) + ) + XCTAssertFalse( + SidebarDragFailsafePolicy.shouldRequestClearWhenMonitoringStarts( + isLeftMouseButtonDown: true + ) + ) + } + + func testRequestsClearForLeftMouseUpEventsOnly() { + XCTAssertTrue( + SidebarDragFailsafePolicy.shouldRequestClear( + forMouseEventType: .leftMouseUp + ) + ) + XCTAssertFalse( + SidebarDragFailsafePolicy.shouldRequestClear( + forMouseEventType: .leftMouseDragged + ) + ) + } +} diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift new file mode 100644 index 00000000..b0d44856 --- /dev/null +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -0,0 +1,75 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class TabManagerSessionSnapshotTests: XCTestCase { + func testSessionSnapshotSerializesWorkspacesAndRestoreRebuildsSelection() { + let manager = TabManager() + guard let firstWorkspace = manager.selectedWorkspace else { + XCTFail("Expected initial workspace") + return + } + firstWorkspace.setCustomTitle("First") + + let secondWorkspace = manager.addWorkspace(select: true) + secondWorkspace.setCustomTitle("Second") + XCTAssertEqual(manager.tabs.count, 2) + XCTAssertEqual(manager.selectedTabId, secondWorkspace.id) + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + XCTAssertEqual(snapshot.workspaces.count, 2) + XCTAssertEqual(snapshot.selectedWorkspaceIndex, 1) + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + XCTAssertEqual(restored.tabs.count, 2) + XCTAssertEqual(restored.selectedTabId, restored.tabs[1].id) + XCTAssertEqual(restored.tabs[0].customTitle, "First") + XCTAssertEqual(restored.tabs[1].customTitle, "Second") + } + + func testRestoreSessionSnapshotWithNoWorkspacesKeepsSingleFallbackWorkspace() { + let manager = TabManager() + let emptySnapshot = SessionTabManagerSnapshot( + selectedWorkspaceIndex: nil, + workspaces: [] + ) + + manager.restoreSessionSnapshot(emptySnapshot) + + XCTAssertEqual(manager.tabs.count, 1) + XCTAssertNotNil(manager.selectedTabId) + } + + func testSessionSnapshotExcludesRemoteWorkspacesFromRestore() throws { + let manager = TabManager() + let remoteWorkspace = manager.addWorkspace(select: true) + let configuration = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64001, + relayID: "relay-test", + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-test.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + remoteWorkspace.configureRemoteConnection(configuration, autoConnect: false) + let paneId = try XCTUnwrap(remoteWorkspace.bonsplitController.allPaneIds.first) + _ = remoteWorkspace.newBrowserSurface(inPane: paneId, url: URL(string: "http://localhost:3000"), focus: false) + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + + XCTAssertEqual(snapshot.workspaces.count, 1) + XCTAssertNil(snapshot.selectedWorkspaceIndex) + XCTAssertFalse(snapshot.workspaces.contains { $0.processTitle == remoteWorkspace.title }) + } +} diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift new file mode 100644 index 00000000..3ff8ce80 --- /dev/null +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -0,0 +1,258 @@ +import XCTest +import AppKit +import Darwin + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class TerminalControllerSocketSecurityTests: XCTestCase { + private func makeSocketPath(_ name: String) -> String { + let shortID = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8) + return URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("csec-\(name.prefix(4))-\(shortID).sock") + .path + } + + override func setUp() { + super.setUp() + TerminalController.shared.stop() + } + + override func tearDown() { + TerminalController.shared.stop() + super.tearDown() + } + + func testSocketPermissionsFollowAccessMode() throws { + let tabManager = TabManager() + + let allowAllPath = makeSocketPath("allow-all") + TerminalController.shared.start( + tabManager: tabManager, + socketPath: allowAllPath, + accessMode: .allowAll + ) + try waitForSocket(at: allowAllPath) + XCTAssertEqual(try socketMode(at: allowAllPath), 0o666) + + TerminalController.shared.stop() + + let restrictedPath = makeSocketPath("cmux-only") + TerminalController.shared.start( + tabManager: tabManager, + socketPath: restrictedPath, + accessMode: .cmuxOnly + ) + try waitForSocket(at: restrictedPath) + XCTAssertEqual(try socketMode(at: restrictedPath), 0o600) + } + + func testPasswordModeRejectsUnauthenticatedCommands() throws { + let socketPath = makeSocketPath("password-mode") + let tabManager = TabManager() + + TerminalController.shared.start( + tabManager: tabManager, + socketPath: socketPath, + accessMode: .password + ) + try waitForSocket(at: socketPath) + + let pingOnly = try sendCommands(["ping"], to: socketPath) + XCTAssertEqual(pingOnly.count, 1) + XCTAssertTrue(pingOnly[0].hasPrefix("ERROR:")) + XCTAssertFalse(pingOnly[0].localizedCaseInsensitiveContains("PONG")) + + let wrongAuthThenPing = try sendCommands( + ["auth not-the-password", "ping"], + to: socketPath + ) + XCTAssertEqual(wrongAuthThenPing.count, 2) + XCTAssertTrue(wrongAuthThenPing[0].hasPrefix("ERROR:")) + XCTAssertTrue(wrongAuthThenPing[1].hasPrefix("ERROR:")) + } + + func testSocketCommandPolicyDistinguishesFocusIntent() throws { +#if DEBUG + let nonFocus = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "ping", + isV2: false + ) + XCTAssertTrue(nonFocus.insideSuppressed) + XCTAssertFalse(nonFocus.insideAllowsFocus) + XCTAssertFalse(nonFocus.outsideSuppressed) + XCTAssertFalse(nonFocus.outsideAllowsFocus) + + let focusV1 = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "focus_window", + isV2: false + ) + XCTAssertTrue(focusV1.insideSuppressed) + XCTAssertTrue(focusV1.insideAllowsFocus) + XCTAssertFalse(focusV1.outsideSuppressed) + + let focusV2 = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "workspace.select", + isV2: true + ) + XCTAssertTrue(focusV2.insideSuppressed) + XCTAssertTrue(focusV2.insideAllowsFocus) + XCTAssertFalse(focusV2.outsideSuppressed) + + let moveWorkspace = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "workspace.move_to_window", + isV2: true + ) + XCTAssertTrue(moveWorkspace.insideSuppressed) + XCTAssertFalse(moveWorkspace.insideAllowsFocus) + + let triggerFlash = TerminalController.debugSocketCommandPolicySnapshot( + commandKey: "surface.trigger_flash", + isV2: true + ) + XCTAssertTrue(triggerFlash.insideSuppressed) + XCTAssertFalse(triggerFlash.insideAllowsFocus) +#else + throw XCTSkip("Socket command policy snapshot helper is debug-only.") +#endif + } + + func testRemoteStatusPayloadOmitsSensitiveSSHConfiguration() { + let tabManager = TabManager() + let workspace = tabManager.addWorkspace(select: false, eagerLoadTerminal: false) + + workspace.configureRemoteConnection( + .init( + destination: "example.com", + port: 2222, + identityFile: "/Users/test/.ssh/id_ed25519", + sshOptions: ["ControlMaster=auto", "ControlPersist=600"], + localProxyPort: 1080, + relayPort: 4444, + relayID: "relay-id", + relayToken: "relay-token", + localSocketPath: "/tmp/cmux-test.sock", + terminalStartupCommand: "ssh example.com" + ), + autoConnect: false + ) + + let payload = workspace.remoteStatusPayload() + XCTAssertNil(payload["identity_file"]) + XCTAssertNil(payload["ssh_options"]) + XCTAssertEqual(payload["has_identity_file"] as? Bool, true) + XCTAssertEqual(payload["has_ssh_options"] as? Bool, true) + } + + private func waitForSocket(at path: String, timeout: TimeInterval = 2.0) throws { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + FileManager.default.fileExists(atPath: path) + }, + object: NSObject() + ) + if XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed { + return + } + XCTFail("Timed out waiting for socket at \(path)") + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ETIMEDOUT)) + } + + private func socketMode(at path: String) throws -> UInt16 { + var fileInfo = stat() + guard lstat(path, &fileInfo) == 0 else { + throw posixError("lstat(\(path))") + } + return UInt16(fileInfo.st_mode & 0o777) + } + + private func sendCommands(_ commands: [String], to socketPath: String) throws -> [String] { + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw posixError("socket(AF_UNIX)") + } + defer { Darwin.close(fd) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + + let bytes = Array(socketPath.utf8) + let maxPathLen = MemoryLayout.size(ofValue: addr.sun_path) + guard bytes.count < maxPathLen else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(ENAMETOOLONG)) + } + + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + let cPath = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) + cPath.initialize(repeating: 0, count: maxPathLen) + for (index, byte) in bytes.enumerated() { + cPath[index] = CChar(bitPattern: byte) + } + } + + let addrLen = socklen_t(MemoryLayout<sa_family_t>.size + bytes.count + 1) + let connectResult = withUnsafePointer(to: &addr) { ptr -> Int32 in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(fd, sockaddrPtr, addrLen) + } + } + guard connectResult == 0 else { + throw posixError("connect(\(socketPath))") + } + + var responses: [String] = [] + for command in commands { + try writeLine(command, to: fd) + responses.append(try readLine(from: fd)) + } + return responses + } + + private func writeLine(_ command: String, to fd: Int32) throws { + let payload = Array((command + "\n").utf8) + var offset = 0 + while offset < payload.count { + let wrote = payload.withUnsafeBytes { raw in + Darwin.write(fd, raw.baseAddress!.advanced(by: offset), payload.count - offset) + } + guard wrote >= 0 else { + throw posixError("write(\(command))") + } + offset += wrote + } + } + + private func readLine(from fd: Int32) throws -> String { + var buffer = [UInt8](repeating: 0, count: 1) + var data = Data() + + while true { + let count = Darwin.read(fd, &buffer, 1) + guard count >= 0 else { + throw posixError("read") + } + if count == 0 { break } + if buffer[0] == 0x0A { break } + data.append(buffer[0]) + } + + guard let line = String(data: data, encoding: .utf8) else { + throw NSError(domain: NSCocoaErrorDomain, code: 0, userInfo: [ + NSLocalizedDescriptionKey: "Invalid UTF-8 response from socket" + ]) + } + return line + } + + private func posixError(_ operation: String) -> NSError { + NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: [NSLocalizedDescriptionKey: "\(operation) failed: \(String(cString: strerror(errno)))"] + ) + } +} diff --git a/cmuxTests/WorkspaceRemoteConnectionTests.swift b/cmuxTests/WorkspaceRemoteConnectionTests.swift new file mode 100644 index 00000000..5bf2fc3c --- /dev/null +++ b/cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -0,0 +1,204 @@ +import XCTest + +#if canImport(cmux) +@testable import cmux +#elseif canImport(cmux_DEV) +@testable import cmux_DEV +#endif + +final class WorkspaceRemoteConnectionTests: XCTestCase { + private struct ProcessRunResult { + let status: Int32 + let stdout: String + let stderr: String + let timedOut: Bool + } + + private func runProcess( + executablePath: String, + arguments: [String], + timeout: TimeInterval + ) -> ProcessRunResult { + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.standardInput = FileHandle.nullDevice + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + do { + try process.run() + } catch { + return ProcessRunResult( + status: -1, + stdout: "", + stderr: String(describing: error), + timedOut: false + ) + } + + let exitSignal = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + process.waitUntilExit() + exitSignal.signal() + } + + let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut + if timedOut { + process.terminate() + _ = exitSignal.wait(timeout: .now() + 1) + } + + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return ProcessRunResult( + status: process.terminationStatus, + stdout: stdout, + stderr: stderr, + timedOut: timedOut + ) + } + + func testRemoteRelayMetadataCleanupScriptRemovesMatchingSocketAddr() { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-cleanup-\(UUID().uuidString)") + let relayDir = home.appendingPathComponent(".cmux/relay") + let socketAddrURL = home.appendingPathComponent(".cmux/socket_addr") + let authURL = relayDir.appendingPathComponent("64008.auth") + let daemonPathURL = relayDir.appendingPathComponent("64008.daemon_path") + + XCTAssertNoThrow(try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true)) + XCTAssertNoThrow(try "127.0.0.1:64008".write(to: socketAddrURL, atomically: true, encoding: .utf8)) + XCTAssertNoThrow(try "auth".write(to: authURL, atomically: true, encoding: .utf8)) + XCTAssertNoThrow(try "daemon".write(to: daemonPathURL, atomically: true, encoding: .utf8)) + defer { try? fileManager.removeItem(at: home) } + + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "HOME=\(home.path)", + "/bin/sh", + "-c", + WorkspaceRemoteSessionController.remoteRelayMetadataCleanupScript(relayPort: 64008), + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertFalse(fileManager.fileExists(atPath: socketAddrURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: authURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path)) + } + + func testRemoteRelayMetadataCleanupScriptPreservesDifferentSocketAddr() { + let fileManager = FileManager.default + let home = fileManager.temporaryDirectory.appendingPathComponent("cmux-relay-cleanup-preserve-\(UUID().uuidString)") + let relayDir = home.appendingPathComponent(".cmux/relay") + let socketAddrURL = home.appendingPathComponent(".cmux/socket_addr") + let authURL = relayDir.appendingPathComponent("64009.auth") + let daemonPathURL = relayDir.appendingPathComponent("64009.daemon_path") + + XCTAssertNoThrow(try fileManager.createDirectory(at: relayDir, withIntermediateDirectories: true)) + XCTAssertNoThrow(try "127.0.0.1:64010".write(to: socketAddrURL, atomically: true, encoding: .utf8)) + XCTAssertNoThrow(try "auth".write(to: authURL, atomically: true, encoding: .utf8)) + XCTAssertNoThrow(try "daemon".write(to: daemonPathURL, atomically: true, encoding: .utf8)) + defer { try? fileManager.removeItem(at: home) } + + let result = runProcess( + executablePath: "/usr/bin/env", + arguments: [ + "HOME=\(home.path)", + "/bin/sh", + "-c", + WorkspaceRemoteSessionController.remoteRelayMetadataCleanupScript(relayPort: 64009), + ], + timeout: 5 + ) + + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertTrue(fileManager.fileExists(atPath: socketAddrURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: authURL.path)) + XCTAssertFalse(fileManager.fileExists(atPath: daemonPathURL.path)) + } + + func testReverseRelayStartupFailureDetailCapturesImmediateForwardingFailure() throws { + let process = Process() + let stderrPipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", "echo 'remote port forwarding failed for listen port 64009' >&2; exit 1"] + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = stderrPipe + + try process.run() + + let detail = WorkspaceRemoteSessionController.reverseRelayStartupFailureDetail( + process: process, + stderrPipe: stderrPipe, + gracePeriod: 1.0 + ) + + XCTAssertEqual(detail, "remote port forwarding failed for listen port 64009") + } + + @MainActor + func testProxyOnlyErrorsKeepSSHWorkspaceConnectedAndLoggedInSidebar() { + let workspace = Workspace() + let config = WorkspaceRemoteConfiguration( + destination: "cmux-macmini", + port: nil, + identityFile: nil, + sshOptions: [], + localProxyPort: nil, + relayPort: 64007, + relayID: String(repeating: "a", count: 16), + relayToken: String(repeating: "b", count: 64), + localSocketPath: "/tmp/cmux-debug-test.sock", + terminalStartupCommand: "ssh cmux-macmini" + ) + + workspace.configureRemoteConnection(config, autoConnect: false) + XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 1) + + let proxyError = "Remote proxy to cmux-macmini unavailable: Failed to start local daemon proxy: daemon RPC timeout waiting for hello response (retry in 3s)" + workspace.applyRemoteConnectionStateUpdate(.error, detail: proxyError, target: "cmux-macmini") + + XCTAssertEqual(workspace.remoteConnectionState, .connected) + XCTAssertEqual(workspace.remoteConnectionDetail, proxyError) + XCTAssertEqual( + workspace.statusEntries["remote.error"]?.value, + "Remote proxy unavailable (cmux-macmini): \(proxyError)" + ) + XCTAssertEqual(workspace.logEntries.last?.source, "remote-proxy") + XCTAssertEqual(workspace.remoteStatusPayload()["connected"] as? Bool, true) + XCTAssertEqual( + ((workspace.remoteStatusPayload()["proxy"] as? [String: Any])?["state"] as? String), + "error" + ) + + workspace.applyRemoteConnectionStateUpdate(.connecting, detail: "Connecting to cmux-macmini", target: "cmux-macmini") + + XCTAssertEqual(workspace.remoteConnectionState, .connected) + XCTAssertEqual( + workspace.statusEntries["remote.error"]?.value, + "Remote proxy unavailable (cmux-macmini): \(proxyError)" + ) + + workspace.applyRemoteConnectionStateUpdate( + .connected, + detail: "Connected to cmux-macmini via shared local proxy 127.0.0.1:9999", + target: "cmux-macmini" + ) + + XCTAssertEqual(workspace.remoteConnectionState, .connected) + XCTAssertNil(workspace.statusEntries["remote.error"]) + XCTAssertEqual( + ((workspace.remoteStatusPayload()["proxy"] as? [String: Any])?["state"] as? String), + "unavailable" + ) + } +} diff --git a/cmuxUITests/AutomationSocketUITests.swift b/cmuxUITests/AutomationSocketUITests.swift index 825207e5..ee2c189e 100644 --- a/cmuxUITests/AutomationSocketUITests.swift +++ b/cmuxUITests/AutomationSocketUITests.swift @@ -69,31 +69,35 @@ final class AutomationSocketUITests: XCTestCase { } private func waitForSocket(exists: Bool, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if FileManager.default.fileExists(atPath: socketPath) == exists { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return FileManager.default.fileExists(atPath: socketPath) == exists + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + FileManager.default.fileExists(atPath: self.socketPath) == exists + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func resolveSocketPath(timeout: TimeInterval) -> String? { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if FileManager.default.fileExists(atPath: socketPath) { - return socketPath - } - if let found = findSocketInTmp() { - return found - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + var resolvedPath: String? + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + if FileManager.default.fileExists(atPath: self.socketPath) { + resolvedPath = self.socketPath + return true + } + if let found = self.findSocketInTmp() { + resolvedPath = found + return true + } + return false + }, + object: NSObject() + ) + if XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed { + return resolvedPath } - if FileManager.default.fileExists(atPath: socketPath) { - return socketPath - } - return findSocketInTmp() + return resolvedPath } private func findSocketInTmp() -> String? { diff --git a/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift b/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift index 01b045c3..1f6a85c8 100644 --- a/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift +++ b/cmuxUITests/BrowserOmnibarSuggestionsUITests.swift @@ -96,15 +96,12 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { // After committing the autocompletion candidate, the omnibar should contain the URL. // Note: example.com may redirect to example.org in some environments. - let deadline = Date().addingTimeInterval(8.0) - while Date() < deadline { - let value = (omnibar.value as? String) ?? "" - if value.contains("example.com") || value.contains("example.org") { - return - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - XCTFail("Expected omnibar to navigate to example.com after keyboard nav + Enter. value=\(String(describing: omnibar.value))") + XCTAssertTrue( + waitForCondition(timeout: 8.0) { + self.containsExampleDomain((omnibar.value as? String) ?? "") + }, + "Expected omnibar to navigate to example.com after keyboard nav + Enter. value=\(String(describing: omnibar.value))" + ) } func testOmnibarEscapeAndClickOutsideBehaveLikeChrome() { @@ -135,18 +132,12 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) // Note: example.com may redirect to example.org in some environments. - func containsExampleDomain(_ value: String) -> Bool { - value.contains("example.com") || value.contains("example.org") - } - - let deadline = Date().addingTimeInterval(8.0) - while Date() < deadline { - let value = (omnibar.value as? String) ?? "" - if containsExampleDomain(value) { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } + XCTAssertTrue( + waitForCondition(timeout: 8.0) { + self.containsExampleDomain((omnibar.value as? String) ?? "") + }, + "Expected committed omnibar value to contain example.com or example.org. value=\(String(describing: omnibar.value))" + ) XCTAssertTrue(containsExampleDomain((omnibar.value as? String) ?? "")) // Type a new query to open the popup, then Escape should revert to the current URL. @@ -289,30 +280,19 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.typeKey("l", modifierFlags: [.command]) // Wait for navigation to finish so we can verify focus is held through page load. - let loaded = Date().addingTimeInterval(8.0) var loadObserved = false - while Date() < loaded { - let value = (omnibar.value as? String) ?? "" - if value.lowercased().contains("example.com") { - loadObserved = true - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.15)) + loadObserved = waitForCondition(timeout: 8.0) { + ((omnibar.value as? String) ?? "").lowercased().contains("example.com") } XCTAssertTrue(loadObserved, "Expected omnibar to reflect the navigated URL after load. value=\(omnibar.value)") let valueAfterLoad = (omnibar.value as? String) ?? "" omnibar.typeText("zx") - let typed = Date().addingTimeInterval(5.0) var valueCaptured = false - while Date() < typed { + valueCaptured = waitForCondition(timeout: 5.0) { let value = (omnibar.value as? String) ?? "" - if value.contains("zx") && value != valueAfterLoad { - valueCaptured = true - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return value.contains("zx") && value != valueAfterLoad } XCTAssertTrue(valueCaptured, "Expected omnirbar to keep keyboard focus after Cmd+L when navigation is in-flight. value=\(String(describing: omnibar.value))") @@ -346,15 +326,9 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { omnibar.typeText("example.com") app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) - let loadedDeadline = Date().addingTimeInterval(8.0) - var loaded = false - while Date() < loadedDeadline { + let loaded = waitForCondition(timeout: 8.0) { let value = ((omnibar.value as? String) ?? "").lowercased() - if value.contains("example.com") || value.contains("example.org") { - loaded = true - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + return self.containsExampleDomain(value) } XCTAssertTrue(loaded, "Expected baseline navigation to load before Cmd+L fast-typing check.") @@ -362,18 +336,11 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.typeKey("l", modifierFlags: [.command]) app.typeText("lo") - let typedDeadline = Date().addingTimeInterval(7.0) var observedValue = "" - var startsWithTypedPrefix = false - while Date() < typedDeadline { + let startsWithTypedPrefix = waitForCondition(timeout: 7.0) { observedValue = ((omnibar.value as? String) ?? "").lowercased() - if observedValue.hasPrefix("lo") { - startsWithTypedPrefix = true - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return observedValue.hasPrefix("lo") } - XCTAssertTrue( startsWithTypedPrefix, "Expected immediate typing after Cmd+L to preserve typed prefix 'lo'. value=\(observedValue)" @@ -411,19 +378,15 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { XCTAssertTrue(rows[0].waitForExistence(timeout: 4.0)) var gmailRowIndex: Int? - let gmailDeadline = Date().addingTimeInterval(4.0) - while Date() < gmailDeadline { + _ = waitForCondition(timeout: 4.0) { for (index, row) in rows.enumerated() where row.exists { let rowValue = (row.value as? String) ?? "" if rowValue.localizedCaseInsensitiveContains("gmail") { gmailRowIndex = index - break + return true } } - if gmailRowIndex != nil { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return false } guard let gmailRowIndex else { let rowValues = rows.enumerated().compactMap { index, row -> String? in @@ -447,15 +410,9 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: []) - let deadline = Date().addingTimeInterval(8.0) - var committedToGmail = false - while Date() < deadline { + let committedToGmail = waitForCondition(timeout: 8.0) { let value = (omnibar.value as? String) ?? "" - if value.localizedCaseInsensitiveContains("gmail.com") { - committedToGmail = true - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + return value.localizedCaseInsensitiveContains("gmail.com") } XCTAssertTrue(committedToGmail, "Expected Enter to commit Gmail autocomplete target. value=\(String(describing: omnibar.value))") } @@ -557,18 +514,14 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { omnibar.typeText("exam") let typedPrefix = "exam" - let inlineDeadline = Date().addingTimeInterval(3.0) var valueBeforeCmdA = "" - while Date() < inlineDeadline { + let sawInlineCompletion = waitForCondition(timeout: 3.0) { valueBeforeCmdA = (omnibar.value as? String) ?? "" let normalized = valueBeforeCmdA.lowercased() - if normalized.hasPrefix(typedPrefix), valueBeforeCmdA.utf16.count > typedPrefix.utf16.count { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return normalized.hasPrefix(typedPrefix) && valueBeforeCmdA.utf16.count > typedPrefix.utf16.count } XCTAssertTrue( - valueBeforeCmdA.lowercased().hasPrefix(typedPrefix) && valueBeforeCmdA.utf16.count > typedPrefix.utf16.count, + sawInlineCompletion, "Expected inline completion to extend typed prefix before Cmd+A. value=\(valueBeforeCmdA)" ) @@ -688,14 +641,9 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { } private func waitForSuggestionRowToBeSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if isSuggestionRowSelected(row) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + self.isSuggestionRowSelected(row) } - return isSuggestionRowSelected(row) } private func isSuggestionRowSelected(_ row: XCUIElement) -> Bool { @@ -734,26 +682,18 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { } private func focusOmnibarWithCmdL(app: XCUIApplication, omnibar: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + let attempts = max(1, Int(ceil(timeout))) + for _ in 0..<attempts { app.typeKey("l", modifierFlags: [.command]) guard omnibar.waitForExistence(timeout: 1.0) else { continue } let before = (omnibar.value as? String) ?? "" omnibar.typeText("z") - let probeDeadline = Date().addingTimeInterval(0.5) - var acceptedProbe = false - while Date() < probeDeadline { + if waitForCondition(timeout: 0.5, predicate: { let value = (omnibar.value as? String) ?? "" - if value != before { - acceptedProbe = true - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - - if acceptedProbe { + return value != before + }) { app.typeKey("a", modifierFlags: [.command]) app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: []) return true @@ -764,4 +704,16 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase { } return false } + + private func containsExampleDomain(_ value: String) -> Bool { + value.contains("example.com") || value.contains("example.org") + } + + private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in predicate() }, + object: nil + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } } diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift index e024151c..1c2bd61b 100644 --- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift +++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift @@ -925,40 +925,23 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { } private func waitForOmnibarToContainExampleDomain(_ omnibar: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + waitForCondition(timeout: timeout) { let value = (omnibar.value as? String) ?? "" - if value.contains("example.com") || value.contains("example.org") { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return value.contains("example.com") || value.contains("example.org") } - let value = (omnibar.value as? String) ?? "" - return value.contains("example.com") || value.contains("example.org") } private func waitForOmnibarToContain(_ omnibar: XCUIElement, value expectedSubstring: String, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + waitForCondition(timeout: timeout) { let value = (omnibar.value as? String) ?? "" - if value.contains(expectedSubstring) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return value.contains(expectedSubstring) } - let value = (omnibar.value as? String) ?? "" - return value.contains(expectedSubstring) } private func waitForElementToBecomeHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if element.exists && element.isHittable { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + element.exists && element.isHittable } - return element.exists && element.isHittable } private var autofocusRacePageURL: String { @@ -989,31 +972,17 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { } private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + guard let data = self.loadData() else { return false } + return keys.allSatisfy { data[$0] != nil } } - if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - return false } - private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadData(), predicate(data) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + private func waitForDataMatch(timeout: TimeInterval, predicate: @escaping ([String: String]) -> Bool) -> Bool { + waitForCondition(timeout: timeout) { + guard let data = self.loadData() else { return false } + return predicate(data) } - if let data = loadData(), predicate(data) { - return true - } - return false } private func waitForNonExistence(_ element: XCUIElement, timeout: TimeInterval) -> Bool { @@ -1028,4 +997,12 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase { } return (try? JSONSerialization.jsonObject(with: data)) as? [String: String] } + + private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in predicate() }, + object: nil + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } } diff --git a/cmuxUITests/CloseWindowConfirmDialogUITests.swift b/cmuxUITests/CloseWindowConfirmDialogUITests.swift index f64078d4..9ae8c87c 100644 --- a/cmuxUITests/CloseWindowConfirmDialogUITests.swift +++ b/cmuxUITests/CloseWindowConfirmDialogUITests.swift @@ -68,36 +68,33 @@ final class CloseWindowConfirmDialogUITests: XCTestCase { } private func waitForCloseWindowAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if isCloseWindowAlertPresent(app: app) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return isCloseWindowAlertPresent(app: app) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.isCloseWindowAlertPresent(app: app) + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForCloseWindowAlertToDismiss(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if !isCloseWindowAlertPresent(app: app) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return !isCloseWindowAlertPresent(app: app) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + !self.isCloseWindowAlertPresent(app: app) + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForMainWindowToClose(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if !app.windows.firstMatch.exists { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return !app.windows.firstMatch.exists + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + !app.windows.firstMatch.exists + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func clickCancelOnCloseWindowAlert(app: XCUIApplication) { diff --git a/cmuxUITests/CloseWorkspaceCmdDUITests.swift b/cmuxUITests/CloseWorkspaceCmdDUITests.swift index b9061916..7389a5e3 100644 --- a/cmuxUITests/CloseWorkspaceCmdDUITests.swift +++ b/cmuxUITests/CloseWorkspaceCmdDUITests.swift @@ -604,23 +604,25 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { } private func waitForCloseWorkspaceAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.dialogs.containing(.staticText, identifier: "Close workspace?").firstMatch.exists { return true } - if app.alerts.containing(.staticText, identifier: "Close workspace?").firstMatch.exists { return true } - if app.staticTexts["Close workspace?"].exists { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return false + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + app.dialogs.containing(.staticText, identifier: "Close workspace?").firstMatch.exists || + app.alerts.containing(.staticText, identifier: "Close workspace?").firstMatch.exists || + app.staticTexts["Close workspace?"].exists + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForCloseTabAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if isCloseTabAlertPresent(app: app) { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return isCloseTabAlertPresent(app: app) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.isCloseTabAlertPresent(app: app) + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } // Must match the defaultValue for dialog.closeTab.title in TabManager. @@ -651,65 +653,72 @@ final class CloseWorkspaceCmdDUITests: XCTestCase { } private func waitForWindowCount(app: XCUIApplication, toBe count: Int, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.windows.count == count { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return app.windows.count == count + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + app.windows.count == count + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForWindowCount(app: XCUIApplication, atLeast count: Int, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.windows.count >= count { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return app.windows.count >= count + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + app.windows.count >= count + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForNoWindowsOrAppNotRunningForeground(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.state != .runningForeground { return true } - if app.windows.count == 0 { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return app.state != .runningForeground || app.windows.count == 0 + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + app.state != .runningForeground || app.windows.count == 0 + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForKeyequivInt(_ key: String, toBeAtLeast expected: Int, atPath path: String, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - let value = loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0 - if value >= expected { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - let value = loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0 - return value >= expected + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + let value = self.loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0 + return value >= expected + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForAnyJSON(atPath path: String, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if loadJSON(atPath: path) != nil { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return loadJSON(atPath: path) != nil + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.loadJSON(atPath: path) != nil + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForJSONKey(_ key: String, equals expected: String, atPath path: String, timeout: TimeInterval) -> [String: String]? { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadJSON(atPath: path), data[key] == expected { - return data - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + var matchedData: [String: String]? + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + guard let data = self.loadJSON(atPath: path), data[key] == expected else { + return false + } + matchedData = data + return true + }, + object: NSObject() + ) + guard XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed else { + return nil } - if let data = loadJSON(atPath: path), data[key] == expected { - return data - } - return nil + return matchedData } private func assertCtrlDPreconditionsBeforeTrigger( diff --git a/cmuxUITests/CloseWorkspaceConfirmDialogUITests.swift b/cmuxUITests/CloseWorkspaceConfirmDialogUITests.swift index 47bfb9f3..d277a58e 100644 --- a/cmuxUITests/CloseWorkspaceConfirmDialogUITests.swift +++ b/cmuxUITests/CloseWorkspaceConfirmDialogUITests.swift @@ -36,14 +36,13 @@ final class CloseWorkspaceConfirmDialogUITests: XCTestCase { } private func waitForCloseWorkspaceAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if isCloseWorkspaceAlertPresent(app: app) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return isCloseWorkspaceAlertPresent(app: app) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.isCloseWorkspaceAlertPresent(app: app) + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func clickCancelOnCloseWorkspaceAlert(app: XCUIApplication) { diff --git a/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift b/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift index c6604cb5..6bdb5284 100644 --- a/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift +++ b/cmuxUITests/CloseWorkspacesConfirmDialogUITests.swift @@ -110,25 +110,23 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase { } private func waitForSocketPong(timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if socketCommand("ping") == "PONG" { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return socketCommand("ping") == "PONG" + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.socketCommand("ping") == "PONG" + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func waitForWorkspaceCount(_ expectedCount: Int, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if workspaceCount() == expectedCount { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return workspaceCount() == expectedCount + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.workspaceCount() == expectedCount + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func workspaceCount() -> Int { @@ -182,14 +180,13 @@ final class CloseWorkspacesConfirmDialogUITests: XCTestCase { } private func waitForCloseWorkspacesAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if isCloseWorkspacesAlertPresent(app: app) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return isCloseWorkspacesAlertPresent(app: app) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + self.isCloseWorkspacesAlertPresent(app: app) + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func clickCancelOnCloseWorkspacesAlert(app: XCUIApplication) { diff --git a/cmuxUITests/JumpToUnreadUITests.swift b/cmuxUITests/JumpToUnreadUITests.swift index a55f4afa..59d2ba39 100644 --- a/cmuxUITests/JumpToUnreadUITests.swift +++ b/cmuxUITests/JumpToUnreadUITests.swift @@ -50,17 +50,14 @@ final class JumpToUnreadUITests: XCTestCase { } private func waitForJumpUnreadData(keys: [String], timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadJumpUnreadData(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - if let data = loadJumpUnreadData(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - return false + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + guard let data = self.loadJumpUnreadData() else { return false } + return keys.allSatisfy { data[$0] != nil } + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func loadJumpUnreadData() -> [String: String]? { diff --git a/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift b/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift index 1f249d61..8ff0ab47 100644 --- a/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift +++ b/cmuxUITests/MenuKeyEquivalentRoutingUITests.swift @@ -4,6 +4,16 @@ import CoreGraphics import ImageIO import Darwin +private extension XCTestCase { + func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in predicate() }, + object: nil + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } +} + final class MenuKeyEquivalentRoutingUITests: XCTestCase { private var gotoSplitPath = "" private var keyequivPath = "" @@ -126,44 +136,24 @@ final class MenuKeyEquivalentRoutingUITests: XCTestCase { } private func waitForGotoSplit(keys: [String], timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadGotoSplit(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + guard let data = self.loadGotoSplit() else { return false } + return keys.allSatisfy { data[$0] != nil } } - if let data = loadGotoSplit(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - return false } - private func waitForGotoSplitMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadGotoSplit(), predicate(data) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + private func waitForGotoSplitMatch(timeout: TimeInterval, predicate: @escaping ([String: String]) -> Bool) -> Bool { + waitForCondition(timeout: timeout) { + guard let data = self.loadGotoSplit() else { return false } + return predicate(data) } - if let data = loadGotoSplit(), predicate(data) { - return true - } - return false } private func waitForKeyequivInt(key: String, toBeAtLeast expected: Int, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - let value = loadKeyequiv()[key].flatMap(Int.init) ?? 0 - if value >= expected { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + let value = self.loadKeyequiv()[key].flatMap(Int.init) ?? 0 + return value >= expected } - let value = loadKeyequiv()[key].flatMap(Int.init) ?? 0 - return value >= expected } private func loadGotoSplit() -> [String: String]? { @@ -280,13 +270,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") // Wait for the app-side repro loop to finish. - let doneDeadline = Date().addingTimeInterval(90.0) - while Date() < doneDeadline { - if let data = loadData(), data["visualDone"] == "1" { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.10)) - } + XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)") guard let data = loadData() else { XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") @@ -329,13 +313,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") - let doneDeadline = Date().addingTimeInterval(90.0) - while Date() < doneDeadline { - if let data = loadData(), data["visualDone"] == "1" { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.10)) - } + XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)") guard let data = loadData() else { XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") @@ -373,13 +351,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") - let doneDeadline = Date().addingTimeInterval(90.0) - while Date() < doneDeadline { - if let data = loadData(), data["visualDone"] == "1" { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.10)) - } + XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)") guard let data = loadData() else { XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") @@ -423,13 +395,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") - let doneDeadline = Date().addingTimeInterval(90.0) - while Date() < doneDeadline { - if let data = loadData(), data["visualDone"] == "1" { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.10)) - } + XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)") guard let data = loadData() else { XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") @@ -474,13 +440,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") - let doneDeadline = Date().addingTimeInterval(90.0) - while Date() < doneDeadline { - if let data = loadData(), data["visualDone"] == "1" { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.10)) - } + XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)") guard let data = loadData() else { XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") @@ -523,13 +483,7 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTAssertTrue(waitForAnyData(timeout: 12.0), "Expected split-close-right test data to be written at \(dataPath)") - let doneDeadline = Date().addingTimeInterval(90.0) - while Date() < doneDeadline { - if let data = loadData(), data["visualDone"] == "1" { - break - } - RunLoop.current.run(until: Date().addingTimeInterval(0.10)) - } + XCTAssertTrue(waitForVisualDone(timeout: 90.0), "Expected visual repro loop to finish. path=\(dataPath)") guard let data = loadData() else { XCTFail("Missing split-close-right data after waiting. path=\(dataPath)") @@ -638,13 +592,12 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { } // Also guard against a delayed blanking: watch for ~1.5s and fail if it goes blank for sustained streak. - let deadline = Date().addingTimeInterval(1.5) var blankStreak = 0 - var sampleIndex = 0 - while Date() < deadline { - sampleIndex += 1 + for sampleIndex in 1...9 { guard let (path, stats) = takeStats("\(label)-watch-\(String(format: "%02d", sampleIndex))", crop: blankCrop) else { - RunLoop.current.run(until: Date().addingTimeInterval(0.17)) + if sampleIndex < 9 { + RunLoop.current.run(until: Date().addingTimeInterval(0.17)) + } continue } if stats.isProbablyBlank { @@ -657,7 +610,9 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { XCTFail("Pane became blank for sustained period after close. label=\(label) stats=\(stats) shots=\(screenshotDir)") return } - RunLoop.current.run(until: Date().addingTimeInterval(0.17)) + if sampleIndex < 9 { + RunLoop.current.run(until: Date().addingTimeInterval(0.17)) + } } } @@ -852,76 +807,54 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { } private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + guard let data = self.loadData() else { return false } + return keys.allSatisfy { data[$0] != nil } } - if let data = loadData(), keys.allSatisfy({ data[$0] != nil }) { - return true - } - return false } private func waitForAnyData(timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if loadData() != nil { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + self.loadData() != nil } - return loadData() != nil } private func waitForSettledData(timeout: TimeInterval) -> [String: String]? { - let deadline = Date().addingTimeInterval(timeout) var last: [String: String]? - while Date() < deadline { - if let data = loadData() { - last = data + _ = waitForCondition(timeout: timeout) { + guard let data = self.loadData() else { return false } + last = data - if let setupError = data["setupError"], !setupError.isEmpty { - return data - } - - let finalPaneCount = Int(data["finalPaneCount"] ?? "") ?? -1 - let missingSelected = Int(data["missingSelectedTabCount"] ?? "") ?? -1 - let missingMapping = Int(data["missingPanelMappingCount"] ?? "") ?? -1 - let emptyPanels = Int(data["emptyPanelAppearCount"] ?? "") ?? -1 - let selectedTerminalCount = Int(data["selectedTerminalCount"] ?? "") ?? -1 - let selectedTerminalAttached = Int(data["selectedTerminalAttachedCount"] ?? "") ?? -1 - let selectedTerminalZeroSize = Int(data["selectedTerminalZeroSizeCount"] ?? "") ?? -1 - let selectedTerminalSurfaceNil = Int(data["selectedTerminalSurfaceNilCount"] ?? "") ?? -1 - - let settled = - finalPaneCount == 2 && - missingSelected == 0 && - missingMapping == 0 && - emptyPanels == 0 && - selectedTerminalCount == 2 && - selectedTerminalAttached == 2 && - selectedTerminalZeroSize == 0 && - selectedTerminalSurfaceNil == 0 - - if settled { - return data - } - - // `recordSplitCloseRightFinalState` streams attempts; give it time to converge. - // If the bug is present it will never converge to "settled". - let attempt = Int(data["finalAttempt"] ?? "") ?? -1 - if attempt >= 20 { - return data - } + if let setupError = data["setupError"], !setupError.isEmpty { + return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } + let finalPaneCount = Int(data["finalPaneCount"] ?? "") ?? -1 + let missingSelected = Int(data["missingSelectedTabCount"] ?? "") ?? -1 + let missingMapping = Int(data["missingPanelMappingCount"] ?? "") ?? -1 + let emptyPanels = Int(data["emptyPanelAppearCount"] ?? "") ?? -1 + let selectedTerminalCount = Int(data["selectedTerminalCount"] ?? "") ?? -1 + let selectedTerminalAttached = Int(data["selectedTerminalAttachedCount"] ?? "") ?? -1 + let selectedTerminalZeroSize = Int(data["selectedTerminalZeroSizeCount"] ?? "") ?? -1 + let selectedTerminalSurfaceNil = Int(data["selectedTerminalSurfaceNilCount"] ?? "") ?? -1 + let settled = + finalPaneCount == 2 && + missingSelected == 0 && + missingMapping == 0 && + emptyPanels == 0 && + selectedTerminalCount == 2 && + selectedTerminalAttached == 2 && + selectedTerminalZeroSize == 0 && + selectedTerminalSurfaceNil == 0 + if settled { + return true + } + + let attempt = Int(data["finalAttempt"] ?? "") ?? -1 + return attempt >= 20 + } return last } @@ -942,14 +875,15 @@ final class SplitCloseRightBlankRegressionUITests: XCTestCase { // MARK: - Automation Socket Client (UI Tests) private func waitForSocketPong(timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if socketCommand("ping") == "PONG" { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + self.socketCommand("ping") == "PONG" + } + } + + private func waitForVisualDone(timeout: TimeInterval) -> Bool { + waitForCondition(timeout: timeout) { + self.loadData()?["visualDone"] == "1" } - return socketCommand("ping") == "PONG" } private func socketCommand(_ cmd: String) -> String? { diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift index 2c2bba0b..d433dc94 100644 --- a/cmuxUITests/MultiWindowNotificationsUITests.swift +++ b/cmuxUITests/MultiWindowNotificationsUITests.swift @@ -399,12 +399,9 @@ final class MultiWindowNotificationsUITests: XCTestCase { } private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.windows.count >= count { return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + app.windows.count >= count } - return app.windows.count >= count } private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { @@ -425,84 +422,51 @@ final class MultiWindowNotificationsUITests: XCTestCase { } private func waitForFocusChange(from token: String?, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadData(), - let current = data["focusToken"], - !current.isEmpty, - current != token { - return true + waitForCondition(timeout: timeout) { + guard let data = self.loadData(), + let current = data["focusToken"], + !current.isEmpty else { + return false } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return current != token } - if let data = loadData(), - let current = data["focusToken"], - !current.isEmpty, - current != token { - return true - } - return false } private func waitForData(keys: [String], timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadData(), keys.allSatisfy({ (data[$0] ?? "").isEmpty == false }) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + guard let data = self.loadData() else { return false } + return keys.allSatisfy { (data[$0] ?? "").isEmpty == false } } - if let data = loadData(), keys.allSatisfy({ (data[$0] ?? "").isEmpty == false }) { - return true - } - return false } - private func waitForDataMatch(timeout: TimeInterval, predicate: ([String: String]) -> Bool) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let data = loadData(), predicate(data) { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + private func waitForDataMatch(timeout: TimeInterval, predicate: @escaping ([String: String]) -> Bool) -> Bool { + waitForCondition(timeout: timeout) { + guard let data = self.loadData() else { return false } + return predicate(data) } - if let data = loadData(), predicate(data) { - return true - } - return false } private func waitForSocketPong(timeout: TimeInterval) -> String? { - let deadline = Date().addingTimeInterval(timeout) var lastResponse: String? - while Date() < deadline { - lastResponse = socketCommand("ping") - if lastResponse == "PONG" { - return "PONG" - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + _ = waitForCondition(timeout: timeout) { + lastResponse = self.socketCommand("ping") + return lastResponse == "PONG" } - return socketCommand("ping") ?? lastResponse + return lastResponse == "PONG" ? "PONG" : (socketCommand("ping") ?? lastResponse) } private func waitForTerminalFocus(surfaceId: String, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if socketCommand("is_terminal_focused \(surfaceId)") == "true" { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + self.socketCommand("is_terminal_focused \(surfaceId)") == "true" } - return socketCommand("is_terminal_focused \(surfaceId)") == "true" } private func waitForCmuxPing(timeout: TimeInterval) -> (stdout: String?, stderr: String?) { - let deadline = Date().addingTimeInterval(timeout) var lastStdout: String? var lastStderr: String? - while Date() < deadline { - let result = runCmuxCommand( - socketPath: socketPath, + let didSucceed = waitForCondition(timeout: timeout) { + let result = self.runCmuxCommand( + socketPath: self.socketPath, arguments: ["ping"], responseTimeoutSeconds: 2.0 ) @@ -515,24 +479,22 @@ final class MultiWindowNotificationsUITests: XCTestCase { lastStderr = stderr } if result.terminationStatus == 0, stdout == "PONG" { - return ("PONG", stderr) + return true } - if isSocketPermissionFailure(stderr), - waitForSocketPong(timeout: 0.5) == "PONG" { - return ("PONG", stderr) + if self.isSocketPermissionFailure(stderr), + self.waitForSocketPong(timeout: 0.5) == "PONG" { + return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return false + } + if didSucceed { + return ("PONG", lastStderr) } - let result = runCmuxCommand( - socketPath: socketPath, - arguments: ["ping"], - responseTimeoutSeconds: 2.0 - ) + let result = runCmuxCommand(socketPath: socketPath, arguments: ["ping"], responseTimeoutSeconds: 2.0) let stdout = result.stdout.isEmpty ? nil : result.stdout let stderr = result.stderr.isEmpty ? nil : result.stderr - if isSocketPermissionFailure(stderr), - waitForSocketPong(timeout: 0.5) == "PONG" { + if isSocketPermissionFailure(stderr), waitForSocketPong(timeout: 0.5) == "PONG" { return ("PONG", stderr) } return (stdout ?? lastStdout, stderr ?? lastStderr) @@ -543,41 +505,30 @@ final class MultiWindowNotificationsUITests: XCTestCase { app: XCUIApplication, timeout: TimeInterval ) -> Bool { - let deadline = Date().addingTimeInterval(timeout) var sawCompletion = false - while Date() < deadline { + let completed = waitForCondition(timeout: timeout) { if app.state == .runningForeground { return false } if FileManager.default.fileExists(atPath: statusPath) { sawCompletion = true - break + return true } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return false } - guard sawCompletion || FileManager.default.fileExists(atPath: statusPath) else { + guard completed || sawCompletion || FileManager.default.fileExists(atPath: statusPath) else { return false } - let postCompletionDeadline = Date().addingTimeInterval(0.75) - while Date() < postCompletionDeadline { - if app.state == .runningForeground { - return false - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return waitForCondition(timeout: 0.75) { + app.state != .runningForeground } - return app.state != .runningForeground } private func waitForAppToLeaveForeground(_ app: XCUIApplication, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if app.state != .runningForeground { - return true - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + waitForCondition(timeout: timeout) { + app.state != .runningForeground } - return app.state != .runningForeground } private func firstSurfaceId(forWorkspaceId workspaceId: String) -> String? { @@ -600,25 +551,29 @@ final class MultiWindowNotificationsUITests: XCTestCase { } private func waitForSurfaceId(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let surfaceId = firstSurfaceId(forWorkspaceId: workspaceId) { - return surfaceId - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + var surfaceId: String? + _ = waitForCondition(timeout: timeout) { + surfaceId = self.firstSurfaceId(forWorkspaceId: workspaceId) + return surfaceId != nil } - return firstSurfaceId(forWorkspaceId: workspaceId) + return surfaceId ?? firstSurfaceId(forWorkspaceId: workspaceId) } private func waitForSurfaceIdViaCLI(forWorkspaceId workspaceId: String, timeout: TimeInterval) -> String? { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if let surfaceId = firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) { - return surfaceId - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + var surfaceId: String? + _ = waitForCondition(timeout: timeout) { + surfaceId = self.firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) + return surfaceId != nil } - return firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) + return surfaceId ?? firstSurfaceIdViaCLI(forWorkspaceId: workspaceId) + } + + private func waitForCondition(timeout: TimeInterval, predicate: @escaping () -> Bool) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in predicate() }, + object: nil + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } private func firstSurfaceIdViaCLI(forWorkspaceId workspaceId: String) -> String? { @@ -938,24 +893,29 @@ final class MultiWindowNotificationsUITests: XCTestCase { fallbackCandidates = [] } - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { + var resolvedPath: String? + _ = waitForCondition(timeout: timeout) { for candidate in primaryCandidates { guard FileManager.default.fileExists(atPath: candidate) else { continue } // Primary candidate is the explicitly requested CMUX_SOCKET_PATH. If it responds, // prefer it even before workspace contents are fully initialized. - if socketRespondsToPing(at: candidate) { - return candidate + if self.socketRespondsToPing(at: candidate) { + resolvedPath = candidate + return true } } for candidate in fallbackCandidates { guard FileManager.default.fileExists(atPath: candidate) else { continue } - if socketRespondsToPing(at: candidate), - socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) { - return candidate + if self.socketRespondsToPing(at: candidate), + self.socketMatchesRequiredWorkspace(candidate, workspaceId: requiredWorkspaceId) { + resolvedPath = candidate + return true } } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + return false + } + if let resolvedPath { + return resolvedPath } for candidate in primaryCandidates { guard FileManager.default.fileExists(atPath: candidate) else { continue } @@ -1108,6 +1068,10 @@ final class MultiWindowNotificationsUITests: XCTestCase { let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { return nil } defer { close(fd) } + var socketTimeout = timeval( + tv_sec: Int(responseTimeout.rounded(.down)), + tv_usec: Int32(((responseTimeout - floor(responseTimeout)) * 1_000_000).rounded()) + ) #if os(macOS) var noSigPipe: Int32 = 1 @@ -1121,6 +1085,24 @@ final class MultiWindowNotificationsUITests: XCTestCase { ) } #endif + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_RCVTIMEO, + ptr, + socklen_t(MemoryLayout<timeval>.size) + ) + } + _ = withUnsafePointer(to: &socketTimeout) { ptr in + setsockopt( + fd, + SOL_SOCKET, + SO_SNDTIMEO, + ptr, + socklen_t(MemoryLayout<timeval>.size) + ) + } var addr = sockaddr_un() memset(&addr, 0, MemoryLayout<sockaddr_un>.size) @@ -1164,19 +1146,17 @@ final class MultiWindowNotificationsUITests: XCTestCase { } guard wrote else { return nil } - let deadline = Date().addingTimeInterval(responseTimeout) var buf = [UInt8](repeating: 0, count: 4096) var accum = "" - while Date() < deadline { - var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) - let ready = poll(&pollDescriptor, 1, 100) - if ready < 0 { + while true { + let n = read(fd, &buf, buf.count) + if n < 0 { + let code = errno + if code == EAGAIN || code == EWOULDBLOCK { + break + } return nil } - if ready == 0 { - continue - } - let n = read(fd, &buf, buf.count) if n <= 0 { break } if let chunk = String(bytes: buf[0..<n], encoding: .utf8) { accum.append(chunk) diff --git a/cmuxUITests/SidebarResizeUITests.swift b/cmuxUITests/SidebarResizeUITests.swift index a29d0c96..d8c7d7ab 100644 --- a/cmuxUITests/SidebarResizeUITests.swift +++ b/cmuxUITests/SidebarResizeUITests.swift @@ -90,16 +90,14 @@ final class SidebarResizeUITests: XCTestCase { } private func waitForElementHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if element.exists, element.isHittable { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in + guard element.exists, element.isHittable else { return false } let frame = element.frame - if frame.width > 1, frame.height > 1 { - return true - } - } - RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } - return false + return frame.width > 1 && frame.height > 1 + }, + object: NSObject() + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed } } diff --git a/daemon/remote/README.md b/daemon/remote/README.md new file mode 100644 index 00000000..9bf4c758 --- /dev/null +++ b/daemon/remote/README.md @@ -0,0 +1,83 @@ +# cmuxd-remote (Go) + +Go remote daemon for `cmux ssh` bootstrap, capability negotiation, and remote proxy RPC. It is not in the terminal keystroke hot path. + +## Commands + +1. `cmuxd-remote version` +2. `cmuxd-remote serve --stdio` +3. `cmuxd-remote cli <command> [args...]` — relay cmux commands to the local app over the reverse SSH forward + +When invoked as `cmux` (via wrapper/symlink installed during bootstrap), the binary auto-dispatches to the `cli` subcommand. This is busybox-style argv[0] detection. + +## RPC methods (newline-delimited JSON over stdio) + +1. `hello` +2. `ping` +3. `proxy.open` +4. `proxy.close` +5. `proxy.write` +6. `proxy.stream.subscribe` +7. async `proxy.stream.data` / `proxy.stream.eof` / `proxy.stream.error` events +8. `session.open` +9. `session.close` +10. `session.attach` +11. `session.resize` +12. `session.detach` +13. `session.status` + +Current integration in cmux: +1. `workspace.remote.configure` now bootstraps this binary over SSH when missing. +2. Client sends `hello` before enabling remote proxy transport. +3. Local workspace proxy broker serves SOCKS5 + HTTP CONNECT and tunnels stream traffic through `proxy.*` RPC over `serve --stdio`, using daemon-pushed stream events instead of polling reads. +4. Daemon status/capabilities are exposed in `workspace.remote.status -> remote.daemon` (including `session.resize.min`). + +`workspace.remote.configure` contract notes: +1. `port` / `local_proxy_port` accept integer values and numeric strings; explicit `null` clears each field. +2. Out-of-range values and invalid types return `invalid_params`. +3. `local_proxy_port` is an internal deterministic test hook used by bind-conflict regressions. +4. SSH option precedence checks are case-insensitive; user overrides for `StrictHostKeyChecking` and control-socket keys prevent default injection. + +## Distribution + +Release and nightly builds publish prebuilt `cmuxd-remote` binaries on GitHub Releases for: +1. `darwin/arm64` +2. `darwin/amd64` +3. `linux/arm64` +4. `linux/amd64` + +The app embeds a compact manifest in `Info.plist` with: +1. exact release asset URLs +2. pinned SHA-256 digests +3. release tag and checksums asset URL + +Release and nightly apps download and cache the matching binary locally, verify its SHA-256, then upload it to the remote host if needed. Dev builds can opt into a local `go build` fallback with `CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1`. + +To inspect what a given app build trusts, run: +1. `cmux remote-daemon-status` +2. `cmux remote-daemon-status --os linux --arch amd64` + +The command prints the exact release asset URL, expected SHA-256, local cache status, and a copy-pasteable `gh attestation verify` command for the selected platform. + +## CLI relay + +The `cli` subcommand (or `cmux` wrapper/symlink) connects to the local cmux app through an SSH reverse forward and relays commands. It supports both v1 text protocol and v2 JSON-RPC commands. + +Socket discovery order: +1. `--socket <path>` flag +2. `CMUX_SOCKET_PATH` environment variable +3. `~/.cmux/socket_addr` file (written by the app after the reverse relay establishes) + +For TCP addresses, the CLI dials once and only refreshes `~/.cmux/socket_addr` a single time if the first address was stale. Relay metadata is published only after the reverse forward is ready, so steady-state use does not rely on polling. + +Authenticated relay details: +1. Each SSH workspace gets its own relay ID and relay token. +2. The app runs a local loopback relay server that requires an HMAC-SHA256 challenge-response before forwarding a command to the real local Unix socket. +3. The remote shell never gets direct access to the local app socket. It only gets the reverse-forwarded relay port plus `~/.cmux/relay/<port>.auth`, which is written with `0600` permissions and removed when the relay stops. + +Integration additions for the relay path: + +1. Bootstrap installs `~/.cmux/bin/cmux` wrapper and keeps a default daemon target (`~/.cmux/bin/cmuxd-remote-current`). +2. A background `ssh -N -R` process reverse-forwards a TCP port to the authenticated local relay server. The relay address is written to `~/.cmux/socket_addr` on the remote. +3. Relay startup writes `~/.cmux/relay/<port>.daemon_path` so the wrapper can route each shell to the correct daemon binary when multiple local cmux instances or versions coexist. +4. Relay startup writes `~/.cmux/relay/<port>.auth` with the relay ID and token needed for HMAC authentication. diff --git a/daemon/remote/cmd/cmuxd-remote/cli.go b/daemon/remote/cmd/cmuxd-remote/cli.go new file mode 100644 index 00000000..2b2bf585 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/cli.go @@ -0,0 +1,758 @@ +package main + +import ( + "bufio" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "time" +) + +type relayAuthState struct { + RelayID string `json:"relay_id"` + RelayToken string `json:"relay_token"` +} + +// protocolVersion indicates whether a command uses the v1 text or v2 JSON-RPC protocol. +type protocolVersion int + +const ( + protoV1 protocolVersion = iota + protoV2 +) + +// commandSpec describes a single CLI command and how to relay it. +type commandSpec struct { + name string // CLI command name (e.g. "ping", "new-window") + proto protocolVersion // v1 text or v2 JSON-RPC + v1Cmd string // v1: literal command string sent over the socket + v2Method string // v2: JSON-RPC method name + // flagKeys lists parameter keys this command accepts. + // They are extracted from --key flags and added to params. + flagKeys []string + // noParams means the command takes no parameters at all. + noParams bool + // paramKeyOverrides remaps specific flags for compatibility aliases. + paramKeyOverrides map[string]string + // defaultParams are applied before flags/env fallbacks. + defaultParams map[string]any +} + +var commands = []commandSpec{ + // V1 text protocol commands + {name: "ping", proto: protoV1, v1Cmd: "ping", noParams: true}, + {name: "new-window", proto: protoV1, v1Cmd: "new_window", noParams: true}, + {name: "current-window", proto: protoV1, v1Cmd: "current_window", noParams: true}, + {name: "close-window", proto: protoV1, v1Cmd: "close_window", flagKeys: []string{"window"}}, + {name: "focus-window", proto: protoV1, v1Cmd: "focus_window", flagKeys: []string{"window"}}, + {name: "list-windows", proto: protoV1, v1Cmd: "list_windows", noParams: true}, + + // V2 JSON-RPC commands + {name: "capabilities", proto: protoV2, v2Method: "system.capabilities", noParams: true}, + {name: "list-workspaces", proto: protoV2, v2Method: "workspace.list", noParams: true}, + {name: "new-workspace", proto: protoV2, v2Method: "workspace.create", flagKeys: []string{"command", "working-directory", "name"}}, + {name: "close-workspace", proto: protoV2, v2Method: "workspace.close", flagKeys: []string{"workspace"}}, + {name: "select-workspace", proto: protoV2, v2Method: "workspace.select", flagKeys: []string{"workspace"}}, + {name: "current-workspace", proto: protoV2, v2Method: "workspace.current", noParams: true}, + {name: "list-panels", proto: protoV2, v2Method: "surface.list", flagKeys: []string{"workspace"}}, + {name: "focus-panel", proto: protoV2, v2Method: "surface.focus", flagKeys: []string{"panel", "workspace"}, paramKeyOverrides: map[string]string{"panel": "surface_id"}}, + {name: "list-panes", proto: protoV2, v2Method: "pane.list", flagKeys: []string{"workspace"}}, + {name: "list-pane-surfaces", proto: protoV2, v2Method: "pane.surfaces", flagKeys: []string{"pane"}}, + {name: "new-pane", proto: protoV2, v2Method: "pane.create", flagKeys: []string{"workspace", "direction", "type", "url"}, defaultParams: map[string]any{"direction": "right"}}, + {name: "new-surface", proto: protoV2, v2Method: "surface.create", flagKeys: []string{"workspace", "pane", "type", "url"}}, + {name: "new-split", proto: protoV2, v2Method: "surface.split", flagKeys: []string{"surface", "direction"}}, + {name: "close-surface", proto: protoV2, v2Method: "surface.close", flagKeys: []string{"surface"}}, + {name: "send", proto: protoV2, v2Method: "surface.send_text", flagKeys: []string{"surface", "text"}}, + {name: "send-key", proto: protoV2, v2Method: "surface.send_key", flagKeys: []string{"surface", "key"}}, + {name: "notify", proto: protoV2, v2Method: "notification.create", flagKeys: []string{"title", "body", "workspace"}}, + {name: "refresh-surfaces", proto: protoV2, v2Method: "surface.refresh", noParams: true}, +} + +var commandIndex map[string]*commandSpec + +func init() { + commandIndex = make(map[string]*commandSpec, len(commands)) + for i := range commands { + commandIndex[commands[i].name] = &commands[i] + } +} + +// runCLI is the entry point for the "cli" subcommand (or busybox "cmux" invocation). +func runCLI(args []string) int { + socketPath := os.Getenv("CMUX_SOCKET_PATH") + + // Parse global flags + var jsonOutput bool + var remaining []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--socket": + if i+1 >= len(args) { + fmt.Fprintln(os.Stderr, "cmux: --socket requires a path") + return 2 + } + socketPath = args[i+1] + i++ + case "--json": + jsonOutput = true + case "--help", "-h": + cliUsage() + return 0 + default: + remaining = append(remaining, args[i:]...) + goto doneFlags + } + } +doneFlags: + + if len(remaining) == 0 { + cliUsage() + return 2 + } + cmdName := remaining[0] + cmdArgs := remaining[1:] + if cmdName == "help" { + cliUsage() + return 0 + } + + // refreshAddr is set when the address came from socket_addr file (not env/flag), + // allowing one stale-address refresh if another workspace has replaced socket_addr. + var refreshAddr func() string + if socketPath == "" { + socketPath = readSocketAddrFile() + refreshAddr = readSocketAddrFile + } + if socketPath == "" { + fmt.Fprintln(os.Stderr, "cmux: CMUX_SOCKET_PATH not set and --socket not provided") + return 1 + } + + // Special case: "rpc" passthrough + if cmdName == "rpc" { + return runRPC(socketPath, cmdArgs, jsonOutput, refreshAddr) + } + + // Browser subcommand delegation + if cmdName == "browser" { + return runBrowserRelay(socketPath, cmdArgs, jsonOutput, refreshAddr) + } + + spec, ok := commandIndex[cmdName] + if !ok { + fmt.Fprintf(os.Stderr, "cmux: unknown command %q\n", cmdName) + return 2 + } + + switch spec.proto { + case protoV1: + return execV1(socketPath, spec, cmdArgs, refreshAddr) + case protoV2: + return execV2(socketPath, spec, cmdArgs, jsonOutput, refreshAddr) + default: + fmt.Fprintf(os.Stderr, "cmux: internal error: unknown protocol for %q\n", cmdName) + return 1 + } +} + +// execV1 sends a v1 text command over the socket. +func execV1(socketPath string, spec *commandSpec, args []string, refreshAddr func() string) int { + cmd := spec.v1Cmd + + if !spec.noParams { + parsed, err := parseFlags(args, spec.flagKeys) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 2 + } + for _, key := range spec.flagKeys { + if val, ok := parsed.flags[key]; ok { + cmd += " " + val + } + } + } + + resp, err := socketRoundTrip(socketPath, cmd, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + fmt.Print(resp) + if !strings.HasSuffix(resp, "\n") { + fmt.Println() + } + return 0 +} + +// execV2 sends a v2 JSON-RPC request over the socket. +func execV2(socketPath string, spec *commandSpec, args []string, jsonOutput bool, refreshAddr func() string) int { + params := make(map[string]any, len(spec.defaultParams)) + for key, value := range spec.defaultParams { + params[key] = value + } + + if !spec.noParams { + parsed, err := parseFlags(args, spec.flagKeys) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 2 + } + // Map flag keys to JSON param keys (e.g. "workspace" → "workspace_id" where appropriate) + for _, key := range spec.flagKeys { + if val, ok := parsed.flags[key]; ok { + paramKey := flagToParamKey(key) + if override, ok := spec.paramKeyOverrides[key]; ok { + paramKey = override + } + params[paramKey] = val + } + } + + // First positional arg is used as initial_command if --command wasn't given + if _, ok := params["initial_command"]; !ok && len(parsed.positional) > 0 { + params["initial_command"] = parsed.positional[0] + } + + applyWorkspaceEnvFallback(params) + applySurfaceEnvFallback(params) + } + + resp, err := socketRoundTripV2(socketPath, spec.v2Method, params, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + + if jsonOutput { + fmt.Println(resp) + } else { + fmt.Println(defaultRelayOutput(resp)) + } + return 0 +} + +// runRPC sends an arbitrary JSON-RPC method with optional JSON params. +func runRPC(socketPath string, args []string, jsonOutput bool, refreshAddr func() string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "cmux rpc: requires a method name") + return 2 + } + method := args[0] + var params map[string]any + if len(args) > 1 { + if err := json.Unmarshal([]byte(args[1]), ¶ms); err != nil { + fmt.Fprintf(os.Stderr, "cmux rpc: invalid JSON params: %v\n", err) + return 2 + } + } + + resp, err := socketRoundTripV2(socketPath, method, params, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + fmt.Println(resp) + return 0 +} + +// runBrowserRelay handles "cmux browser <subcommand>" by mapping to browser.* v2 methods. +func runBrowserRelay(socketPath string, args []string, jsonOutput bool, refreshAddr func() string) int { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "cmux browser: requires a subcommand (open, navigate, back, forward, reload, get-url)") + return 2 + } + + sub := args[0] + subArgs := args[1:] + + var method string + var flagKeys []string + var allowPositionalURL bool + var useWorkspaceEnv bool + var useSurfaceEnv bool + switch sub { + case "open", "open-split", "new": + method = "browser.open_split" + flagKeys = []string{"url", "workspace", "surface"} + allowPositionalURL = true + useWorkspaceEnv = true + case "navigate": + method = "browser.navigate" + flagKeys = []string{"url", "surface"} + allowPositionalURL = true + useSurfaceEnv = true + case "back": + method = "browser.back" + flagKeys = []string{"surface"} + useSurfaceEnv = true + case "forward": + method = "browser.forward" + flagKeys = []string{"surface"} + useSurfaceEnv = true + case "reload": + method = "browser.reload" + flagKeys = []string{"surface"} + useSurfaceEnv = true + case "get-url": + method = "browser.url.get" + flagKeys = []string{"surface"} + useSurfaceEnv = true + default: + fmt.Fprintf(os.Stderr, "cmux browser: unknown subcommand %q\n", sub) + return 2 + } + + params := make(map[string]any) + parsed, err := parseFlags(subArgs, flagKeys) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux browser: %v\n", err) + return 2 + } + for _, key := range flagKeys { + if val, ok := parsed.flags[key]; ok { + paramKey := flagToParamKey(key) + params[paramKey] = val + } + } + if allowPositionalURL { + if _, ok := params["url"]; !ok && len(parsed.positional) > 0 { + params["url"] = strings.Join(parsed.positional, " ") + } + } + if useWorkspaceEnv { + applyWorkspaceEnvFallback(params) + } + if useSurfaceEnv { + applySurfaceEnvFallback(params) + } + + resp, err := socketRoundTripV2(socketPath, method, params, refreshAddr) + if err != nil { + fmt.Fprintf(os.Stderr, "cmux: %v\n", err) + return 1 + } + if jsonOutput { + fmt.Println(resp) + } else { + fmt.Println(defaultRelayOutput(resp)) + } + return 0 +} + +func applyWorkspaceEnvFallback(params map[string]any) { + if _, ok := params["workspace_id"]; ok { + return + } + if envWs := os.Getenv("CMUX_WORKSPACE_ID"); envWs != "" { + params["workspace_id"] = envWs + } +} + +func applySurfaceEnvFallback(params map[string]any) { + if _, ok := params["surface_id"]; ok { + return + } + if envSf := os.Getenv("CMUX_SURFACE_ID"); envSf != "" { + params["surface_id"] = envSf + } +} + +func defaultRelayOutput(resp string) string { + var result any + if err := json.Unmarshal([]byte(resp), &result); err != nil { + trimmed := strings.TrimSpace(resp) + if trimmed == "" { + return "OK" + } + return trimmed + } + + if relayResultIsEmpty(result) { + return "OK" + } + + switch typed := result.(type) { + case string: + return typed + default: + encoded, err := json.MarshalIndent(typed, "", " ") + if err != nil { + return "OK" + } + return string(encoded) + } +} + +func relayResultIsEmpty(result any) bool { + switch typed := result.(type) { + case nil: + return true + case map[string]any: + return len(typed) == 0 + case []any: + return len(typed) == 0 + case string: + return typed == "" + default: + return false + } +} + +// flagToParamKey maps a CLI flag name to its JSON-RPC param key. +func flagToParamKey(key string) string { + switch key { + case "workspace": + return "workspace_id" + case "surface": + return "surface_id" + case "panel": + return "panel_id" + case "pane": + return "pane_id" + case "window": + return "window_id" + case "command": + return "initial_command" + case "name": + return "title" + case "working-directory": + return "working_directory" + default: + return key + } +} + +// parsedFlags holds the results of flag parsing. +type parsedFlags struct { + flags map[string]string // --key value pairs + positional []string // non-flag arguments +} + +// parseFlags extracts --key value pairs from args for the given allowed keys. +// Non-flag arguments are collected in positional. +func parseFlags(args []string, keys []string) (parsedFlags, error) { + allowed := make(map[string]bool, len(keys)) + for _, k := range keys { + allowed[k] = true + } + + result := parsedFlags{flags: make(map[string]string)} + for i := 0; i < len(args); i++ { + if args[i] == "--" { + result.positional = append(result.positional, args[i+1:]...) + break + } + if !strings.HasPrefix(args[i], "--") { + result.positional = append(result.positional, args[i]) + continue + } + key := strings.TrimPrefix(args[i], "--") + if !allowed[key] { + return parsedFlags{}, fmt.Errorf("unknown flag --%s", key) + } + if i+1 < len(args) { + result.flags[key] = args[i+1] + i++ + } + } + return result, nil +} + +// readSocketAddrFile reads the socket address from ~/.cmux/socket_addr as a fallback +// when CMUX_SOCKET_PATH is not set. Written by the cmux app after the relay establishes. +func readSocketAddrFile() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + data, err := os.ReadFile(filepath.Join(home, ".cmux", "socket_addr")) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func readRelayAuthFile(socketPath string) *relayAuthState { + if strings.Contains(socketPath, ":") && !strings.HasPrefix(socketPath, "/") { + _, port, err := net.SplitHostPort(socketPath) + if err != nil || port == "" { + return nil + } + home, err := os.UserHomeDir() + if err != nil { + return nil + } + data, err := os.ReadFile(filepath.Join(home, ".cmux", "relay", port+".auth")) + if err != nil { + return nil + } + var state relayAuthState + if err := json.Unmarshal(data, &state); err != nil { + return nil + } + if state.RelayID == "" || state.RelayToken == "" { + return nil + } + return &state + } + return nil +} + +func currentRelayAuth(socketPath string) *relayAuthState { + relayID := strings.TrimSpace(os.Getenv("CMUX_RELAY_ID")) + relayToken := strings.TrimSpace(os.Getenv("CMUX_RELAY_TOKEN")) + if relayID != "" && relayToken != "" { + return &relayAuthState{RelayID: relayID, RelayToken: relayToken} + } + return readRelayAuthFile(socketPath) +} + +// dialSocket connects to the cmux socket. If addr contains a colon and doesn't +// start with '/', it's treated as a TCP address (host:port); otherwise Unix socket. +// For TCP connections, refreshAddr is used only to recover from a stale socket_addr +// rewrite, not to poll for relay readiness. +func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) { + if strings.Contains(addr, ":") && !strings.HasPrefix(addr, "/") { + conn, connectedAddr, err := dialTCP(addr) + if err != nil && refreshAddr != nil && isConnectionRefused(err) { + if refreshedAddr := strings.TrimSpace(refreshAddr()); refreshedAddr != "" && refreshedAddr != addr { + addr = refreshedAddr + conn, connectedAddr, err = dialTCP(addr) + } + } + if err != nil { + return nil, err + } + if auth := currentRelayAuth(connectedAddr); auth != nil { + if err := authenticateRelayConn(conn, auth); err != nil { + conn.Close() + return nil, err + } + } + return conn, nil + } + return net.Dial("unix", addr) +} + +func dialTCP(addr string) (net.Conn, string, error) { + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + return nil, addr, err + } + setTCPNoDelay(conn) + return conn, addr, nil +} + +func isConnectionRefused(err error) bool { + if opErr, ok := err.(*net.OpError); ok { + return strings.Contains(opErr.Err.Error(), "connection refused") + } + return strings.Contains(err.Error(), "connection refused") +} + +func authenticateRelayConn(conn net.Conn, auth *relayAuthState) error { + reader := bufio.NewReader(conn) + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + + var challenge struct { + Protocol string `json:"protocol"` + Version int `json:"version"` + RelayID string `json:"relay_id"` + Nonce string `json:"nonce"` + } + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read relay auth challenge: %w", err) + } + if err := json.Unmarshal([]byte(line), &challenge); err != nil { + return fmt.Errorf("invalid relay auth challenge") + } + if challenge.Protocol != "cmux-relay-auth" || challenge.Version != 1 || challenge.RelayID != auth.RelayID || challenge.Nonce == "" { + return fmt.Errorf("relay auth challenge mismatch") + } + + tokenBytes, err := hex.DecodeString(auth.RelayToken) + if err != nil { + return fmt.Errorf("invalid relay auth token") + } + mac := computeRelayMAC(tokenBytes, auth.RelayID, challenge.Nonce, challenge.Version) + payload, err := json.Marshal(map[string]any{ + "relay_id": auth.RelayID, + "mac": hex.EncodeToString(mac), + }) + if err != nil { + return fmt.Errorf("failed to encode relay auth response: %w", err) + } + if _, err := conn.Write(append(payload, '\n')); err != nil { + return fmt.Errorf("failed to send relay auth response: %w", err) + } + + line, err = reader.ReadString('\n') + if err != nil { + return fmt.Errorf("failed to read relay auth result: %w", err) + } + var result struct { + OK bool `json:"ok"` + } + if err := json.Unmarshal([]byte(line), &result); err != nil { + return fmt.Errorf("invalid relay auth result") + } + if !result.OK { + return fmt.Errorf("relay auth rejected") + } + _ = conn.SetDeadline(time.Time{}) + return nil +} + +func computeRelayMAC(token []byte, relayID, nonce string, version int) []byte { + mac := hmac.New(sha256.New, token) + _, _ = io.WriteString(mac, fmt.Sprintf("relay_id=%s\nnonce=%s\nversion=%d", relayID, nonce, version)) + return mac.Sum(nil) +} + +// socketRoundTrip sends a raw text line and reads a raw text response (v1). +func socketRoundTrip(socketPath, command string, refreshAddr func() string) (string, error) { + conn, err := dialSocket(socketPath, refreshAddr) + if err != nil { + return "", fmt.Errorf("failed to connect to %s: %w", socketPath, err) + } + defer conn.Close() + + if _, err := fmt.Fprintf(conn, "%s\n", command); err != nil { + return "", fmt.Errorf("failed to send command: %w", err) + } + + // V1 handlers may return multiple lines (e.g. list_windows). Read until + // the stream goes idle briefly after seeing at least one newline. + reader := bufio.NewReader(conn) + var response strings.Builder + sawNewline := false + + for { + readTimeout := 15 * time.Second + if sawNewline { + readTimeout = 120 * time.Millisecond + } + _ = conn.SetReadDeadline(time.Now().Add(readTimeout)) + + chunk, err := reader.ReadString('\n') + if chunk != "" { + response.WriteString(chunk) + if strings.Contains(chunk, "\n") { + sawNewline = true + } + } + + if err != nil { + if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + if sawNewline { + break + } + return "", fmt.Errorf("failed to read response: timeout waiting for response") + } + if errors.Is(err, io.EOF) { + break + } + return "", fmt.Errorf("failed to read response: %w", err) + } + } + + return strings.TrimRight(response.String(), "\n"), nil +} + +// socketRoundTripV2 sends a JSON-RPC request and returns the result JSON. +func socketRoundTripV2(socketPath, method string, params map[string]any, refreshAddr func() string) (string, error) { + conn, err := dialSocket(socketPath, refreshAddr) + if err != nil { + return "", fmt.Errorf("failed to connect to %s: %w", socketPath, err) + } + defer conn.Close() + + id := randomHex(8) + req := map[string]any{ + "id": id, + "method": method, + } + if params != nil { + req["params"] = params + } else { + req["params"] = map[string]any{} + } + + payload, err := json.Marshal(req) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + if _, err := conn.Write(append(payload, '\n')); err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + // Parse the response to check for errors + var resp map[string]any + if err := json.Unmarshal([]byte(line), &resp); err != nil { + return strings.TrimRight(line, "\n"), nil + } + + if ok, _ := resp["ok"].(bool); !ok { + if errObj, _ := resp["error"].(map[string]any); errObj != nil { + code, _ := errObj["code"].(string) + msg, _ := errObj["message"].(string) + return "", fmt.Errorf("server error [%s]: %s", code, msg) + } + return "", fmt.Errorf("server returned error response") + } + + // Return the result portion as JSON + if result, ok := resp["result"]; ok { + resultJSON, err := json.Marshal(result) + if err != nil { + return "", fmt.Errorf("failed to marshal result: %w", err) + } + return string(resultJSON), nil + } + + return "{}", nil +} + +func randomHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +func cliUsage() { + fmt.Fprintln(os.Stderr, "Usage: cmux [--socket <path>] [--json] <command> [args...]") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Commands:") + fmt.Fprintln(os.Stderr, " ping Check connectivity") + fmt.Fprintln(os.Stderr, " capabilities List server capabilities") + fmt.Fprintln(os.Stderr, " list-workspaces List all workspaces") + fmt.Fprintln(os.Stderr, " new-window Create a new window") + fmt.Fprintln(os.Stderr, " new-workspace Create a new workspace") + fmt.Fprintln(os.Stderr, " new-surface Create a new surface") + fmt.Fprintln(os.Stderr, " new-split Split an existing surface") + fmt.Fprintln(os.Stderr, " close-surface Close a surface") + fmt.Fprintln(os.Stderr, " close-workspace Close a workspace") + fmt.Fprintln(os.Stderr, " select-workspace Select a workspace") + fmt.Fprintln(os.Stderr, " send Send text to a surface") + fmt.Fprintln(os.Stderr, " send-key Send a key to a surface") + fmt.Fprintln(os.Stderr, " notify Create a notification") + fmt.Fprintln(os.Stderr, " browser <sub> Browser commands (open, navigate, back, forward, reload, get-url)") + fmt.Fprintln(os.Stderr, " rpc <method> [json-params] Send arbitrary JSON-RPC") +} diff --git a/daemon/remote/cmd/cmuxd-remote/cli_test.go b/daemon/remote/cmd/cmuxd-remote/cli_test.go new file mode 100644 index 00000000..a8b9f623 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/cli_test.go @@ -0,0 +1,923 @@ +package main + +import ( + "bufio" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + original := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("pipe stdout: %v", err) + } + os.Stdout = writer + defer func() { + os.Stdout = original + }() + + fn() + + if err := writer.Close(); err != nil { + t.Fatalf("close stdout writer: %v", err) + } + output, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("read stdout: %v", err) + } + if err := reader.Close(); err != nil { + t.Fatalf("close stdout reader: %v", err) + } + return string(output) +} + +func makeShortUnixSocketPath(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("/tmp", "cmuxd-") + if err != nil { + t.Fatalf("mkdtemp: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return filepath.Join(dir, "cmux.sock") +} + +// startMockSocket creates a Unix socket that accepts one connection, +// reads a line, and responds with the given canned response. +func startMockSocket(t *testing.T, response string) string { + t.Helper() + sockPath := makeShortUnixSocketPath(t) + + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + _ = n // consume request + conn.Write([]byte(response + "\n")) + conn.Close() + } + }() + + return sockPath +} + +// startMockV2Socket creates a Unix socket that echoes the received request's method +// back as a successful JSON-RPC response with the method name in the result. +func startMockV2Socket(t *testing.T) string { + t.Helper() + sockPath := makeShortUnixSocketPath(t) + + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + if n > 0 { + var req map[string]any + if err := json.Unmarshal(buf[:n], &req); err == nil { + resp := map[string]any{ + "id": req["id"], + "ok": true, + "result": map[string]any{"method": req["method"], "params": req["params"]}, + } + payload, _ := json.Marshal(resp) + conn.Write(append(payload, '\n')) + } else { + conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n")) + } + } + conn.Close() + } + }() + + return sockPath +} + +func startMockV2SocketWithRequestCapture(t *testing.T) (string, <-chan map[string]any) { + t.Helper() + sockPath := makeShortUnixSocketPath(t) + requests := make(chan map[string]any, 8) + + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + if n == 0 { + return + } + var req map[string]any + if err := json.Unmarshal(buf[:n], &req); err != nil { + _, _ = conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n")) + return + } + requests <- req + resp := map[string]any{ + "id": req["id"], + "ok": true, + "result": map[string]any{"method": req["method"], "params": req["params"]}, + } + payload, _ := json.Marshal(resp) + _, _ = conn.Write(append(payload, '\n')) + }(conn) + } + }() + + return sockPath, requests +} + +func startMockV2TCPSocketWithResult(t *testing.T, result any) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen on TCP: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + if n == 0 { + return + } + var req map[string]any + if err := json.Unmarshal(buf[:n], &req); err != nil { + _, _ = conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n")) + return + } + resp := map[string]any{ + "id": req["id"], + "ok": true, + "result": result, + } + payload, _ := json.Marshal(resp) + _, _ = conn.Write(append(payload, '\n')) + }(conn) + } + }() + + return ln.Addr().String() +} + +// startMockTCPSocket creates a TCP listener that responds with a canned response. +func startMockTCPSocket(t *testing.T, response string) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen on TCP: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + _ = n + conn.Write([]byte(response + "\n")) + conn.Close() + } + }() + + return ln.Addr().String() +} + +func startMockAuthenticatedTCPSocket(t *testing.T, relayID, relayToken, response string) string { + t.Helper() + relayTokenBytes := mustHex(t, relayToken) + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen on TCP: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + nonce := "testnonce" + challenge, _ := json.Marshal(map[string]any{ + "protocol": "cmux-relay-auth", + "version": 1, + "relay_id": relayID, + "nonce": nonce, + }) + _, _ = conn.Write(append(challenge, '\n')) + + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') + if err != nil { + return + } + var authResp map[string]any + if err := json.Unmarshal([]byte(line), &authResp); err != nil { + _, _ = conn.Write([]byte(`{"ok":false}` + "\n")) + return + } + macHex, _ := authResp["mac"].(string) + receivedMAC, err := hex.DecodeString(macHex) + if err != nil { + _, _ = conn.Write([]byte(`{"ok":false}` + "\n")) + return + } + + h := hmac.New(sha256.New, relayTokenBytes) + _, _ = io.WriteString(h, fmt.Sprintf("relay_id=%s\nnonce=%s\nversion=%d", relayID, nonce, 1)) + expectedMAC := h.Sum(nil) + if !hmac.Equal(receivedMAC, expectedMAC) { + _, _ = conn.Write([]byte(`{"ok":false}` + "\n")) + return + } + + _, _ = conn.Write([]byte(`{"ok":true}` + "\n")) + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + _, _ = conn.Write([]byte(response)) + if n > 0 && !strings.HasSuffix(response, "\n") { + _, _ = conn.Write([]byte("\n")) + } + }(conn) + } + }() + + return ln.Addr().String() +} + +func mustHex(t *testing.T, value string) []byte { + t.Helper() + data, err := hex.DecodeString(value) + if err != nil { + t.Fatalf("decode hex: %v", err) + } + return data +} + +func TestDialSocketRefreshesToUpdatedTCPAddressWithoutPolling(t *testing.T) { + staleListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen stale: %v", err) + } + staleAddr := staleListener.Addr().String() + staleListener.Close() + + readyListener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen ready: %v", err) + } + defer readyListener.Close() + + accepted := make(chan struct{}) + go func() { + defer close(accepted) + conn, acceptErr := readyListener.Accept() + if acceptErr != nil { + return + } + conn.Close() + }() + + refreshCalls := 0 + start := time.Now() + conn, err := dialSocket(staleAddr, func() string { + refreshCalls++ + return readyListener.Addr().String() + }) + elapsed := time.Since(start) + if err != nil { + t.Fatalf("dialSocket should refresh to updated address, got: %v", err) + } + conn.Close() + <-accepted + if refreshCalls != 1 { + t.Fatalf("refreshAddr should be called once, got %d", refreshCalls) + } + if elapsed > 500*time.Millisecond { + t.Fatalf("dialSocket should fail over without polling, took %v", elapsed) + } +} + +func TestDialSocketFailsFastWhenTCPAddressStaysStale(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := ln.Addr().String() + ln.Close() + + refreshCalls := 0 + start := time.Now() + _, err = dialSocket(addr, func() string { + refreshCalls++ + return addr + }) + elapsed := time.Since(start) + if err == nil { + t.Fatal("dialSocket should fail when the relay address stays stale") + } + if refreshCalls != 1 { + t.Fatalf("refreshAddr should be called once on stale TCP failure, got %d", refreshCalls) + } + if elapsed > 500*time.Millisecond { + t.Fatalf("dialSocket should fail fast without polling, took %v", elapsed) + } +} + +func TestCLIPingV1(t *testing.T) { + sockPath := startMockSocket(t, "pong") + code := runCLI([]string{"--socket", sockPath, "ping"}) + if code != 0 { + t.Fatalf("ping should return 0, got %d", code) + } +} + +func TestCLIPingV1OverTCP(t *testing.T) { + addr := startMockTCPSocket(t, "pong") + code := runCLI([]string{"--socket", addr, "ping"}) + if code != 0 { + t.Fatalf("ping over TCP should return 0, got %d", code) + } +} + +func TestCLIPingV1OverAuthenticatedTCPWithEnv(t *testing.T) { + relayID := "relay-1" + relayToken := strings.Repeat("a1", 32) + addr := startMockAuthenticatedTCPSocket(t, relayID, relayToken, "pong") + t.Setenv("CMUX_RELAY_ID", relayID) + t.Setenv("CMUX_RELAY_TOKEN", relayToken) + + code := runCLI([]string{"--socket", addr, "ping"}) + if code != 0 { + t.Fatalf("ping over authenticated TCP should return 0, got %d", code) + } +} + +func TestCLIPingV1OverAuthenticatedTCPWithRelayFile(t *testing.T) { + relayID := "relay-2" + relayToken := strings.Repeat("b2", 32) + addr := startMockAuthenticatedTCPSocket(t, relayID, relayToken, "pong") + _, port, err := net.SplitHostPort(addr) + if err != nil { + t.Fatalf("split host port: %v", err) + } + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("CMUX_RELAY_ID", "") + t.Setenv("CMUX_RELAY_TOKEN", "") + relayDir := filepath.Join(home, ".cmux", "relay") + if err := os.MkdirAll(relayDir, 0o700); err != nil { + t.Fatalf("mkdir relay dir: %v", err) + } + authPayload, _ := json.Marshal(relayAuthState{RelayID: relayID, RelayToken: relayToken}) + if err := os.WriteFile(filepath.Join(relayDir, port+".auth"), authPayload, 0o600); err != nil { + t.Fatalf("write auth file: %v", err) + } + + code := runCLI([]string{"--socket", addr, "ping"}) + if code != 0 { + t.Fatalf("ping over authenticated TCP file relay should return 0, got %d", code) + } +} + +func TestDialSocketDetection(t *testing.T) { + // Unix socket paths should attempt Unix dial + for _, path := range []string{"/tmp/cmux-nonexistent-test-99999.sock", "/var/run/cmux-nonexistent.sock"} { + conn, err := dialSocket(path, nil) + if conn != nil { + conn.Close() + } + // We expect a connection error (not found), not a panic + if err == nil { + t.Fatalf("dialSocket(%q) should fail for non-existent path", path) + } + } + + // TCP addresses should attempt TCP dial + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + go func() { + conn, _ := ln.Accept() + if conn != nil { + conn.Close() + } + }() + + conn, err := dialSocket(ln.Addr().String(), nil) + if err != nil { + t.Fatalf("dialSocket(%q) should succeed for TCP: %v", ln.Addr().String(), err) + } + conn.Close() +} + +func TestCLINewWindowV1(t *testing.T) { + sockPath := startMockSocket(t, "OK window_id=abc123") + code := runCLI([]string{"--socket", sockPath, "new-window"}) + if code != 0 { + t.Fatalf("new-window should return 0, got %d", code) + } +} + +func TestSocketRoundTripReadsFullMultilineV1Response(t *testing.T) { + addr := startMockTCPSocket(t, "window:alpha\nwindow:beta\nwindow:gamma") + resp, err := socketRoundTrip(addr, "list_windows", nil) + if err != nil { + t.Fatalf("socketRoundTrip should succeed, got error: %v", err) + } + want := "window:alpha\nwindow:beta\nwindow:gamma" + if resp != want { + t.Fatalf("socketRoundTrip truncated v1 response: got %q want %q", resp, want) + } +} + +func TestCLICloseWindowV1(t *testing.T) { + // Verify that the flag value is appended to the v1 command + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + receivedCh := make(chan string, 1) + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + receivedCh <- strings.TrimSpace(string(buf[:n])) + conn.Write([]byte("OK\n")) + conn.Close() + }() + + code := runCLI([]string{"--socket", sockPath, "close-window", "--window", "win-42"}) + if code != 0 { + t.Fatalf("close-window should return 0, got %d", code) + } + select { + case received := <-receivedCh: + if received != "close_window win-42" { + t.Fatalf("expected 'close_window win-42', got %q", received) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for close-window payload") + } +} + +func TestCLIListWorkspacesV2(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "--json", "list-workspaces"}) + if code != 0 { + t.Fatalf("list-workspaces should return 0, got %d", code) + } +} + +func TestCLIListWorkspacesV2DefaultOutputShowsResult(t *testing.T) { + sockPath := startMockV2TCPSocketWithResult(t, map[string]any{"method": "workspace.list", "params": map[string]any{}}) + output := captureStdout(t, func() { + code := runCLI([]string{"--socket", sockPath, "list-workspaces"}) + if code != 0 { + t.Fatalf("list-workspaces should return 0, got %d", code) + } + }) + if !strings.Contains(output, "\"method\": \"workspace.list\"") { + t.Fatalf("expected default output to include result payload, got %q", output) + } +} + +func TestCLINotifyDefaultOutputPrintsOKForEmptyResult(t *testing.T) { + sockPath := startMockV2TCPSocketWithResult(t, map[string]any{}) + output := captureStdout(t, func() { + code := runCLI([]string{"--socket", sockPath, "notify", "--body", "hi"}) + if code != 0 { + t.Fatalf("notify should return 0, got %d", code) + } + }) + if strings.TrimSpace(output) != "OK" { + t.Fatalf("expected empty-result command to print OK, got %q", output) + } +} + +func TestCLIRPCPassthrough(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "rpc", "system.capabilities"}) + if code != 0 { + t.Fatalf("rpc should return 0, got %d", code) + } +} + +func TestCLIRPCWithParams(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "rpc", "workspace.create", `{"title":"test"}`}) + if code != 0 { + t.Fatalf("rpc with params should return 0, got %d", code) + } +} + +func TestCLIUnknownCommand(t *testing.T) { + code := runCLI([]string{"--socket", "/dev/null", "does-not-exist"}) + if code != 2 { + t.Fatalf("unknown command should return 2, got %d", code) + } +} + +func TestCLINoSocket(t *testing.T) { + // Without CMUX_SOCKET_PATH set, should fail + os.Unsetenv("CMUX_SOCKET_PATH") + code := runCLI([]string{"ping"}) + if code != 1 { + t.Fatalf("missing socket should return 1, got %d", code) + } +} + +func TestCLISocketEnvVar(t *testing.T) { + sockPath := startMockSocket(t, "pong") + os.Setenv("CMUX_SOCKET_PATH", sockPath) + defer os.Unsetenv("CMUX_SOCKET_PATH") + + code := runCLI([]string{"ping"}) + if code != 0 { + t.Fatalf("ping with env socket should return 0, got %d", code) + } +} + +func TestCLIV2FlagMapping(t *testing.T) { + // Verify that --workspace gets mapped to workspace_id in params + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + receivedParamsCh := make(chan map[string]any, 1) + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + var req map[string]any + json.Unmarshal(buf[:n], &req) + receivedParams, _ := req["params"].(map[string]any) + receivedParamsCh <- receivedParams + resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}} + payload, _ := json.Marshal(resp) + conn.Write(append(payload, '\n')) + conn.Close() + }() + + code := runCLI([]string{"--socket", sockPath, "--json", "close-workspace", "--workspace", "ws-abc"}) + if code != 0 { + t.Fatalf("close-workspace should return 0, got %d", code) + } + select { + case receivedParams := <-receivedParamsCh: + if receivedParams["workspace_id"] != "ws-abc" { + t.Fatalf("expected workspace_id=ws-abc, got %v", receivedParams) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for close-workspace payload") + } +} + +func TestBusyboxArgv0Detection(t *testing.T) { + // Verify that when argv[0] base is "cmux", we enter CLI mode + base := filepath.Base("cmux") + if base != "cmux" { + t.Fatalf("expected base 'cmux', got %q", base) + } + base2 := filepath.Base("/home/user/.cmux/bin/cmux") + if base2 != "cmux" { + t.Fatalf("expected base 'cmux', got %q", base2) + } + base3 := filepath.Base("cmuxd-remote") + if base3 == "cmux" { + t.Fatalf("cmuxd-remote should not match cmux") + } +} + +func TestCLIBrowserSubcommand(t *testing.T) { + sockPath := startMockV2Socket(t) + code := runCLI([]string{"--socket", sockPath, "--json", "browser", "open", "--url", "https://example.com"}) + if code != 0 { + t.Fatalf("browser open should return 0, got %d", code) + } +} + +func TestCLINewPaneDefaultsDirectionAndForwardsExtraFlags(t *testing.T) { + sockPath, requests := startMockV2SocketWithRequestCapture(t) + code := runCLI([]string{ + "--socket", sockPath, "--json", + "new-pane", + "--workspace", "ws-1", + "--type", "browser", + "--url", "https://example.com", + }) + if code != 0 { + t.Fatalf("new-pane should return 0, got %d", code) + } + + select { + case req := <-requests: + if got := req["method"]; got != "pane.create" { + t.Fatalf("expected pane.create, got %v", got) + } + params, _ := req["params"].(map[string]any) + if got := params["workspace_id"]; got != "ws-1" { + t.Fatalf("expected workspace_id ws-1, got %v", got) + } + if got := params["direction"]; got != "right" { + t.Fatalf("expected default direction right, got %v", got) + } + if got := params["type"]; got != "browser" { + t.Fatalf("expected type browser, got %v", got) + } + if got := params["url"]; got != "https://example.com" { + t.Fatalf("expected url to be forwarded, got %v", got) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for new-pane request") + } +} + +func TestCLIListPanelsUsesSurfaceList(t *testing.T) { + sockPath, requests := startMockV2SocketWithRequestCapture(t) + code := runCLI([]string{"--socket", sockPath, "--json", "list-panels", "--workspace", "ws-1"}) + if code != 0 { + t.Fatalf("list-panels should return 0, got %d", code) + } + + select { + case req := <-requests: + if got := req["method"]; got != "surface.list" { + t.Fatalf("expected surface.list, got %v", got) + } + params, _ := req["params"].(map[string]any) + if got := params["workspace_id"]; got != "ws-1" { + t.Fatalf("expected workspace_id ws-1, got %v", got) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for list-panels request") + } +} + +func TestCLIFocusPanelUsesSurfaceFocus(t *testing.T) { + sockPath, requests := startMockV2SocketWithRequestCapture(t) + code := runCLI([]string{"--socket", sockPath, "--json", "focus-panel", "--workspace", "ws-1", "--panel", "surface-1"}) + if code != 0 { + t.Fatalf("focus-panel should return 0, got %d", code) + } + + select { + case req := <-requests: + if got := req["method"]; got != "surface.focus" { + t.Fatalf("expected surface.focus, got %v", got) + } + params, _ := req["params"].(map[string]any) + if got := params["workspace_id"]; got != "ws-1" { + t.Fatalf("expected workspace_id ws-1, got %v", got) + } + if got := params["surface_id"]; got != "surface-1" { + t.Fatalf("expected surface_id surface-1, got %v", got) + } + if _, ok := params["panel_id"]; ok { + t.Fatalf("did not expect panel_id in params: %v", params) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for focus-panel request") + } +} + +func TestCLIBrowserOpenUsesOpenSplitAndWorkspaceEnv(t *testing.T) { + sockPath, requests := startMockV2SocketWithRequestCapture(t) + t.Setenv("CMUX_WORKSPACE_ID", "env-ws") + code := runCLI([]string{"--socket", sockPath, "--json", "browser", "open", "https://example.com"}) + if code != 0 { + t.Fatalf("browser open should return 0, got %d", code) + } + + select { + case req := <-requests: + if got := req["method"]; got != "browser.open_split" { + t.Fatalf("expected browser.open_split, got %v", got) + } + params, _ := req["params"].(map[string]any) + if got := params["workspace_id"]; got != "env-ws" { + t.Fatalf("expected workspace_id env-ws, got %v", got) + } + if got := params["url"]; got != "https://example.com" { + t.Fatalf("expected positional url to be forwarded, got %v", got) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for browser open request") + } +} + +func TestCLIBrowserGetURLUsesCurrentMethodAndSurfaceEnv(t *testing.T) { + sockPath, requests := startMockV2SocketWithRequestCapture(t) + t.Setenv("CMUX_SURFACE_ID", "env-sf") + code := runCLI([]string{"--socket", sockPath, "--json", "browser", "get-url"}) + if code != 0 { + t.Fatalf("browser get-url should return 0, got %d", code) + } + + select { + case req := <-requests: + if got := req["method"]; got != "browser.url.get" { + t.Fatalf("expected browser.url.get, got %v", got) + } + params, _ := req["params"].(map[string]any) + if got := params["surface_id"]; got != "env-sf" { + t.Fatalf("expected surface_id env-sf, got %v", got) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for browser get-url request") + } +} + +func TestCLINoArgs(t *testing.T) { + code := runCLI([]string{}) + if code != 2 { + t.Fatalf("no args should return 2, got %d", code) + } +} + +func TestCLIHelpFlag(t *testing.T) { + code := runCLI([]string{"--help"}) + if code != 0 { + t.Fatalf("--help should return 0, got %d", code) + } +} + +func TestCLIHelpCommand(t *testing.T) { + code := runCLI([]string{"help"}) + if code != 0 { + t.Fatalf("help should return 0, got %d", code) + } +} + +func TestFlagToParamKey(t *testing.T) { + tests := []struct { + input, expected string + }{ + {"workspace", "workspace_id"}, + {"surface", "surface_id"}, + {"panel", "panel_id"}, + {"pane", "pane_id"}, + {"window", "window_id"}, + {"command", "initial_command"}, + {"name", "title"}, + {"working-directory", "working_directory"}, + {"title", "title"}, + {"url", "url"}, + {"direction", "direction"}, + } + for _, tc := range tests { + got := flagToParamKey(tc.input) + if got != tc.expected { + t.Errorf("flagToParamKey(%q) = %q, want %q", tc.input, got, tc.expected) + } + } +} + +func TestParseFlags(t *testing.T) { + args := []string{"positional-cmd", "--workspace", "ws-1", "--surface", "sf-2", "--unknown", "val"} + _, err := parseFlags(args, []string{"workspace", "surface"}) + if err == nil { + t.Fatal("parseFlags should reject unknown flags") + } +} + +func TestParseFlagsCollectsKnownFlagsAndPositionalArgs(t *testing.T) { + args := []string{"positional-cmd", "--workspace", "ws-1", "--surface", "sf-2"} + result, err := parseFlags(args, []string{"workspace", "surface"}) + if err != nil { + t.Fatalf("parseFlags should succeed for known flags: %v", err) + } + if result.flags["workspace"] != "ws-1" { + t.Errorf("expected workspace=ws-1, got %q", result.flags["workspace"]) + } + if result.flags["surface"] != "sf-2" { + t.Errorf("expected surface=sf-2, got %q", result.flags["surface"]) + } + if len(result.positional) == 0 || result.positional[0] != "positional-cmd" { + t.Errorf("expected first positional=positional-cmd, got %v", result.positional) + } +} + +func TestCLIEnvVarDefaults(t *testing.T) { + // Test that CMUX_WORKSPACE_ID and CMUX_SURFACE_ID are used as defaults + dir := t.TempDir() + sockPath := filepath.Join(dir, "cmux.sock") + + receivedParamsCh := make(chan map[string]any, 1) + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + var req map[string]any + json.Unmarshal(buf[:n], &req) + receivedParams, _ := req["params"].(map[string]any) + receivedParamsCh <- receivedParams + resp := map[string]any{"id": req["id"], "ok": true, "result": map[string]any{}} + payload, _ := json.Marshal(resp) + conn.Write(append(payload, '\n')) + conn.Close() + }() + + os.Setenv("CMUX_WORKSPACE_ID", "env-ws-id") + os.Setenv("CMUX_SURFACE_ID", "env-sf-id") + defer os.Unsetenv("CMUX_WORKSPACE_ID") + defer os.Unsetenv("CMUX_SURFACE_ID") + + code := runCLI([]string{"--socket", sockPath, "--json", "close-surface"}) + if code != 0 { + t.Fatalf("close-surface should return 0, got %d", code) + } + select { + case receivedParams := <-receivedParamsCh: + if receivedParams["workspace_id"] != "env-ws-id" { + t.Errorf("expected workspace_id from env, got %v", receivedParams["workspace_id"]) + } + if receivedParams["surface_id"] != "env-sf-id" { + t.Errorf("expected surface_id from env, got %v", receivedParams["surface_id"]) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for close-surface payload") + } +} diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go new file mode 100644 index 00000000..78c647a3 --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -0,0 +1,1105 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "math" + "net" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +var version = "dev" + +type rpcRequest struct { + ID any `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` +} + +type rpcError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type rpcResponse struct { + ID any `json:"id,omitempty"` + OK bool `json:"ok"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcEvent struct { + Event string `json:"event"` + StreamID string `json:"stream_id,omitempty"` + DataBase64 string `json:"data_base64,omitempty"` + Error string `json:"error,omitempty"` +} + +type streamState struct { + conn net.Conn + readerStarted bool +} + +type stdioFrameWriter struct { + mu sync.Mutex + writer *bufio.Writer +} + +type rpcServer struct { + mu sync.Mutex + nextStreamID uint64 + nextSessionID uint64 + streams map[string]*streamState + sessions map[string]*sessionState + frameWriter *stdioFrameWriter +} + +type sessionAttachment struct { + Cols int + Rows int + UpdatedAt time.Time +} + +type sessionState struct { + attachments map[string]sessionAttachment + effectiveCols int + effectiveRows int + lastKnownCols int + lastKnownRows int +} + +const maxRPCFrameBytes = 4 * 1024 * 1024 + +func main() { + if shouldRunCLIForInvocation(os.Args[0], os.Args[1:]) { + os.Exit(runCLI(os.Args[1:])) + } + os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} + +func shouldRunCLIForInvocation(argv0 string, args []string) bool { + base := filepath.Base(argv0) + if base == "cmux" { + return true + } + if !strings.HasPrefix(base, "cmuxd-remote") || len(args) == 0 { + return false + } + return !isDaemonEntryCommand(args[0]) +} + +func isDaemonEntryCommand(arg string) bool { + switch arg { + case "version", "serve", "cli": + return true + default: + return false + } +} + +func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + if len(args) == 0 { + usage(stderr) + return 2 + } + + switch args[0] { + case "version": + _, _ = fmt.Fprintln(stdout, version) + return 0 + case "serve": + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + fs.SetOutput(stderr) + stdio := fs.Bool("stdio", false, "serve over stdin/stdout") + if err := fs.Parse(args[1:]); err != nil { + return 2 + } + if !*stdio { + _, _ = fmt.Fprintln(stderr, "serve requires --stdio") + return 2 + } + if err := runStdioServer(stdin, stdout); err != nil { + _, _ = fmt.Fprintf(stderr, "serve failed: %v\n", err) + return 1 + } + return 0 + case "cli": + return runCLI(args[1:]) + default: + usage(stderr) + return 2 + } +} + +func usage(w io.Writer) { + _, _ = fmt.Fprintln(w, "Usage:") + _, _ = fmt.Fprintln(w, " cmuxd-remote version") + _, _ = fmt.Fprintln(w, " cmuxd-remote serve --stdio") + _, _ = fmt.Fprintln(w, " cmuxd-remote cli <command> [args...]") +} + +func runStdioServer(stdin io.Reader, stdout io.Writer) error { + writer := &stdioFrameWriter{ + writer: bufio.NewWriter(stdout), + } + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]*streamState{}, + sessions: map[string]*sessionState{}, + frameWriter: writer, + } + defer server.closeAll() + + reader := bufio.NewReaderSize(stdin, 64*1024) + defer writer.writer.Flush() + + for { + line, oversized, readErr := readRPCFrame(reader, maxRPCFrameBytes) + if readErr != nil { + if errors.Is(readErr, io.EOF) { + return nil + } + return readErr + } + if oversized { + if err := writer.writeResponse(rpcResponse{ + OK: false, + Error: &rpcError{ + Code: "invalid_request", + Message: "request frame exceeds maximum size", + }, + }); err != nil { + return err + } + continue + } + line = bytes.TrimSuffix(line, []byte{'\n'}) + line = bytes.TrimSuffix(line, []byte{'\r'}) + if len(line) == 0 { + continue + } + + var req rpcRequest + if err := json.Unmarshal(line, &req); err != nil { + if err := writer.writeResponse(rpcResponse{ + OK: false, + Error: &rpcError{ + Code: "invalid_request", + Message: "invalid JSON request", + }, + }); err != nil { + return err + } + continue + } + + resp := server.handleRequest(req) + if err := writer.writeResponse(resp); err != nil { + return err + } + } +} + +func setTCPNoDelay(conn net.Conn) { + tcpConn, ok := conn.(*net.TCPConn) + if !ok { + return + } + _ = tcpConn.SetNoDelay(true) +} + +func readRPCFrame(reader *bufio.Reader, maxBytes int) ([]byte, bool, error) { + frame := make([]byte, 0, 1024) + for { + chunk, err := reader.ReadSlice('\n') + if len(chunk) > 0 { + if len(frame)+len(chunk) > maxBytes { + if errors.Is(err, bufio.ErrBufferFull) { + if drainErr := discardUntilNewline(reader); drainErr != nil && !errors.Is(drainErr, io.EOF) { + return nil, false, drainErr + } + } + return nil, true, nil + } + frame = append(frame, chunk...) + } + + if err == nil { + return frame, false, nil + } + if errors.Is(err, bufio.ErrBufferFull) { + continue + } + if errors.Is(err, io.EOF) { + if len(frame) == 0 { + return nil, false, io.EOF + } + return frame, false, nil + } + return nil, false, err + } +} + +func discardUntilNewline(reader *bufio.Reader) error { + for { + _, err := reader.ReadSlice('\n') + if err == nil || errors.Is(err, io.EOF) { + return err + } + if errors.Is(err, bufio.ErrBufferFull) { + continue + } + return err + } +} + +func (w *stdioFrameWriter) writeResponse(resp rpcResponse) error { + return w.writeJSONFrame(resp) +} + +func (w *stdioFrameWriter) writeEvent(event rpcEvent) error { + return w.writeJSONFrame(event) +} + +func (w *stdioFrameWriter) writeJSONFrame(payload any) error { + data, err := json.Marshal(payload) + if err != nil { + return err + } + w.mu.Lock() + defer w.mu.Unlock() + if _, err := w.writer.Write(data); err != nil { + return err + } + if err := w.writer.WriteByte('\n'); err != nil { + return err + } + return w.writer.Flush() +} + +func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse { + if req.Method == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_request", + Message: "method is required", + }, + } + } + + switch req.Method { + case "hello": + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "name": "cmuxd-remote", + "version": version, + "capabilities": []string{ + "session.basic", + "session.resize.min", + "proxy.http_connect", + "proxy.socks5", + "proxy.stream", + "proxy.stream.push", + }, + }, + } + case "ping": + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "pong": true, + }, + } + case "proxy.open": + return s.handleProxyOpen(req) + case "proxy.close": + return s.handleProxyClose(req) + case "proxy.write": + return s.handleProxyWrite(req) + case "proxy.stream.subscribe": + return s.handleProxyStreamSubscribe(req) + case "session.open": + return s.handleSessionOpen(req) + case "session.close": + return s.handleSessionClose(req) + case "session.attach": + return s.handleSessionAttach(req) + case "session.resize": + return s.handleSessionResize(req) + case "session.detach": + return s.handleSessionDetach(req) + case "session.status": + return s.handleSessionStatus(req) + default: + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "method_not_found", + Message: fmt.Sprintf("unknown method %q", req.Method), + }, + } + } +} + +func (s *rpcServer) handleProxyOpen(req rpcRequest) rpcResponse { + host, ok := getStringParam(req.Params, "host") + if !ok || host == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.open requires host", + }, + } + } + port, ok := getIntParam(req.Params, "port") + if !ok || port <= 0 || port > 65535 { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.open requires port in range 1-65535", + }, + } + } + + timeoutMs := 10000 + if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout && parsed >= 0 { + timeoutMs = parsed + } + + conn, err := net.DialTimeout( + "tcp", + net.JoinHostPort(host, strconv.Itoa(port)), + time.Duration(timeoutMs)*time.Millisecond, + ) + if err != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "open_failed", + Message: err.Error(), + }, + } + } + setTCPNoDelay(conn) + + s.mu.Lock() + streamID := fmt.Sprintf("s-%d", s.nextStreamID) + s.nextStreamID++ + s.streams[streamID] = &streamState{conn: conn} + s.mu.Unlock() + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "stream_id": streamID, + }, + } +} + +func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse { + streamID, ok := getStringParam(req.Params, "stream_id") + if !ok || streamID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.close requires stream_id", + }, + } + } + + s.mu.Lock() + state, exists := s.streams[streamID] + if exists { + delete(s.streams, streamID) + } + s.mu.Unlock() + + if !exists { + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "closed": true, + }, + } + } + + _ = state.conn.Close() + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "closed": true, + }, + } +} + +func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse { + streamID, ok := getStringParam(req.Params, "stream_id") + if !ok || streamID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.write requires stream_id", + }, + } + } + dataBase64, ok := getStringParam(req.Params, "data_base64") + if !ok { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.write requires data_base64", + }, + } + } + payload, err := base64.StdEncoding.DecodeString(dataBase64) + if err != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "data_base64 must be valid base64", + }, + } + } + + state, found := s.getStream(streamID) + if !found { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "stream not found", + }, + } + } + conn := state.conn + + timeoutMs := 8000 + if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout { + timeoutMs = parsed + } + if timeoutMs > 0 { + if err := conn.SetWriteDeadline(time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)); err != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: err.Error(), + }, + } + } + defer conn.SetWriteDeadline(time.Time{}) + } + + total := 0 + for total < len(payload) { + written, writeErr := conn.Write(payload[total:]) + if written == 0 && writeErr == nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: "write made no progress", + }, + } + } + total += written + if writeErr != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: writeErr.Error(), + }, + } + } + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "written": total, + }, + } +} + +func (s *rpcServer) handleProxyStreamSubscribe(req rpcRequest) rpcResponse { + streamID, ok := getStringParam(req.Params, "stream_id") + if !ok || streamID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.stream.subscribe requires stream_id", + }, + } + } + + s.mu.Lock() + state, found := s.streams[streamID] + if !found { + s.mu.Unlock() + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "stream not found", + }, + } + } + alreadySubscribed := state.readerStarted + if !alreadySubscribed { + state.readerStarted = true + } + conn := state.conn + s.mu.Unlock() + + if !alreadySubscribed { + go s.streamPump(streamID, conn) + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "subscribed": true, + "already_subscribed": alreadySubscribed, + }, + } +} + +func (s *rpcServer) handleSessionOpen(req rpcRequest) rpcResponse { + sessionID, _ := getStringParam(req.Params, "session_id") + + s.mu.Lock() + defer s.mu.Unlock() + + if sessionID == "" { + sessionID = fmt.Sprintf("sess-%d", s.nextSessionID) + s.nextSessionID++ + } + + session, exists := s.sessions[sessionID] + if !exists { + session = &sessionState{ + attachments: map[string]sessionAttachment{}, + } + s.sessions[sessionID] = session + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionClose(req rpcRequest) rpcResponse { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.close requires session_id", + }, + } + } + + s.mu.Lock() + _, exists := s.sessions[sessionID] + if exists { + delete(s.sessions, sessionID) + } + s.mu.Unlock() + + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "session_id": sessionID, + "closed": true, + }, + } +} + +func (s *rpcServer) handleSessionAttach(req rpcRequest) rpcResponse { + sessionID, attachmentID, cols, rows, badResp := parseSessionAttachmentParams(req, "session.attach") + if badResp != nil { + return *badResp + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + + session.attachments[attachmentID] = sessionAttachment{ + Cols: cols, + Rows: rows, + UpdatedAt: time.Now().UTC(), + } + recomputeSessionSize(session) + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionResize(req rpcRequest) rpcResponse { + sessionID, attachmentID, cols, rows, badResp := parseSessionAttachmentParams(req, "session.resize") + if badResp != nil { + return *badResp + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + if _, exists := session.attachments[attachmentID]; !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "attachment not found", + }, + } + } + + session.attachments[attachmentID] = sessionAttachment{ + Cols: cols, + Rows: rows, + UpdatedAt: time.Now().UTC(), + } + recomputeSessionSize(session) + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionDetach(req rpcRequest) rpcResponse { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.detach requires session_id", + }, + } + } + attachmentID, ok := getStringParam(req.Params, "attachment_id") + if !ok || attachmentID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.detach requires attachment_id", + }, + } + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + if _, exists := session.attachments[attachmentID]; !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "attachment not found", + }, + } + } + + delete(session.attachments, attachmentID) + recomputeSessionSize(session) + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionStatus(req rpcRequest) rpcResponse { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.status requires session_id", + }, + } + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func parseSessionAttachmentParams(req rpcRequest, method string) (sessionID string, attachmentID string, cols int, rows int, badResp *rpcResponse) { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires session_id", + }, + } + return "", "", 0, 0, &resp + } + attachmentID, ok = getStringParam(req.Params, "attachment_id") + if !ok || attachmentID == "" { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires attachment_id", + }, + } + return "", "", 0, 0, &resp + } + + cols, ok = getIntParam(req.Params, "cols") + if !ok || cols <= 0 { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires cols > 0", + }, + } + return "", "", 0, 0, &resp + } + rows, ok = getIntParam(req.Params, "rows") + if !ok || rows <= 0 { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires rows > 0", + }, + } + return "", "", 0, 0, &resp + } + + return sessionID, attachmentID, cols, rows, nil +} + +func recomputeSessionSize(session *sessionState) { + if len(session.attachments) == 0 { + session.effectiveCols = session.lastKnownCols + session.effectiveRows = session.lastKnownRows + return + } + + minCols := 0 + minRows := 0 + for _, attachment := range session.attachments { + if minCols == 0 || attachment.Cols < minCols { + minCols = attachment.Cols + } + if minRows == 0 || attachment.Rows < minRows { + minRows = attachment.Rows + } + } + + session.effectiveCols = minCols + session.effectiveRows = minRows + session.lastKnownCols = minCols + session.lastKnownRows = minRows +} + +func sessionSnapshot(sessionID string, session *sessionState) map[string]any { + attachmentIDs := make([]string, 0, len(session.attachments)) + for attachmentID := range session.attachments { + attachmentIDs = append(attachmentIDs, attachmentID) + } + sort.Strings(attachmentIDs) + + attachments := make([]map[string]any, 0, len(attachmentIDs)) + for _, attachmentID := range attachmentIDs { + attachment := session.attachments[attachmentID] + attachments = append(attachments, map[string]any{ + "attachment_id": attachmentID, + "cols": attachment.Cols, + "rows": attachment.Rows, + "updated_at": attachment.UpdatedAt.Format(time.RFC3339Nano), + }) + } + + return map[string]any{ + "session_id": sessionID, + "attachments": attachments, + "effective_cols": session.effectiveCols, + "effective_rows": session.effectiveRows, + "last_known_cols": session.lastKnownCols, + "last_known_rows": session.lastKnownRows, + } +} + +func (s *rpcServer) getStream(streamID string) (*streamState, bool) { + s.mu.Lock() + defer s.mu.Unlock() + state, ok := s.streams[streamID] + return state, ok +} + +func (s *rpcServer) dropStream(streamID string) { + s.mu.Lock() + state, ok := s.streams[streamID] + if ok { + delete(s.streams, streamID) + } + s.mu.Unlock() + if ok { + _ = state.conn.Close() + } +} + +func (s *rpcServer) closeAll() { + s.mu.Lock() + streams := make([]net.Conn, 0, len(s.streams)) + for id, state := range s.streams { + delete(s.streams, id) + streams = append(streams, state.conn) + } + for id := range s.sessions { + delete(s.sessions, id) + } + s.mu.Unlock() + for _, conn := range streams { + _ = conn.Close() + } +} + +func (s *rpcServer) streamPump(streamID string, conn net.Conn) { + defer func() { + if recovered := recover(); recovered != nil { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.error", + StreamID: streamID, + Error: fmt.Sprintf("stream panic: %v", recovered), + }) + s.dropStream(streamID) + } + }() + + buffer := make([]byte, 32768) + for { + n, readErr := conn.Read(buffer) + data := append([]byte(nil), buffer[:max(0, n)]...) + if len(data) > 0 { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.data", + StreamID: streamID, + DataBase64: base64.StdEncoding.EncodeToString(data), + }) + } + + if readErr == nil { + if n == 0 { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.error", + StreamID: streamID, + Error: "read made no progress", + }) + s.dropStream(streamID) + return + } + continue + } + + if readErr == io.EOF { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.eof", + StreamID: streamID, + DataBase64: "", + }) + } else if !errors.Is(readErr, net.ErrClosed) { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.error", + StreamID: streamID, + Error: readErr.Error(), + }) + } + + s.dropStream(streamID) + return + } +} + +func getStringParam(params map[string]any, key string) (string, bool) { + if params == nil { + return "", false + } + raw, ok := params[key] + if !ok || raw == nil { + return "", false + } + value, ok := raw.(string) + return value, ok +} + +func getIntParam(params map[string]any, key string) (int, bool) { + if params == nil { + return 0, false + } + raw, ok := params[key] + if !ok || raw == nil { + return 0, false + } + switch value := raw.(type) { + case int: + return value, true + case int8: + return int(value), true + case int16: + return int(value), true + case int32: + return int(value), true + case int64: + return int(value), true + case uint: + return int(value), true + case uint8: + return int(value), true + case uint16: + return int(value), true + case uint32: + return int(value), true + case uint64: + return int(value), true + case float64: + if math.Trunc(value) != value { + return 0, false + } + return int(value), true + case json.Number: + n, err := value.Int64() + if err != nil { + return 0, false + } + return int(n), true + default: + return 0, false + } +} diff --git a/daemon/remote/cmd/cmuxd-remote/main_test.go b/daemon/remote/cmd/cmuxd-remote/main_test.go new file mode 100644 index 00000000..531dbc9b --- /dev/null +++ b/daemon/remote/cmd/cmuxd-remote/main_test.go @@ -0,0 +1,755 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/base64" + "encoding/json" + "io" + "math" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +type notifyingBuffer struct { + mu sync.Mutex + buffer bytes.Buffer + notify chan struct{} +} + +func newNotifyingBuffer() *notifyingBuffer { + return ¬ifyingBuffer{notify: make(chan struct{}, 1)} +} + +func (b *notifyingBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + n, err := b.buffer.Write(p) + if n > 0 { + select { + case b.notify <- struct{}{}: + default: + } + } + return n, err +} + +func (b *notifyingBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buffer.String() +} + +type eofWithPayloadConn struct { + payload []byte + readOnce bool +} + +func (c *eofWithPayloadConn) Read(p []byte) (int, error) { + if c.readOnce { + return 0, io.EOF + } + c.readOnce = true + n := copy(p, c.payload) + return n, io.EOF +} + +func (c *eofWithPayloadConn) Write(p []byte) (int, error) { + return len(p), nil +} + +func (c *eofWithPayloadConn) Close() error { return nil } +func (c *eofWithPayloadConn) LocalAddr() net.Addr { + return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} +} +func (c *eofWithPayloadConn) RemoteAddr() net.Addr { + return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} +} +func (c *eofWithPayloadConn) SetDeadline(time.Time) error { return nil } +func (c *eofWithPayloadConn) SetReadDeadline(time.Time) error { return nil } +func (c *eofWithPayloadConn) SetWriteDeadline(time.Time) error { return nil } + +func TestRunVersion(t *testing.T) { + var out bytes.Buffer + code := run([]string{"version"}, strings.NewReader(""), &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run version exit code = %d, want 0", code) + } + if strings.TrimSpace(out.String()) == "" { + t.Fatalf("version output should not be empty") + } +} + +func TestWrapperBinaryDispatchesIntoCLI(t *testing.T) { + if os.Getenv("CMUXD_REMOTE_MAIN_HELPER") == "1" { + separator := 0 + for i, arg := range os.Args { + if arg == "--" { + separator = i + break + } + } + if separator == 0 { + t.Fatal("helper process missing -- separator") + } + os.Args = append([]string{os.Args[0]}, os.Args[separator+1:]...) + main() + return + } + + sockPath := startMockSocket(t, "PONG") + wrapperPath := filepath.Join(t.TempDir(), "cmuxd-remote-current") + if err := os.Symlink(os.Args[0], wrapperPath); err != nil { + t.Fatalf("symlink wrapper path: %v", err) + } + + cmd := exec.Command( + wrapperPath, + "-test.run=TestWrapperBinaryDispatchesIntoCLI", + "--", + "--socket", sockPath, "ping", + ) + cmd.Env = append(os.Environ(), "CMUXD_REMOTE_MAIN_HELPER=1") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("wrapper invocation failed: %v\n%s", err, output) + } + + if got := strings.TrimSpace(string(output)); got != "PONG" { + t.Fatalf("wrapper invocation output = %q, want %q", got, "PONG") + } +} + +func TestRunStdioHelloAndPing(t *testing.T) { + input := strings.NewReader( + `{"id":1,"method":"hello","params":{}}` + "\n" + + `{"id":2,"method":"ping","params":{}}` + "\n", + ) + var out bytes.Buffer + code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run serve exit code = %d, want 0", code) + } + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 2 { + t.Fatalf("got %d response lines, want 2: %q", len(lines), out.String()) + } + + var first map[string]any + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("failed to decode first response: %v", err) + } + if ok, _ := first["ok"].(bool); !ok { + t.Fatalf("first response should be ok=true: %v", first) + } + firstResult, _ := first["result"].(map[string]any) + if firstResult == nil { + t.Fatalf("first response missing result object: %v", first) + } + capabilities, _ := firstResult["capabilities"].([]any) + if len(capabilities) < 2 { + t.Fatalf("hello should return capabilities: %v", firstResult) + } + var sawPushCapability bool + for _, capability := range capabilities { + if capability == "proxy.stream.push" { + sawPushCapability = true + break + } + } + if !sawPushCapability { + t.Fatalf("hello should advertise proxy.stream.push: %v", firstResult) + } + + var second map[string]any + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatalf("failed to decode second response: %v", err) + } + if ok, _ := second["ok"].(bool); !ok { + t.Fatalf("second response should be ok=true: %v", second) + } +} + +func TestRunStdioInvalidJSONAndUnknownMethod(t *testing.T) { + input := strings.NewReader( + `{"id":1,"method":"hello","params":{}` + "\n" + + `{"id":2,"method":"unknown","params":{}}` + "\n", + ) + var out bytes.Buffer + code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run serve exit code = %d, want 0", code) + } + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 2 { + t.Fatalf("got %d response lines, want 2: %q", len(lines), out.String()) + } + + var first map[string]any + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("failed to decode first response: %v", err) + } + if ok, _ := first["ok"].(bool); ok { + t.Fatalf("first response should be ok=false for invalid JSON: %v", first) + } + firstError, _ := first["error"].(map[string]any) + if got := firstError["code"]; got != "invalid_request" { + t.Fatalf("invalid JSON should return invalid_request; got=%v payload=%v", got, first) + } + + var second map[string]any + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatalf("failed to decode second response: %v", err) + } + if ok, _ := second["ok"].(bool); ok { + t.Fatalf("second response should be ok=false for unknown method: %v", second) + } + secondError, _ := second["error"].(map[string]any) + if got := secondError["code"]; got != "method_not_found" { + t.Fatalf("unknown method should return method_not_found; got=%v payload=%v", got, second) + } +} + +func TestRunStdioSessionResizeFlow(t *testing.T) { + input := strings.NewReader( + `{"id":1,"method":"session.open","params":{"session_id":"sess-stdio"}}` + "\n" + + `{"id":2,"method":"session.attach","params":{"session_id":"sess-stdio","attachment_id":"a1","cols":120,"rows":40}}` + "\n" + + `{"id":3,"method":"session.attach","params":{"session_id":"sess-stdio","attachment_id":"a2","cols":90,"rows":30}}` + "\n" + + `{"id":4,"method":"session.status","params":{"session_id":"sess-stdio"}}` + "\n", + ) + var out bytes.Buffer + code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run serve exit code = %d, want 0", code) + } + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 4 { + t.Fatalf("got %d response lines, want 4: %q", len(lines), out.String()) + } + + var status map[string]any + if err := json.Unmarshal([]byte(lines[3]), &status); err != nil { + t.Fatalf("failed to decode status response: %v", err) + } + if ok, _ := status["ok"].(bool); !ok { + t.Fatalf("session.status should be ok=true: %v", status) + } + result, _ := status["result"].(map[string]any) + if result == nil { + t.Fatalf("session.status missing result object: %v", status) + } + effectiveCols, _ := result["effective_cols"].(float64) + effectiveRows, _ := result["effective_rows"].(float64) + if int(effectiveCols) != 90 || int(effectiveRows) != 30 { + t.Fatalf("session smallest-wins effective size mismatch: got=%vx%v payload=%v", effectiveCols, effectiveRows, result) + } +} + +func TestProxyStreamRoundTrip(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen failed: %v", err) + } + defer listener.Close() + + done := make(chan struct{}) + go func() { + defer close(done) + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer conn.Close() + + buffer := make([]byte, 4) + if _, readErr := io.ReadFull(conn, buffer); readErr != nil { + return + } + if string(buffer) != "ping" { + return + } + _, _ = conn.Write([]byte("pong")) + }() + + eventOutput := newNotifyingBuffer() + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]*streamState{}, + sessions: map[string]*sessionState{}, + frameWriter: &stdioFrameWriter{ + writer: bufio.NewWriter(eventOutput), + }, + } + defer server.closeAll() + + port := listener.Addr().(*net.TCPAddr).Port + openResp := server.handleRequest(rpcRequest{ + ID: 1, + Method: "proxy.open", + Params: map[string]any{ + "host": "127.0.0.1", + "port": port, + "timeout_ms": 1000, + }, + }) + if !openResp.OK { + t.Fatalf("proxy.open failed: %+v", openResp) + } + openResult, _ := openResp.Result.(map[string]any) + streamID, _ := openResult["stream_id"].(string) + if streamID == "" { + t.Fatalf("proxy.open missing stream_id: %+v", openResp) + } + + writeResp := server.handleRequest(rpcRequest{ + ID: 2, + Method: "proxy.write", + Params: map[string]any{ + "stream_id": streamID, + "data_base64": base64.StdEncoding.EncodeToString([]byte("ping")), + }, + }) + if !writeResp.OK { + t.Fatalf("proxy.write failed: %+v", writeResp) + } + + readResp := server.handleRequest(rpcRequest{ + ID: 3, + Method: "proxy.stream.subscribe", + Params: map[string]any{ + "stream_id": streamID, + }, + }) + if !readResp.OK { + t.Fatalf("proxy.stream.subscribe failed: %+v", readResp) + } + select { + case <-eventOutput.notify: + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for proxy.stream.data event") + } + + lines := strings.Split(strings.TrimSpace(eventOutput.String()), "\n") + if len(lines) == 0 || strings.TrimSpace(lines[0]) == "" { + t.Fatalf("proxy.stream.data event output was empty") + } + + var event map[string]any + if err := json.Unmarshal([]byte(lines[0]), &event); err != nil { + t.Fatalf("failed to decode stream event: %v", err) + } + if got := event["event"]; got != "proxy.stream.data" { + t.Fatalf("unexpected stream event=%v payload=%v", got, event) + } + dataBase64, _ := event["data_base64"].(string) + data, decodeErr := base64.StdEncoding.DecodeString(dataBase64) + if decodeErr != nil { + t.Fatalf("proxy.stream.data returned invalid base64: %v", decodeErr) + } + if string(data) != "pong" { + t.Fatalf("proxy.stream.data payload=%q, want %q", string(data), "pong") + } + + closeResp := server.handleRequest(rpcRequest{ + ID: 4, + Method: "proxy.close", + Params: map[string]any{ + "stream_id": streamID, + }, + }) + if !closeResp.OK { + t.Fatalf("proxy.close failed: %+v", closeResp) + } + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("proxy test server goroutine did not finish") + } +} + +func TestProxyStreamEOFPayloadIsNotDuplicatedAcrossDataAndEOFEvents(t *testing.T) { + eventOutput := newNotifyingBuffer() + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]*streamState{ + "stream-1": { + conn: &eofWithPayloadConn{payload: []byte("tail")}, + }, + }, + sessions: map[string]*sessionState{}, + frameWriter: &stdioFrameWriter{ + writer: bufio.NewWriter(eventOutput), + }, + } + defer server.closeAll() + + resp := server.handleRequest(rpcRequest{ + ID: 1, + Method: "proxy.stream.subscribe", + Params: map[string]any{"stream_id": "stream-1"}, + }) + if !resp.OK { + t.Fatalf("proxy.stream.subscribe failed: %+v", resp) + } + + deadline := time.Now().Add(2 * time.Second) + for strings.Count(strings.TrimSpace(eventOutput.String()), "\n")+boolToInt(strings.TrimSpace(eventOutput.String()) != "") < 2 { + remaining := time.Until(deadline) + if remaining <= 0 { + t.Fatalf("timed out waiting for proxy stream events: %q", eventOutput.String()) + } + select { + case <-eventOutput.notify: + case <-time.After(remaining): + t.Fatalf("timed out waiting for proxy stream events: %q", eventOutput.String()) + } + } + + lines := strings.Split(strings.TrimSpace(eventOutput.String()), "\n") + if len(lines) != 2 { + t.Fatalf("expected exactly 2 stream events, got %d: %q", len(lines), eventOutput.String()) + } + + var first map[string]any + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("decode first event: %v", err) + } + var second map[string]any + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatalf("decode second event: %v", err) + } + + if got := first["event"]; got != "proxy.stream.data" { + t.Fatalf("first event = %v, want proxy.stream.data", got) + } + if got := second["event"]; got != "proxy.stream.eof" { + t.Fatalf("second event = %v, want proxy.stream.eof", got) + } + + firstPayload, err := base64.StdEncoding.DecodeString(first["data_base64"].(string)) + if err != nil { + t.Fatalf("decode first payload: %v", err) + } + secondPayload, err := decodeOptionalBase64(second["data_base64"]) + if err != nil { + t.Fatalf("decode second payload: %v", err) + } + + if string(firstPayload) != "tail" { + t.Fatalf("proxy.stream.data payload = %q, want %q", string(firstPayload), "tail") + } + if len(secondPayload) != 0 { + t.Fatalf("proxy.stream.eof payload = %q, want empty payload after data event", string(secondPayload)) + } +} + +func boolToInt(value bool) int { + if value { + return 1 + } + return 0 +} + +func decodeOptionalBase64(value any) ([]byte, error) { + encoded, ok := value.(string) + if !ok || encoded == "" { + return nil, nil + } + return base64.StdEncoding.DecodeString(encoded) +} + +func TestGetIntParamRejectsFractionalFloat64(t *testing.T) { + params := map[string]any{ + "port": 80.9, + "timeout_ms": 100.0, + } + + if _, ok := getIntParam(params, "port"); ok { + t.Fatalf("fractional float64 should be rejected") + } + + timeout, ok := getIntParam(params, "timeout_ms") + if !ok { + t.Fatalf("integral float64 should be accepted") + } + if timeout != 100 { + t.Fatalf("timeout_ms = %d, want 100", timeout) + } +} + +func TestRunStdioOversizedFrameContinuesServing(t *testing.T) { + oversized := `{"id":1,"method":"ping","params":{"blob":"` + strings.Repeat("a", maxRPCFrameBytes) + `"}}` + input := strings.NewReader(oversized + "\n" + `{"id":2,"method":"ping","params":{}}` + "\n") + var out bytes.Buffer + code := run([]string{"serve", "--stdio"}, input, &out, &bytes.Buffer{}) + if code != 0 { + t.Fatalf("run serve exit code = %d, want 0", code) + } + + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 2 { + t.Fatalf("got %d response lines, want 2: %q", len(lines), out.String()) + } + + var first map[string]any + if err := json.Unmarshal([]byte(lines[0]), &first); err != nil { + t.Fatalf("failed to decode first response: %v", err) + } + if ok, _ := first["ok"].(bool); ok { + t.Fatalf("first response should be oversized-frame error: %v", first) + } + firstError, _ := first["error"].(map[string]any) + if got := firstError["code"]; got != "invalid_request" { + t.Fatalf("oversized frame should return invalid_request; got=%v payload=%v", got, first) + } + + var second map[string]any + if err := json.Unmarshal([]byte(lines[1]), &second); err != nil { + t.Fatalf("failed to decode second response: %v", err) + } + if ok, _ := second["ok"].(bool); !ok { + t.Fatalf("second response should still be handled after oversized frame: %v", second) + } +} + +func TestProxyOpenInvalidParams(t *testing.T) { + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]*streamState{}, + sessions: map[string]*sessionState{}, + } + defer server.closeAll() + + resp := server.handleRequest(rpcRequest{ + ID: 1, + Method: "proxy.open", + Params: map[string]any{ + "host": "127.0.0.1", + "port": strconv.Itoa(8080), + }, + }) + if resp.OK { + t.Fatalf("proxy.open with invalid port type should fail: %+v", resp) + } + errObj, _ := resp.Error, resp.Error + if errObj == nil || errObj.Code != "invalid_params" { + t.Fatalf("proxy.open invalid params should return invalid_params: %+v", resp) + } +} + +func TestSessionResizeCoordinator(t *testing.T) { + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]*streamState{}, + sessions: map[string]*sessionState{}, + } + defer server.closeAll() + + openResp := server.handleRequest(rpcRequest{ + ID: 1, + Method: "session.open", + Params: map[string]any{ + "session_id": "sess-rz", + }, + }) + if !openResp.OK { + t.Fatalf("session.open failed: %+v", openResp) + } + + attachSmall := server.handleRequest(rpcRequest{ + ID: 2, + Method: "session.attach", + Params: map[string]any{ + "session_id": "sess-rz", + "attachment_id": "a-small", + "cols": 90, + "rows": 30, + }, + }) + assertEffectiveSize(t, attachSmall, 90, 30) + + attachLarge := server.handleRequest(rpcRequest{ + ID: 3, + Method: "session.attach", + Params: map[string]any{ + "session_id": "sess-rz", + "attachment_id": "a-large", + "cols": 120, + "rows": 40, + }, + }) + assertEffectiveSize(t, attachLarge, 90, 30) // RZ-001: smallest wins + + resizeLarge := server.handleRequest(rpcRequest{ + ID: 4, + Method: "session.resize", + Params: map[string]any{ + "session_id": "sess-rz", + "attachment_id": "a-large", + "cols": 200, + "rows": 60, + }, + }) + assertEffectiveSize(t, resizeLarge, 90, 30) // RZ-002: still bounded by smallest + + detachSmall := server.handleRequest(rpcRequest{ + ID: 5, + Method: "session.detach", + Params: map[string]any{ + "session_id": "sess-rz", + "attachment_id": "a-small", + }, + }) + assertEffectiveSize(t, detachSmall, 200, 60) // RZ-003: expands to next smallest + + detachLarge := server.handleRequest(rpcRequest{ + ID: 6, + Method: "session.detach", + Params: map[string]any{ + "session_id": "sess-rz", + "attachment_id": "a-large", + }, + }) + assertEffectiveSize(t, detachLarge, 200, 60) // no attachments: keep last-known size + assertAttachmentCount(t, detachLarge, 0) + + reattach := server.handleRequest(rpcRequest{ + ID: 7, + Method: "session.attach", + Params: map[string]any{ + "session_id": "sess-rz", + "attachment_id": "a-reconnect", + "cols": 110, + "rows": 50, + }, + }) + assertEffectiveSize(t, reattach, 110, 50) // RZ-004: recompute from active attachments on reattach +} + +func TestSessionInvalidParamsAndNotFound(t *testing.T) { + server := &rpcServer{ + nextStreamID: 1, + nextSessionID: 1, + streams: map[string]*streamState{}, + sessions: map[string]*sessionState{}, + } + defer server.closeAll() + + missingSession := server.handleRequest(rpcRequest{ + ID: 1, + Method: "session.attach", + Params: map[string]any{ + "session_id": "missing", + "attachment_id": "a1", + "cols": 80, + "rows": 24, + }, + }) + if missingSession.OK || missingSession.Error == nil || missingSession.Error.Code != "not_found" { + t.Fatalf("session.attach on missing session should return not_found: %+v", missingSession) + } + + badSize := server.handleRequest(rpcRequest{ + ID: 2, + Method: "session.attach", + Params: map[string]any{ + "session_id": "missing", + "attachment_id": "a1", + "cols": 0, + "rows": 24, + }, + }) + if badSize.OK || badSize.Error == nil || badSize.Error.Code != "invalid_params" { + t.Fatalf("session.attach with cols=0 should return invalid_params: %+v", badSize) + } +} + +func assertEffectiveSize(t *testing.T, resp rpcResponse, wantCols, wantRows int) { + t.Helper() + if !resp.OK { + t.Fatalf("expected ok response, got error: %+v", resp) + } + result, ok := resp.Result.(map[string]any) + if !ok { + t.Fatalf("response missing result map: %+v", resp) + } + gotCols := asInt(t, result["effective_cols"], "effective_cols") + gotRows := asInt(t, result["effective_rows"], "effective_rows") + if gotCols != wantCols || gotRows != wantRows { + t.Fatalf("effective size = %dx%d, want %dx%d payload=%+v", gotCols, gotRows, wantCols, wantRows, result) + } +} + +func assertAttachmentCount(t *testing.T, resp rpcResponse, want int) { + t.Helper() + if !resp.OK { + t.Fatalf("expected ok response, got error: %+v", resp) + } + result, ok := resp.Result.(map[string]any) + if !ok { + t.Fatalf("response missing result map: %+v", resp) + } + attachments, ok := result["attachments"].([]map[string]any) + if ok { + if len(attachments) != want { + t.Fatalf("attachments len = %d, want %d payload=%+v", len(attachments), want, result) + } + return + } + attachmentsAny, ok := result["attachments"].([]any) + if !ok { + t.Fatalf("attachments field has unexpected type (%T) payload=%+v", result["attachments"], result) + } + if len(attachmentsAny) != want { + t.Fatalf("attachments len = %d, want %d payload=%+v", len(attachmentsAny), want, result) + } +} + +func asInt(t *testing.T, value any, field string) int { + t.Helper() + switch typed := value.(type) { + case int: + return typed + case int8: + return int(typed) + case int16: + return int(typed) + case int32: + return int(typed) + case int64: + return int(typed) + case uint: + return int(typed) + case uint8: + return int(typed) + case uint16: + return int(typed) + case uint32: + return int(typed) + case uint64: + return int(typed) + case float64: + if typed != math.Trunc(typed) { + t.Fatalf("%s should be integer-valued, got %v", field, typed) + } + return int(typed) + default: + t.Fatalf("%s has unexpected type %T (%v)", field, value, value) + return 0 + } +} diff --git a/daemon/remote/go.mod b/daemon/remote/go.mod new file mode 100644 index 00000000..f4b93baa --- /dev/null +++ b/daemon/remote/go.mod @@ -0,0 +1,3 @@ +module github.com/manaflow-ai/cmux/daemon/remote + +go 1.22 diff --git a/docs/remote-daemon-spec.md b/docs/remote-daemon-spec.md new file mode 100644 index 00000000..3c8bb0c8 --- /dev/null +++ b/docs/remote-daemon-spec.md @@ -0,0 +1,214 @@ +# Remote SSH Living Spec + +Last updated: March 12, 2026 +Tracking issue: https://github.com/manaflow-ai/cmux/issues/151 +Primary PR: https://github.com/manaflow-ai/cmux/pull/1296 +CLI relay PR: https://github.com/manaflow-ai/cmux/pull/374 + +This document is the working source of truth for: +1. what is implemented now +2. what is intentionally temporary +3. what must be built next + +## 1. Document Type + +This is a **living implementation spec** (also called an **execution spec**): a spec-level document with status tracking (`DONE`, `IN PROGRESS`, `TODO`) and acceptance tests. + +## 2. Objective + +`cmux ssh` should provide: +1. durable remote terminals with reconnect/reuse +2. browser traffic that egresses from the remote host via proxying +3. tmux-style PTY resize semantics (`smallest screen wins`) + +## 3. Current State (Implemented) + +### 3.1 Remote Workspace + Reconnect UX +- `DONE` `cmux ssh` creates remote-tagged workspaces and does not require `--name`. +- `DONE` scoped shell niceties are applied only for `cmux ssh` launches. +- `DONE` context menu actions exist for remote workspaces (`Reconnect Workspace(s)`, `Disconnect Workspace(s)`). +- `DONE` socket API includes `workspace.remote.reconnect`. + +### 3.2 Bootstrap + Daemon +- `DONE` local app probes remote platform, verifies a release-pinned `cmuxd-remote` artifact by embedded manifest SHA-256, uploads it when missing, and runs `serve --stdio`. +- `DONE` daemon `hello` handshake is enforced. +- `DONE` daemon now exposes proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.stream.subscribe`) plus pushed `proxy.stream.*` events. +- `DONE` local proxy broker now tunnels SOCKS5/CONNECT traffic over daemon stream RPC instead of `ssh -D`. +- `DONE` daemon now exposes session resize-coordinator RPC (`session.open`, `session.attach`, `session.resize`, `session.detach`, `session.status`, `session.close`). +- `DONE` transport-level proxy failures now escalate from broker retry to full daemon re-bootstrap/reconnect in the session controller. +- `DONE` SOCKS handshake parsing now preserves pipelined post-connect payload bytes instead of dropping request-prefix bytes. +- `DONE` `workspace.remote.configure.local_proxy_port` exists as an internal deterministic test hook for bind-conflict regression coverage. +- `DONE` bootstrap/probe failures surface actionable details. +- `DONE` bootstrap installs `~/.cmux/bin/cmux` wrapper (also tries `/usr/local/bin/cmux`) so `cmux` is available in PATH on the remote. + +### 3.5 CLI Relay (Running cmux Commands From Remote) +- `DONE` `cmuxd-remote` includes a table-driven CLI relay (`cli` subcommand) that maps CLI args to v1 text or v2 JSON-RPC messages. +- `DONE` busybox-style argv[0] detection: when invoked as `cmux` via wrapper/symlink, auto-dispatches to CLI relay. +- `DONE` background `ssh -N -R 127.0.0.1:PORT:127.0.0.1:LOCAL_RELAY_PORT` process reverse-forwards a TCP port to a dedicated authenticated local relay server. Uses TCP instead of Unix socket forwarding because many servers have `AllowStreamLocalForwarding` disabled. +- `DONE` relay process uses `-S none` / standalone SSH transport (avoids ControlMaster multiplexing and inherited `RemoteForward` directives) and `ExitOnForwardFailure=yes` so dead reverse binds fail fast instead of publishing bad relay metadata. +- `DONE` relay address written to `~/.cmux/socket_addr` on the remote only after the reverse forward survives startup validation. +- `DONE` Go CLI no longer polls for relay readiness. It dials the published relay once and only refreshes `~/.cmux/socket_addr` a single time to recover from a stale shared address rewrite. +- `DONE` `cmux ssh` startup exports session-local `CMUX_SOCKET_PATH=127.0.0.1:<relay_port>` so parallel sessions pin to their own relay instead of racing on shared socket_addr. +- `DONE` relay startup writes `~/.cmux/relay/<relay_port>.daemon_path`; remote `cmux` wrapper uses this to select the right daemon binary per session, including mixed local cmux versions. +- `DONE` relay startup writes `~/.cmux/relay/<relay_port>.auth` with a relay ID and token; the local relay requires HMAC-SHA256 challenge-response before forwarding any command to the real local socket. +- `DONE` ephemeral port range (49152-65535) filtered from probe results to exclude relay ports from other workspaces. +- `DONE` multi-workspace port conflict detection uses TCP connect check (`isLoopbackPortReachable`) so ports already forwarded by another workspace are silently skipped instead of flagged as conflicts. +- `DONE` orphaned relay SSH processes from previous app sessions are cleaned up before starting a new relay. + +### 3.6 Artifact Trust +- `DONE` release and nightly workflows publish `cmuxd-remote` assets for `darwin/linux × arm64/amd64`. +- `DONE` release and nightly apps embed a compact `CMUXRemoteDaemonManifestJSON` in `Info.plist` with exact asset URLs and SHA-256 digests. +- `DONE` `cmux remote-daemon-status` exposes the current manifest entry, local cache verification state, release download command, and GitHub attestation verification command. + +### 3.3 Error Surfacing +- `DONE` remote errors are surfaced in sidebar status + logs + notifications. +- `DONE` reconnect retry count/time is included in surfaced error text (for example, `retry 1 in 4s`). + +### 3.4 Removed Temporary Behavior +- `DONE` removed remote listening-port probe loop and per-port SSH `-L` mirroring. +- `DONE` remote browser routing now uses a single shared local proxy endpoint instead of detected-port mirroring. +- `DONE` remote status now includes structured proxy metadata (`remote.proxy`) and `proxy_unavailable` error code when proxy setup fails. + +## 4. Target Architecture (No Port Mirroring) + +### 4.1 Browser Networking Path +1. `DONE` one local proxy endpoint is created per SSH transport/session key (not per detected port). +2. `DONE` endpoint is provided by a local broker that supports SOCKS5 + HTTP CONNECT and tunnels via daemon stream RPC. +3. `DONE` browser panels in remote workspaces are auto-wired to the workspace proxy endpoint. +4. `DONE` browser panels in local workspaces are not force-proxied. +5. `DONE` identical SSH transports share one endpoint via a transport-scoped broker. + +### 4.2 WKWebView Wiring +1. `DONE` use workspace-scoped `WKWebsiteDataStore(forIdentifier:)`. +2. `DONE` apply workspace/browser scoped `proxyConfigurations`. +3. `DONE` prefer SOCKS5 proxy config. +4. `DONE` keep HTTP CONNECT proxy config as fallback. +5. `DONE` re-apply proxy config on reconnect/state updates. + +### 4.3 Remote Daemon + Transport +1. `DONE` `cmuxd-remote` now supports proxy stream RPC (`proxy.open`, `proxy.close`, `proxy.write`, `proxy.stream.subscribe`) with pushed `proxy.stream.data/eof/error` events. +2. `DONE` local side now runs a shared local broker that serves SOCKS5/CONNECT and tunnels each stream over persistent daemon stdio RPC without polling reads. +3. `DONE` removed remote service-port discovery/probing from browser routing path. + +### 4.4 Explicit Non-Goal +1. Automatic mirroring of every remote listening port to local loopback is not a goal for browser support. + +## 5. PTY Resize Semantics (tmux-style) + +### 5.1 Core Rule +For each session with multiple attachments, the effective PTY size is: +1. `cols = min(cols_i over attached clients)` +2. `rows = min(rows_i over attached clients)` + +This is the `smallest screen wins` rule. + +### 5.2 State Model +Per session track: +1. set of active attachments `{attachment_id -> cols, rows, updated_at}` +2. effective size currently applied to PTY +3. last-known size when temporarily unattached + +### 5.3 Recompute Triggers +Recompute effective size on: +1. attachment create +2. attachment detach +3. resize event from any attachment +4. reconnect reattach + +### 5.4 Correctness Requirements +1. Never shrink history because of UI relayout noise; only PTY viewport changes. +2. On reconnect, reuse persisted session and recompute from active attachments. +3. If no attachments remain, keep last-known PTY size (do not force 80x24 reset). + +## 6. Milestones (Living Status) + +| ID | Milestone | Status | Notes | +|---|---|---|---| +| M-001 | `cmux ssh` workspace creation + metadata + optional `--name` | DONE | Covered by `tests_v2/test_ssh_remote_cli_metadata.py` | +| M-002 | Remote bootstrap/upload/start + hello handshake | DONE | Includes daemon capability handshake + status surfacing | +| M-003 | Reconnect/disconnect UX + API + improved error surfacing | DONE | Includes retry count in surfaced errors | +| M-004 | Docker e2e for bootstrap/reconnect shell niceties | DONE | Docker suites validate proxy-path bootstrap and reconnect behavior | +| M-004b | CLI relay: run cmux commands from within SSH sessions | DONE | Reverse TCP forward + Go CLI relay + bootstrap wrapper | +| M-005 | Remove automatic remote port mirroring path | DONE | `WorkspaceRemoteSessionController` now uses one shared daemon-backed proxy endpoint | +| M-006 | Transport-scoped local proxy broker (SOCKS5 + CONNECT) | DONE | Identical SSH transports now reuse one local proxy endpoint | +| M-007 | Remote proxy stream RPC in `cmuxd-remote` | DONE | `proxy.open/close/write/proxy.stream.subscribe` plus pushed stream events implemented | +| M-008 | WebView proxy auto-wiring for remote workspaces | DONE | Workspace-scoped `WKWebsiteDataStore.proxyConfigurations` wiring is active | +| M-009 | PTY resize coordinator (`smallest screen wins`) | DONE | Daemon session RPC now tracks attachments and applies min cols/rows semantics with unit tests | +| M-010 | Resize + proxy reconnect e2e test suites | DONE | `tests_v2/test_ssh_remote_docker_forwarding.py` validates HTTP/websocket egress plus SOCKS pipelined-payload handling; `tests_v2/test_ssh_remote_docker_reconnect.py` verifies reconnect recovery and repeats SOCKS pipelined-payload checks after host restart; `tests_v2/test_ssh_remote_proxy_bind_conflict.py` validates structured `proxy_unavailable` bind-conflict surfacing and `local_proxy_port` status retention under bind conflict; `tests_v2/test_ssh_remote_daemon_resize_stdio.py` validates session resize semantics over real stdio RPC process boundaries; `tests_v2/test_ssh_remote_cli_metadata.py` validates `workspace.remote.configure` numeric-string compatibility, explicit `null` clear semantics (including `workspace.remote.status` reflection), strict `port`/`local_proxy_port` validation (bounds/type), case-insensitive SSH option override precedence for StrictHostKeyChecking/control-socket keys, and `local_proxy_port` payload echo for deterministic bind-conflict test hook behavior | + +## 7. Acceptance Test Matrix (With Status) + +### 7.1 Terminal + Reconnect + +| ID | Scenario | Status | +|---|---|---| +| T-001 | baseline remote connect | DONE | +| T-002 | identical host reuse semantics | DONE | +| T-003 | no `--name` | DONE | +| T-004 | reconnect API success/error paths | DONE | +| T-005 | retry count visible in daemon error detail | DONE | + +### 7.2 CLI Relay + +| ID | Scenario | Status | +|---|---|---| +| C-001 | `cmux ping` from remote session | DONE | +| C-002 | `cmux list-workspaces --json` from remote | DONE | +| C-003 | `cmux new-workspace` from remote | DONE | +| C-004 | `cmux rpc system.capabilities` passthrough | DONE | +| C-005 | TCP retry handles relay not yet established | DONE | +| C-006 | multi-workspace port conflict silent skip | DONE | +| C-007 | ephemeral port filtering excludes relay ports | DONE | + +### 7.3 Browser Proxy (Target) + +| ID | Scenario | Status | +|---|---|---| +| W-001 | remote workspace browser auto-proxied | DONE | +| W-002 | browser egress equals remote network path | DONE | +| W-003 | websocket via SOCKS5/CONNECT through remote daemon | DONE | +| W-004 | reconnect restores browser proxy path automatically | DONE | +| W-005 | local proxy bind conflict yields structured `proxy_unavailable` | DONE | +| W-006 | proxy transport failure triggers daemon re-bootstrap and recovers after host recreation | DONE | +| W-007 | SOCKS greeting/connect + immediate pipelined payload in same write remains intact | DONE | + +### 7.4 Resize + +| ID | Scenario | Status | +|---|---|---| +| RZ-001 | two attachments, smallest wins | DONE | +| RZ-002 | grow one attachment, PTY stays bounded by smallest | DONE | +| RZ-003 | detach smallest, PTY expands to next smallest | DONE | +| RZ-004 | reconnect preserves session + applies recomputed size | DONE | +| RZ-005 | daemon stdio RPC round-trip enforces resize semantics end-to-end | DONE | + +## 8. Removal Checklist (Port Mirroring) + +Before declaring browser proxying complete: +1. `DONE` remove remote port probe loop and `-L` auto-forward orchestration +2. `DONE` remove mirror-specific routing behavior as default remote behavior +3. `DONE` replace mirroring docker assertions with proxy egress assertions +4. `DONE` keep optional explicit user-driven forwarding out of this path; no automatic mirroring remains in browser routing + +## 9. Open Decisions + +1. Proxy auth policy for local broker (`none` vs optional credentials). +2. Reconnect backoff profile and max retry budget. + +## 10. Socket API Contract Notes + +### 10.1 `workspace.remote.configure` Port Fields +1. `port` and `local_proxy_port` accept integer values and numeric strings. +2. Explicit `null` clears each field. +3. Out-of-range values and invalid types (for example booleans/non-numeric strings/fractional numbers) return `invalid_params`. +4. `local_proxy_port` is an internal deterministic test hook to force local bind conflicts in regression coverage. + +### 10.2 SSH Option Precedence +1. `StrictHostKeyChecking` default (`accept-new`) is only injected when no user override is present. +2. Control-socket defaults (`ControlMaster`, `ControlPersist`, `ControlPath`) are only injected when missing. +3. SSH option key matching is case-insensitive for precedence checks in both CLI-built commands and remote configure payloads. + +### 10.3 SSH Docker E2E Harness Knobs +1. `CMUX_SSH_TEST_DOCKER_HOST` sets the SSH destination host/IP used by docker-backed SSH fixtures (default `127.0.0.1`). +2. `CMUX_SSH_TEST_DOCKER_BIND_ADDR` sets the bind address used in fixture container publish mappings (default `127.0.0.1`). +3. Defaults preserve loopback behavior on a single host; override both when docker runs on a different host (for example VM -> host OrbStack). diff --git a/scripts/build-ghostty-cli-helper.sh b/scripts/build-ghostty-cli-helper.sh index ac8cda4b..d38e641c 100755 --- a/scripts/build-ghostty-cli-helper.sh +++ b/scripts/build-ghostty-cli-helper.sh @@ -53,6 +53,16 @@ if [[ -z "$OUTPUT_PATH" ]]; then exit 1 fi +# Allow CI to skip the zig build (e.g., macOS 26 where zig 0.15.2 can't link). +# Creates a stub binary so the Xcode Run Script file-existence check passes. +if [[ "${CMUX_SKIP_ZIG_BUILD:-}" == "1" ]]; then + echo "Skipping zig CLI helper build (CMUX_SKIP_ZIG_BUILD=1)" + mkdir -p "$(dirname "$OUTPUT_PATH")" + printf '#!/bin/sh\necho "ghostty CLI helper stub (zig build skipped)" >&2\nexit 1\n' > "$OUTPUT_PATH" + chmod +x "$OUTPUT_PATH" + exit 0 +fi + if [[ "$UNIVERSAL" == "true" && -n "$TARGET_TRIPLE" ]]; then echo "--universal and --target are mutually exclusive" >&2 usage >&2 diff --git a/scripts/build_remote_daemon_release_assets.sh b/scripts/build_remote_daemon_release_assets.sh new file mode 100755 index 00000000..6765fb38 --- /dev/null +++ b/scripts/build_remote_daemon_release_assets.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/build_remote_daemon_release_assets.sh \ + --version <app-version> \ + --release-tag <tag> \ + --repo <owner/repo> \ + --output-dir <dir> + +Builds cmuxd-remote release assets for the supported remote platforms and emits: + cmuxd-remote-<goos>-<goarch> + cmuxd-remote-checksums.txt + cmuxd-remote-manifest.json +EOF +} + +VERSION="" +RELEASE_TAG="" +REPO="" +OUTPUT_DIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + VERSION="${2:-}" + shift 2 + ;; + --release-tag) + RELEASE_TAG="${2:-}" + shift 2 + ;; + --repo) + REPO="${2:-}" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "error: unknown option $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "$VERSION" || -z "$RELEASE_TAG" || -z "$REPO" || -z "$OUTPUT_DIR" ]]; then + echo "error: --version, --release-tag, --repo, and --output-dir are required" >&2 + usage + exit 1 +fi + +if ! command -v go >/dev/null 2>&1; then + echo "error: go is required to build cmuxd-remote release assets" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +DAEMON_ROOT="${REPO_ROOT}/daemon/remote" +mkdir -p "$OUTPUT_DIR" +OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" +rm -f "$OUTPUT_DIR"/cmuxd-remote-* "$OUTPUT_DIR"/cmuxd-remote-checksums.txt "$OUTPUT_DIR"/cmuxd-remote-manifest.json + +DAEMON_GO_LDFLAGS="-s -w -X main.version=${VERSION}" +DAEMON_GO_BUILD_ARGS=( + build + -trimpath + -buildvcs=false + -ldflags "$DAEMON_GO_LDFLAGS" +) + +CHECKSUMS_ASSET_NAME="cmuxd-remote-checksums.txt" +CHECKSUMS_PATH="${OUTPUT_DIR}/${CHECKSUMS_ASSET_NAME}" +MANIFEST_PATH="${OUTPUT_DIR}/cmuxd-remote-manifest.json" + +TARGETS=( + "darwin arm64" + "darwin amd64" + "linux arm64" + "linux amd64" +) + +: > "$CHECKSUMS_PATH" +ENTRIES_FILE="$(mktemp "${TMPDIR:-/tmp}/cmuxd-remote-entries.XXXXXX")" +trap 'rm -f "$ENTRIES_FILE"' EXIT +: > "$ENTRIES_FILE" + +for target in "${TARGETS[@]}"; do + read -r GOOS GOARCH <<<"$target" + ASSET_NAME="cmuxd-remote-${GOOS}-${GOARCH}" + OUTPUT_PATH="${OUTPUT_DIR}/${ASSET_NAME}" + + ( + cd "$DAEMON_ROOT" + GOOS="$GOOS" \ + GOARCH="$GOARCH" \ + CGO_ENABLED=0 \ + go "${DAEMON_GO_BUILD_ARGS[@]}" \ + -o "$OUTPUT_PATH" \ + ./cmd/cmuxd-remote + ) + chmod 755 "$OUTPUT_PATH" + + SHA256="$(shasum -a 256 "$OUTPUT_PATH" | awk '{print $1}')" + printf '%s %s\n' "$SHA256" "$ASSET_NAME" >> "$CHECKSUMS_PATH" + + printf '%s\t%s\t%s\t%s\n' "$GOOS" "$GOARCH" "$ASSET_NAME" "$SHA256" >> "$ENTRIES_FILE" +done + +python3 - <<'PY' "$VERSION" "$RELEASE_TAG" "$REPO" "$CHECKSUMS_ASSET_NAME" "$CHECKSUMS_PATH" "$MANIFEST_PATH" "$ENTRIES_FILE" +import json +import sys +import urllib.parse +from pathlib import Path + +version, release_tag, repo, checksums_asset_name, checksums_path, manifest_path, entries_file = sys.argv[1:] +quoted_tag = urllib.parse.quote(release_tag, safe="") +release_url = f"https://github.com/{repo}/releases/download/{quoted_tag}" +checksums_url = f"{release_url}/{urllib.parse.quote(checksums_asset_name, safe='')}" + +entries = [] +for line in Path(entries_file).read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + go_os, go_arch, asset_name, sha256 = line.split("\t") + entries.append({ + "goOS": go_os, + "goArch": go_arch, + "assetName": asset_name, + "downloadURL": f"{release_url}/{urllib.parse.quote(asset_name, safe='')}", + "sha256": sha256, + }) + +manifest = { + "schemaVersion": 1, + "appVersion": version, + "releaseTag": release_tag, + "releaseURL": release_url, + "checksumsAssetName": checksums_asset_name, + "checksumsURL": checksums_url, + "entries": entries, +} +Path(manifest_path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") +PY + +echo "Built cmuxd-remote assets in ${OUTPUT_DIR}" diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 65ff7c1d..1e45a32a 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -3,6 +3,7 @@ # Format: <ghostty_sha> <sha256> 7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207 a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d +c47010b80cd9ae6d1ab744c120f011a465521ea3 d6904870a3c920b2787b1c4b950cfdef232606bb9876964f5e8497081d5cb5df 0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de 312c7b23a7c8dc0704431940d76ba5dc32a46afb ae73cb18a9d6efec42126a1d99e0e9d12022403d7dc301dfa21ed9f7c89c9e30 404a3f175ba6baafabc46cac807194883e040980 bcbd2954f4746fe5bcb4bfca6efeddd3ea355fda2836371f4c7150271c58acbd diff --git a/scripts/release_asset_guard.js b/scripts/release_asset_guard.js index d16d328e..4699b324 100644 --- a/scripts/release_asset_guard.js +++ b/scripts/release_asset_guard.js @@ -1,6 +1,15 @@ "use strict"; -const IMMUTABLE_RELEASE_ASSETS = ["cmux-macos.dmg", "appcast.xml"]; +const IMMUTABLE_RELEASE_ASSETS = [ + "cmux-macos.dmg", + "appcast.xml", + "cmuxd-remote-darwin-arm64", + "cmuxd-remote-darwin-amd64", + "cmuxd-remote-linux-arm64", + "cmuxd-remote-linux-amd64", + "cmuxd-remote-checksums.txt", + "cmuxd-remote-manifest.json", +]; const RELEASE_ASSET_GUARD_STATE = Object.freeze({ CLEAR: "clear", PARTIAL: "partial", diff --git a/scripts/release_asset_guard.test.js b/scripts/release_asset_guard.test.js index c320cf81..39cdcf89 100644 --- a/scripts/release_asset_guard.test.js +++ b/scripts/release_asset_guard.test.js @@ -11,7 +11,7 @@ const { test("marks guard as complete and skips build/upload when all immutable assets already exist", () => { const result = evaluateReleaseAssetGuard({ - existingAssetNames: ["cmux-macos.dmg", "appcast.xml", "notes.txt"], + existingAssetNames: [...IMMUTABLE_RELEASE_ASSETS, "notes.txt"], }); assert.deepEqual(result.conflicts, IMMUTABLE_RELEASE_ASSETS); @@ -36,12 +36,16 @@ test("marks guard as clear when immutable assets are not present", () => { }); test("marks guard as partial when only some immutable assets exist", () => { + const partialAssets = ["appcast.xml", "cmuxd-remote-manifest.json"]; const result = evaluateReleaseAssetGuard({ - existingAssetNames: ["appcast.xml"], + existingAssetNames: partialAssets, }); - assert.deepEqual(result.conflicts, ["appcast.xml"]); - assert.deepEqual(result.missingImmutableAssets, ["cmux-macos.dmg"]); + assert.deepEqual(result.conflicts, partialAssets); + assert.deepEqual( + result.missingImmutableAssets, + IMMUTABLE_RELEASE_ASSETS.filter((assetName) => !partialAssets.includes(assetName)), + ); assert.equal(result.guardState, RELEASE_ASSET_GUARD_STATE.PARTIAL); assert.equal(result.hasPartialConflict, true); assert.equal(result.shouldSkipBuildAndUpload, false); diff --git a/scripts/reload.sh b/scripts/reload.sh index 12c57e81..5abcab9a 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -10,9 +10,88 @@ BUNDLE_SET=0 DERIVED_SET=0 TAG="" CMUX_DEBUG_LOG="" +CLI_PATH="" LAST_SOCKET_PATH_DIR="$HOME/Library/Application Support/cmux" LAST_SOCKET_PATH_FILE="${LAST_SOCKET_PATH_DIR}/last-socket-path" +write_dev_cli_shim() { + local target="$1" + local fallback_bin="$2" + mkdir -p "$(dirname "$target")" + cat > "$target" <<EOF +#!/usr/bin/env bash +# cmux dev shim (managed by scripts/reload.sh) +set -euo pipefail + +CLI_PATH_FILE="/tmp/cmux-last-cli-path" +CLI_PATH_OWNER="\$(stat -f '%u' "\$CLI_PATH_FILE" 2>/dev/null || stat -c '%u' "\$CLI_PATH_FILE" 2>/dev/null || echo -1)" +if [[ -r "\$CLI_PATH_FILE" ]] && [[ ! -L "\$CLI_PATH_FILE" ]] && [[ "\$CLI_PATH_OWNER" == "\$(id -u)" ]]; then + CLI_PATH="\$(cat "\$CLI_PATH_FILE")" + if [[ -x "\$CLI_PATH" ]]; then + exec "\$CLI_PATH" "\$@" + fi +fi + +if [[ -x "$fallback_bin" ]]; then + exec "$fallback_bin" "\$@" +fi + +echo "error: no reload-selected dev cmux CLI found. Run ./scripts/reload.sh --tag <name> first." >&2 +exit 1 +EOF + chmod +x "$target" +} + +select_cmux_shim_target() { + local app_cli_dir="/Applications/cmux.app/Contents/Resources/bin" + local marker="cmux dev shim (managed by scripts/reload.sh)" + local target="" + local path_entry="" + local candidate="" + + IFS=':' read -r -a path_entries <<< "${PATH:-}" + for path_entry in "${path_entries[@]}"; do + [[ -z "$path_entry" ]] && continue + if [[ "$path_entry" == "~/"* ]]; then + path_entry="$HOME/${path_entry#~/}" + fi + if [[ "$path_entry" == "$app_cli_dir" ]]; then + break + fi + [[ -d "$path_entry" && -w "$path_entry" ]] || continue + candidate="$path_entry/cmux" + if [[ ! -e "$candidate" ]]; then + target="$candidate" + break + fi + if [[ -f "$candidate" ]] && grep -q "$marker" "$candidate" 2>/dev/null; then + target="$candidate" + break + fi + done + + if [[ -n "$target" ]]; then + echo "$target" + return 0 + fi + + # Fallback for PATH layouts where app CLI isn't listed or no earlier entries were writable. + for path_entry in /opt/homebrew/bin /usr/local/bin "$HOME/.local/bin" "$HOME/bin"; do + [[ -d "$path_entry" && -w "$path_entry" ]] || continue + candidate="$path_entry/cmux" + if [[ ! -e "$candidate" ]]; then + echo "$candidate" + return 0 + fi + if [[ -f "$candidate" ]] && grep -q "$marker" "$candidate" 2>/dev/null; then + echo "$candidate" + return 0 + fi + done + + return 1 +} + write_last_socket_path() { local socket_path="$1" mkdir -p "$LAST_SOCKET_PATH_DIR" @@ -288,6 +367,14 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_PATH string \"${CMUX_SOCKET}\"" "$INFO_PLIST" /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_DEBUG_LOG \"${CMUX_DEBUG_LOG}\"" "$INFO_PLIST" 2>/dev/null \ || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_DEBUG_LOG string \"${CMUX_DEBUG_LOG}\"" "$INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_SOCKET_ENABLE 1" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_ENABLE string 1" "$INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_SOCKET_MODE automation" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_SOCKET_MODE string automation" "$INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD 1" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD string 1" "$INFO_PLIST" + /usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXTERM_REPO_ROOT \"${PWD}\"" "$INFO_PLIST" 2>/dev/null \ + || /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUXTERM_REPO_ROOT string \"${PWD}\"" "$INFO_PLIST" if [[ -S "$CMUXD_SOCKET" ]]; then for PID in $(lsof -t "$CMUXD_SOCKET" 2>/dev/null); do kill "$PID" 2>/dev/null || true @@ -303,6 +390,21 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then APP_PATH="$TAG_APP_PATH" fi +CLI_PATH="$(dirname "$APP_PATH")/cmux" +if [[ -x "$CLI_PATH" ]]; then + (umask 077; printf '%s\n' "$CLI_PATH" > /tmp/cmux-last-cli-path) || true + ln -sfn "$CLI_PATH" /tmp/cmux-cli || true + + # Stable shim that always follows the last reload-selected dev CLI. + DEV_CLI_SHIM="$HOME/.local/bin/cmux-dev" + write_dev_cli_shim "$DEV_CLI_SHIM" "/Applications/cmux.app/Contents/Resources/bin/cmux" + + CMUX_SHIM_TARGET="$(select_cmux_shim_target || true)" + if [[ -n "${CMUX_SHIM_TARGET:-}" ]]; then + write_dev_cli_shim "$CMUX_SHIM_TARGET" "/Applications/cmux.app/Contents/Resources/bin/cmux" + fi +fi + # Ensure any running instance is fully terminated, regardless of DerivedData path. /usr/bin/osascript -e "tell application id \"${BUNDLE_ID}\" to quit" >/dev/null 2>&1 || true sleep 0.3 @@ -344,6 +446,8 @@ fi OPEN_CLEAN_ENV=( env -u CMUX_SOCKET_PATH + -u CMUX_WORKSPACE_ID + -u CMUX_SURFACE_ID -u CMUX_TAB_ID -u CMUX_PANEL_ID -u CMUXD_UNIX_PATH @@ -364,10 +468,11 @@ OPEN_CLEAN_ENV=( if [[ -n "${TAG_SLUG:-}" && -n "${CMUX_SOCKET:-}" ]]; then # Ensure tag-specific socket paths win even if the caller has CMUX_* overrides. - "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open -g "$APP_PATH" + "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_ENABLE=1 CMUX_SOCKET_MODE=automation CMUX_SOCKET_PATH="$CMUX_SOCKET" CMUXD_UNIX_PATH="$CMUXD_SOCKET" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH" elif [[ -n "${TAG_SLUG:-}" ]]; then - "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" open -g "$APP_PATH" + "${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_SOCKET_ENABLE=1 CMUX_SOCKET_MODE=automation CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH" else + echo "/tmp/cmux-debug.sock" > /tmp/cmux-last-socket-path || true echo "/tmp/cmux-debug.log" > /tmp/cmux-last-debug-log-path || true "${OPEN_CLEAN_ENV[@]}" open -g "$APP_PATH" fi @@ -395,3 +500,16 @@ fi if [[ -n "${TAG_SLUG:-}" ]]; then print_tag_cleanup_reminder "$TAG_SLUG" fi + +if [[ -x "${CLI_PATH:-}" ]]; then + echo + echo "CLI path:" + echo " $CLI_PATH" + echo "CLI helpers:" + echo " /tmp/cmux-cli ..." + echo " $HOME/.local/bin/cmux-dev ..." + if [[ -n "${CMUX_SHIM_TARGET:-}" ]]; then + echo " $CMUX_SHIM_TARGET ..." + fi + echo "If your shell still resolves the old cmux, run: rehash" +fi diff --git a/tests/fixtures/ssh-remote/Dockerfile b/tests/fixtures/ssh-remote/Dockerfile new file mode 100644 index 00000000..470986d8 --- /dev/null +++ b/tests/fixtures/ssh-remote/Dockerfile @@ -0,0 +1,20 @@ +FROM alpine:3.20 + +RUN apk add --no-cache openssh python3 iproute2 net-tools ncurses + +RUN adduser -D -s /bin/sh dev \ + && mkdir -p /home/dev/.ssh /run/sshd /srv/www \ + && chown -R dev:dev /home/dev/.ssh \ + && chmod 700 /home/dev/.ssh \ + && echo "cmux-ssh-forward-ok" > /srv/www/index.html + +RUN ssh-keygen -A + +COPY sshd_config /etc/ssh/sshd_config +COPY run.sh /usr/local/bin/run.sh +COPY ws_echo.py /usr/local/bin/ws_echo.py +RUN chmod +x /usr/local/bin/run.sh + +EXPOSE 22 + +CMD ["/usr/local/bin/run.sh"] diff --git a/tests/fixtures/ssh-remote/run.sh b/tests/fixtures/ssh-remote/run.sh new file mode 100644 index 00000000..9089554f --- /dev/null +++ b/tests/fixtures/ssh-remote/run.sh @@ -0,0 +1,38 @@ +#!/bin/sh +set -eu + +if [ -z "${AUTHORIZED_KEY:-}" ]; then + echo "AUTHORIZED_KEY is required" >&2 + exit 1 +fi + +REMOTE_HTTP_PORT="${REMOTE_HTTP_PORT:-43173}" +REMOTE_WS_PORT="${REMOTE_WS_PORT:-43174}" + +mkdir -p /home/dev/.ssh /root/.ssh /run/sshd +printf '%s\n' "$AUTHORIZED_KEY" > /home/dev/.ssh/authorized_keys +printf '%s\n' "$AUTHORIZED_KEY" > /root/.ssh/authorized_keys +chown -R dev:dev /home/dev/.ssh +chmod 700 /home/dev/.ssh +chmod 600 /home/dev/.ssh/authorized_keys +chmod 700 /root/.ssh +chmod 600 /root/.ssh/authorized_keys + +python3 -m http.server "$REMOTE_HTTP_PORT" --bind 127.0.0.1 --directory /srv/www >/tmp/http.log 2>&1 & +HTTP_PID=$! +python3 /usr/local/bin/ws_echo.py --host 127.0.0.1 --port "$REMOTE_WS_PORT" >/tmp/ws.log 2>&1 & +WS_PID=$! + +sleep 0.2 +if ! kill -0 "$HTTP_PID" 2>/dev/null; then + echo "HTTP fixture failed to start (see /tmp/http.log)" >&2 + cat /tmp/http.log >&2 || true + exit 1 +fi +if ! kill -0 "$WS_PID" 2>/dev/null; then + echo "WebSocket fixture failed to start (see /tmp/ws.log)" >&2 + cat /tmp/ws.log >&2 || true + exit 1 +fi + +exec /usr/sbin/sshd -D -e diff --git a/tests/fixtures/ssh-remote/sshd_config b/tests/fixtures/ssh-remote/sshd_config new file mode 100644 index 00000000..9885b799 --- /dev/null +++ b/tests/fixtures/ssh-remote/sshd_config @@ -0,0 +1,31 @@ +Port 22 +Protocol 2 +AddressFamily any +ListenAddress 0.0.0.0 +ListenAddress :: + +HostKey /etc/ssh/ssh_host_rsa_key +HostKey /etc/ssh/ssh_host_ecdsa_key +HostKey /etc/ssh/ssh_host_ed25519_key + +PermitRootLogin yes +PubkeyAuthentication yes +PasswordAuthentication no +KbdInteractiveAuthentication no +ChallengeResponseAuthentication no +UsePAM no +AuthorizedKeysFile .ssh/authorized_keys +PermitEmptyPasswords no +AcceptEnv TERM_PROGRAM TERM_PROGRAM_VERSION COLORTERM + +X11Forwarding no +AllowTcpForwarding yes +AllowStreamLocalForwarding yes +StreamLocalBindUnlink yes +GatewayPorts no +PermitTunnel no +ClientAliveInterval 30 +ClientAliveCountMax 2 +PrintMotd no +PidFile /run/sshd.pid +Subsystem sftp /usr/lib/ssh/sftp-server diff --git a/tests/fixtures/ssh-remote/ws_echo.py b/tests/fixtures/ssh-remote/ws_echo.py new file mode 100644 index 00000000..ec857287 --- /dev/null +++ b/tests/fixtures/ssh-remote/ws_echo.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Tiny WebSocket echo server for SSH proxy integration tests.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import socket +import struct +import threading + + +GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + + +def _recv_exact(conn: socket.socket, n: int, pending: bytearray | None = None) -> bytes: + data = bytearray() + if pending: + take = min(len(pending), n) + if take: + data.extend(pending[:take]) + del pending[:take] + while len(data) < n: + chunk = conn.recv(n - len(data)) + if not chunk: + raise ConnectionError("unexpected EOF") + data.extend(chunk) + return bytes(data) + + +def _recv_until(conn: socket.socket, marker: bytes, limit: int = 8192) -> tuple[bytes, bytearray]: + data = bytearray() + while marker not in data: + chunk = conn.recv(1024) + if not chunk: + raise ConnectionError("unexpected EOF while reading headers") + data.extend(chunk) + if len(data) > limit: + raise ValueError("header too large") + marker_end = data.index(marker) + len(marker) + return bytes(data[:marker_end]), bytearray(data[marker_end:]) + + +def _read_frame(conn: socket.socket, pending: bytearray | None = None) -> tuple[int, bytes]: + first, second = _recv_exact(conn, 2, pending) + opcode = first & 0x0F + masked = (second & 0x80) != 0 + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", _recv_exact(conn, 2, pending))[0] + elif length == 127: + length = struct.unpack("!Q", _recv_exact(conn, 8, pending))[0] + + mask_key = _recv_exact(conn, 4, pending) if masked else b"" + payload = _recv_exact(conn, length, pending) if length else b"" + if masked and payload: + payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload)) + return opcode, payload + + +def _send_frame(conn: socket.socket, opcode: int, payload: bytes) -> None: + first = 0x80 | (opcode & 0x0F) + length = len(payload) + if length < 126: + header = bytes([first, length]) + elif length <= 0xFFFF: + header = bytes([first, 126]) + struct.pack("!H", length) + else: + header = bytes([first, 127]) + struct.pack("!Q", length) + conn.sendall(header + payload) + + +def handle_client(conn: socket.socket) -> None: + try: + request, pending = _recv_until(conn, b"\r\n\r\n") + headers_raw = request.decode("utf-8", errors="replace").split("\r\n") + header_map: dict[str, str] = {} + for line in headers_raw[1:]: + if not line or ":" not in line: + continue + k, v = line.split(":", 1) + header_map[k.strip().lower()] = v.strip() + + key = header_map.get("sec-websocket-key", "") + upgrade = header_map.get("upgrade", "").lower() + connection_hdr = header_map.get("connection", "").lower() + if not key or upgrade != "websocket" or "upgrade" not in connection_hdr: + conn.sendall(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n") + return + + accept = base64.b64encode(hashlib.sha1((key + GUID).encode("utf-8")).digest()).decode("ascii") + response = ( + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {accept}\r\n" + "\r\n" + ) + conn.sendall(response.encode("utf-8")) + + while True: + opcode, payload = _read_frame(conn, pending) + if opcode == 0x8: # close + _send_frame(conn, 0x8, b"") + return + if opcode == 0x9: # ping + _send_frame(conn, 0xA, payload) + continue + if opcode == 0x1: # text + _send_frame(conn, 0x1, payload) + continue + # ignore all other opcodes + finally: + try: + conn.close() + except Exception: + pass + + +def main() -> int: + parser = argparse.ArgumentParser(description="WebSocket echo server") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=43174) + args = parser.parse_args() + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind((args.host, args.port)) + server.listen(16) + while True: + conn, _ = server.accept() + thread = threading.Thread(target=handle_client, args=(conn,), daemon=True) + thread.start() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ci_self_hosted_guard.sh b/tests/test_ci_self_hosted_guard.sh index 3b4f7f65..c3a5281c 100755 --- a/tests/test_ci_self_hosted_guard.sh +++ b/tests/test_ci_self_hosted_guard.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Regression test for https://github.com/manaflow-ai/cmux/issues/385. -# Ensures Depot-hosted UI tests are never run for fork pull requests. +# Ensures paid/gated CI jobs are never run for fork pull requests. set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" @@ -9,21 +9,35 @@ WORKFLOW_FILE="$ROOT_DIR/.github/workflows/ci.yml" EXPECTED_IF="if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository" if ! grep -Fq "$EXPECTED_IF" "$WORKFLOW_FILE"; then - echo "FAIL: Missing fork pull_request guard for tests in $WORKFLOW_FILE" + echo "FAIL: Missing fork pull_request guard in $WORKFLOW_FILE" echo "Expected line:" echo " $EXPECTED_IF" exit 1 fi +# tests: must use WarpBuild runner with fork guard (paid runner) if ! awk ' - /^ tests-depot:/ { in_tests=1; next } + /^ tests:/ { in_tests=1; next } in_tests && /^ [^[:space:]]/ { in_tests=0 } - in_tests && /runs-on: depot-macos-latest/ { saw_depot=1 } + in_tests && /runs-on: warp-macos-15-arm64-6x/ { saw_warp=1 } in_tests && /github.event.pull_request.head.repo.full_name == github.repository/ { saw_guard=1 } - END { exit !(saw_depot && saw_guard) } + END { exit !(saw_warp && saw_guard) } ' "$WORKFLOW_FILE"; then - echo "FAIL: tests-depot block must keep both depot-macos-latest runner and fork guard" + echo "FAIL: tests block must keep both warp-macos-15-arm64-6x runner and fork guard" exit 1 fi -echo "PASS: tests-depot Depot runner fork guard is present" +# tests-build-and-lag: must use WarpBuild runner with fork guard (paid runner) +if ! awk ' + /^ tests-build-and-lag:/ { in_tests=1; next } + in_tests && /^ [^[:space:]]/ { in_tests=0 } + in_tests && /runs-on: warp-macos-15-arm64-6x/ { saw_warp=1 } + in_tests && /github.event.pull_request.head.repo.full_name == github.repository/ { saw_guard=1 } + END { exit !(saw_warp && saw_guard) } +' "$WORKFLOW_FILE"; then + echo "FAIL: tests-build-and-lag block must keep both warp-macos-15-arm64-6x runner and fork guard" + exit 1 +fi + +echo "PASS: tests WarpBuild runner fork guard is present" +echo "PASS: tests-build-and-lag WarpBuild runner fork guard is present" diff --git a/tests/test_cli_version_flag.py b/tests/test_cli_version_flag.py index b48419f2..00499ce0 100644 --- a/tests/test_cli_version_flag.py +++ b/tests/test_cli_version_flag.py @@ -32,13 +32,17 @@ def resolve_cmux_cli() -> str: raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.") -def run(cli_path: str, *args: str) -> tuple[int, str, str]: - proc = subprocess.run( - [cli_path, *args], - text=True, - capture_output=True, - check=False, - ) +def run(cli_path: str, *args: str, timeout: float = 5.0) -> tuple[int, str, str]: + try: + proc = subprocess.run( + [cli_path, *args], + text=True, + capture_output=True, + check=False, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return 124, "", f"timed out after {timeout:.1f}s" return proc.returncode, proc.stdout.strip(), proc.stderr.strip() diff --git a/tests/test_cli_version_memory_guard.py b/tests/test_cli_version_memory_guard.py index 6252ea5e..46d1497e 100644 --- a/tests/test_cli_version_memory_guard.py +++ b/tests/test_cli_version_memory_guard.py @@ -9,6 +9,7 @@ from __future__ import annotations import glob import os import plistlib +import re import shutil import subprocess import tempfile @@ -96,7 +97,7 @@ def run_with_limits(cli_path: str, *args: str) -> dict[str, object]: env.pop("CMUX_COMMIT", None) proc = subprocess.Popen( - [cli_path, *args], + ["/usr/bin/time", "-l", cli_path, *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -104,54 +105,42 @@ def run_with_limits(cli_path: str, *args: str) -> dict[str, object]: ) started = time.time() - peak_rss_kb = 0 - failure_reason: str | None = None - - while True: - exit_code = proc.poll() - if exit_code is not None: - stdout, stderr = proc.communicate() - return { - "exit_code": exit_code, - "stdout": stdout.strip(), - "stderr": stderr.strip(), - "elapsed": time.time() - started, - "peak_rss_kb": peak_rss_kb, - "failure_reason": None, - } - - try: - rss_kb = int( - subprocess.check_output( - ["ps", "-o", "rss=", "-p", str(proc.pid)], - text=True, - ).strip() - or "0" - ) - except subprocess.CalledProcessError: - rss_kb = 0 - - peak_rss_kb = max(peak_rss_kb, rss_kb) + try: + stdout, stderr = proc.communicate(timeout=TIMEOUT_SECONDS) + except subprocess.TimeoutExpired: + proc.kill() + stdout, stderr = proc.communicate() elapsed = time.time() - started + return { + "exit_code": proc.returncode, + "stdout": stdout.strip(), + "stderr": stderr.strip(), + "elapsed": elapsed, + "peak_rss_kb": 0, + "failure_reason": f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)", + } - if rss_kb > RSS_LIMIT_KB: - failure_reason = f"rss limit exceeded ({rss_kb} KB > {RSS_LIMIT_KB} KB)" - elif elapsed > TIMEOUT_SECONDS: - failure_reason = f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)" + elapsed = time.time() - started + peak_rss_kb = 0 + rss_match = re.search(r"(\d+)\s+maximum resident set size", stderr) + if rss_match: + peak_rss_raw = int(rss_match.group(1)) + peak_rss_kb = peak_rss_raw if peak_rss_raw <= RSS_LIMIT_KB * 16 else peak_rss_raw // 1024 - if failure_reason: - proc.kill() - stdout, stderr = proc.communicate() - return { - "exit_code": proc.returncode, - "stdout": stdout.strip(), - "stderr": stderr.strip(), - "elapsed": elapsed, - "peak_rss_kb": peak_rss_kb, - "failure_reason": failure_reason, - } + failure_reason: str | None = None + if peak_rss_kb > RSS_LIMIT_KB: + failure_reason = f"rss limit exceeded ({peak_rss_kb} KB > {RSS_LIMIT_KB} KB)" + elif elapsed > TIMEOUT_SECONDS: + failure_reason = f"timeout exceeded ({elapsed:.2f}s > {TIMEOUT_SECONDS:.2f}s)" - time.sleep(0.05) + return { + "exit_code": proc.returncode, + "stdout": stdout.strip(), + "stderr": stderr.strip(), + "elapsed": elapsed, + "peak_rss_kb": peak_rss_kb, + "failure_reason": failure_reason, + } def main() -> int: diff --git a/tests/test_remote_daemon_release_assets.sh b/tests/test_remote_daemon_release_assets.sh new file mode 100755 index 00000000..8495d835 --- /dev/null +++ b/tests/test_remote_daemon_release_assets.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +OUTPUT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-remote-assets-test.XXXXXX")" +trap 'rm -rf "$OUTPUT_DIR"' EXIT + +"$ROOT_DIR/scripts/build_remote_daemon_release_assets.sh" \ + --version "0.62.0-test" \ + --release-tag "v0.62.0-test" \ + --repo "manaflow-ai/cmux" \ + --output-dir "$OUTPUT_DIR" >/dev/null + +for asset in \ + cmuxd-remote-darwin-arm64 \ + cmuxd-remote-darwin-amd64 \ + cmuxd-remote-linux-arm64 \ + cmuxd-remote-linux-amd64 \ + cmuxd-remote-checksums.txt \ + cmuxd-remote-manifest.json +do + if [[ ! -f "$OUTPUT_DIR/$asset" ]]; then + echo "FAIL: missing asset $asset" >&2 + exit 1 + fi +done + +python3 - <<'PY' "$OUTPUT_DIR/cmuxd-remote-manifest.json" "$OUTPUT_DIR/cmuxd-remote-checksums.txt" +import json +import sys +from pathlib import Path + +manifest_path = Path(sys.argv[1]) +checksums_path = Path(sys.argv[2]) +manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + +expected_targets = { + ("darwin", "arm64"), + ("darwin", "amd64"), + ("linux", "arm64"), + ("linux", "amd64"), +} +actual_targets = {(entry["goOS"], entry["goArch"]) for entry in manifest["entries"]} +if actual_targets != expected_targets: + raise SystemExit(f"FAIL: manifest targets {sorted(actual_targets)} != {sorted(expected_targets)}") + +if manifest["appVersion"] != "0.62.0-test": + raise SystemExit(f"FAIL: unexpected appVersion {manifest['appVersion']}") +if manifest["releaseTag"] != "v0.62.0-test": + raise SystemExit(f"FAIL: unexpected releaseTag {manifest['releaseTag']}") +if not manifest["checksumsURL"].endswith("/cmuxd-remote-checksums.txt"): + raise SystemExit(f"FAIL: unexpected checksumsURL {manifest['checksumsURL']}") + +checksum_lines = [line for line in checksums_path.read_text(encoding="utf-8").splitlines() if line.strip()] +if len(checksum_lines) != 4: + raise SystemExit(f"FAIL: expected 4 checksum lines, got {len(checksum_lines)}") + +for entry in manifest["entries"]: + if not entry["downloadURL"].endswith("/" + entry["assetName"]): + raise SystemExit(f"FAIL: downloadURL mismatch for {entry['assetName']}") + if len(entry["sha256"]) != 64: + raise SystemExit(f"FAIL: invalid sha256 for {entry['assetName']}") + +print("PASS: remote daemon release assets include all targets and manifest entries") +PY diff --git a/tests/test_sidebar_copy_ssh_error_context_menu.py b/tests/test_sidebar_copy_ssh_error_context_menu.py new file mode 100644 index 00000000..52b3a6f3 --- /dev/null +++ b/tests/test_sidebar_copy_ssh_error_context_menu.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Regression test: sidebar context menu shows Copy SSH Error only when an SSH error exists.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + content_view_path = repo_root / "Sources" / "ContentView.swift" + if not content_view_path.exists(): + print(f"FAIL: missing expected file: {content_view_path}") + return 1 + + content = content_view_path.read_text(encoding="utf-8") + failures: list[str] = [] + + require( + content, + "private var copyableSidebarSSHError: String?", + "Missing sidebar SSH error extraction helper", + failures, + ) + require( + content, + 'tab.statusEntries["remote.error"]?.value', + "Missing remote.error status fallback for copyable SSH error text", + failures, + ) + require( + content, + "if let copyableSidebarSSHError {", + "Copy SSH Error menu entry is no longer conditionally gated", + failures, + ) + require( + content, + 'Button("Copy SSH Error")', + "Missing Copy SSH Error context menu button", + failures, + ) + require( + content, + "copyTextToPasteboard(copyableSidebarSSHError)", + "Copy SSH Error button no longer writes the resolved error text", + failures, + ) + + if failures: + print("FAIL: sidebar copy SSH error context-menu regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: sidebar Copy SSH Error context menu wiring is intact") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/pane_resize_test_support.py b/tests_v2/pane_resize_test_support.py new file mode 100644 index 00000000..4b55bbde --- /dev/null +++ b/tests_v2/pane_resize_test_support.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import re +import secrets +import time + +from cmux import cmux, cmuxError + + +ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") + + +def must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def clean_line(raw: str) -> str: + line = OSC_ESCAPE_RE.sub("", raw) + line = ANSI_ESCAPE_RE.sub("", line) + line = line.replace("\r", "") + return line.strip() + + +def layout_panes(client: cmux) -> list[dict]: + layout_payload = client.layout_debug() or {} + layout = layout_payload.get("layout") or {} + return list(layout.get("panes") or []) + + +def pane_extent(client: cmux, pane_id: str, axis: str) -> float: + panes = layout_panes(client) + for pane in panes: + pid = str(pane.get("paneId") or pane.get("pane_id") or "") + if pid != pane_id: + continue + frame = pane.get("frame") or {} + return float(frame.get(axis) or 0.0) + raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}") + + +def workspace_panes(client: cmux, workspace_id: str) -> list[tuple[str, bool, int]]: + payload = client._call("pane.list", {"workspace_id": workspace_id}) or {} + out: list[tuple[str, bool, int]] = [] + for row in payload.get("panes") or []: + out.append(( + str(row.get("id") or ""), + bool(row.get("focused")), + int(row.get("surface_count") or 0), + )) + return out + + +def focused_pane_id(client: cmux, workspace_id: str) -> str: + for pane_id, focused, _surface_count in workspace_panes(client, workspace_id): + if focused: + return pane_id + raise cmuxError("No focused pane found") + + +def surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: + payload = client._call( + "surface.read_text", + {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, + ) or {} + return str(payload.get("text") or "") + + +def surface_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]: + text = surface_scrollback_text(client, workspace_id, surface_id) + return [clean_line(raw) for raw in text.splitlines()] + + +def scrollback_has_exact_line(client: cmux, workspace_id: str, surface_id: str, token: str) -> bool: + return token in surface_scrollback_lines(client, workspace_id, surface_id) + + +def wait_for_surface_command_roundtrip(client: cmux, workspace_id: str, surface_id: str) -> None: + for _attempt in range(1, 5): + token = f"CMUX_READY_{secrets.token_hex(4)}" + client.send_surface(surface_id, f"echo {token}\n") + try: + wait_for( + lambda: scrollback_has_exact_line(client, workspace_id, surface_id, token), + timeout_s=2.5, + ) + return + except cmuxError: + time.sleep(0.1) + raise cmuxError("Timed out waiting for surface command roundtrip") + + +def pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]: + panes = [p for p in layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids] + if len(panes) < 2: + raise cmuxError(f"Need >=2 panes for resize test, got {panes}") + + def x_of(p: dict) -> float: + return float((p.get("frame") or {}).get("x") or 0.0) + + def y_of(p: dict) -> float: + return float((p.get("frame") or {}).get("y") or 0.0) + + x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes) + y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes) + + if x_span >= y_span: + left_pane = min(panes, key=x_of) + left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "") + return ("right" if target_pane == left_id else "left"), "width" + + top_pane = min(panes, key=y_of) + top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "") + return ("down" if target_pane == top_id else "up"), "height" diff --git a/tests_v2/test_cli_browser_console_errors_text.py b/tests_v2/test_cli_browser_console_errors_text.py new file mode 100644 index 00000000..96586165 --- /dev/null +++ b/tests_v2/test_cli_browser_console_errors_text.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Regression: CLI browser console/errors commands should print entries in text mode.""" + +from __future__ import annotations + +import glob +import http.server +import os +import socketserver +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, args: list[str]) -> str: + proc = subprocess.run( + [cli, "--socket", SOCKET_PATH, *args], + capture_output=True, + text=True, + check=False, + env=dict(os.environ), + ) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(args)}): {merged}") + return proc.stdout.strip() + + +def _wait_for(pred, timeout_s: float = 6.0, step_s: float = 0.05) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _wait_selector(c: cmux, surface_id: str, selector: str, timeout_s: float = 6.0) -> None: + timeout_ms = max(1, int(timeout_s * 1000.0)) + c._call("browser.wait", {"surface_id": surface_id, "selector": selector, "timeout_ms": timeout_ms}) + + +def _open_server() -> tuple[str, socketserver.TCPServer, threading.Thread, tempfile.TemporaryDirectory[str]]: + root = tempfile.TemporaryDirectory(prefix="cmux-browser-cli-logs-") + root_path = Path(root.name) + (root_path / "index.html").write_text( + """<!doctype html> +<html> + <body> + <div id="ready">ready</div> + <script> + window.emitLogs = function () { + console.log('cmux-console-entry'); + setTimeout(function () { throw new Error('cmux-browser-boom'); }, 0); + return true; + }; + </script> + </body> +</html> +""".strip(), + encoding="utf-8", + ) + + class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=root.name, **kwargs) + + def log_message(self, format: str, *args) -> None: # noqa: A003 + return + + class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): + allow_reuse_address = True + daemon_threads = True + + server = ThreadedTCPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + return base_url, server, thread, root + + +def main() -> int: + cli = _find_cli_binary() + base_url, server, thread, root = _open_server() + workspace_id = "" + try: + with cmux(SOCKET_PATH) as c: + opened = c._call("browser.open_split", {"url": f"{base_url}/index.html"}) or {} + workspace_id = str(opened.get("workspace_id") or "") + surface_id = str(opened.get("surface_id") or "") + _must(bool(surface_id), f"browser.open_split returned no surface_id: {opened}") + + _wait_selector(c, surface_id, "#ready", timeout_s=7.0) + c._call("browser.eval", {"surface_id": surface_id, "script": "window.emitLogs()"}) + + def console_ready() -> bool: + payload = c._call("browser.console.list", {"surface_id": surface_id}) or {} + return int(payload.get("count") or 0) >= 1 + + def errors_ready() -> bool: + payload = c._call("browser.errors.list", {"surface_id": surface_id}) or {} + return int(payload.get("count") or 0) >= 1 + + _wait_for(console_ready, timeout_s=7.0) + _wait_for(errors_ready, timeout_s=7.0) + + console_output = _run_cli(cli, ["browser", surface_id, "console"]) + _must("cmux-console-entry" in console_output, f"browser console text mode should print entries: {console_output!r}") + _must(console_output != "OK", f"browser console text mode should not collapse to OK: {console_output!r}") + + errors_output = _run_cli(cli, ["browser", surface_id, "errors"]) + _must("cmux-browser-boom" in errors_output, f"browser errors text mode should print entries: {errors_output!r}") + _must(errors_output != "OK", f"browser errors text mode should not collapse to OK: {errors_output!r}") + finally: + try: + server.shutdown() + server.server_close() + thread.join(timeout=1.0) + except Exception: + pass + root.cleanup() + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + print("PASS: browser console/errors text mode prints returned entries") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_cli_global_flags_and_v1_error_contract.py b/tests_v2/test_cli_global_flags_and_v1_error_contract.py new file mode 100644 index 00000000..badc306a --- /dev/null +++ b/tests_v2/test_cli_global_flags_and_v1_error_contract.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Regression: global CLI flags still parse and v1 ERROR responses fail with non-zero exit.""" + +from __future__ import annotations + +import glob +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +LAST_SOCKET_HINT_PATH = Path("/tmp/cmux-last-socket-path") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(cmd: list[str], env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) + + +def _merged_output(proc: subprocess.CompletedProcess[str]) -> str: + return f"{proc.stdout}\n{proc.stderr}".strip() + + +def main() -> int: + cli = _find_cli_binary() + + # Global --version should be handled before socket command dispatch. + version_proc = _run([cli, "--version"]) + version_out = _merged_output(version_proc).lower() + _must(version_proc.returncode == 0, f"--version should succeed: {version_proc.returncode} {version_out!r}") + _must("cmux" in version_out, f"--version output should mention cmux: {version_out!r}") + + # Debug builds should auto-resolve the active debug socket via /tmp/cmux-last-socket-path + # when CMUX_SOCKET_PATH is not set. + hint_backup: str | None = None + hint_had_file = LAST_SOCKET_HINT_PATH.exists() + if hint_had_file: + hint_backup = LAST_SOCKET_HINT_PATH.read_text(encoding="utf-8") + try: + LAST_SOCKET_HINT_PATH.write_text(f"{SOCKET_PATH}\n", encoding="utf-8") + auto_env = dict(os.environ) + auto_env.pop("CMUX_SOCKET_PATH", None) + auto_env.pop("CMUX_SOCKET", None) + auto_ping = _run([cli, "ping"], env=auto_env) + auto_ping_out = _merged_output(auto_ping).lower() + _must(auto_ping.returncode == 0, f"debug auto socket resolution should succeed: {auto_ping.returncode} {auto_ping_out!r}") + _must("pong" in auto_ping_out, f"debug auto socket resolution should return pong: {auto_ping_out!r}") + finally: + try: + if hint_had_file: + LAST_SOCKET_HINT_PATH.write_text(hint_backup or "", encoding="utf-8") + else: + LAST_SOCKET_HINT_PATH.unlink(missing_ok=True) + except OSError: + pass + + # Global --password should parse as a flag (not a command name) and still allow non-password sockets. + ping_proc = _run([cli, "--socket", SOCKET_PATH, "--password", "ignored-in-cmuxonly", "ping"]) + ping_out = _merged_output(ping_proc).lower() + _must(ping_proc.returncode == 0, f"ping with --password should succeed: {ping_proc.returncode} {ping_out!r}") + _must("pong" in ping_out, f"ping should still return pong: {ping_out!r}") + + # V1 errors must produce non-zero exit codes for automation correctness. + bad_focus = _run([cli, "--socket", SOCKET_PATH, "focus-window", "--window", "window:999999"]) + bad_out = _merged_output(bad_focus).lower() + _must(bad_focus.returncode != 0, f"focus-window with invalid target should fail non-zero: {bad_out!r}") + _must("error" in bad_out, f"focus-window failure should surface an error: {bad_out!r}") + + print("PASS: global flags parse correctly and v1 ERROR responses fail the CLI process") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_cli_sidebar_metadata_commands.py b/tests_v2/test_cli_sidebar_metadata_commands.py new file mode 100644 index 00000000..7b3b69af --- /dev/null +++ b/tests_v2/test_cli_sidebar_metadata_commands.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Regression: sidebar metadata CLI commands still dispatch through the public cmux CLI.""" + +from __future__ import annotations + +import glob +import os +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, args: list[str], *, extra_env: dict[str, str] | None = None) -> str: + env = dict(os.environ) + if extra_env: + env.update(extra_env) + proc = subprocess.run( + [cli, "--socket", SOCKET_PATH, *args], + capture_output=True, + text=True, + check=False, + env=env, + ) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(args)}): {merged}") + return proc.stdout.strip() + + +def main() -> int: + cli = _find_cli_binary() + workspace_id = "" + + try: + with cmux(SOCKET_PATH) as client: + workspace_id = client.new_workspace() + + status_response = _run_cli(cli, ["set-status", "build", "compiling", "--workspace", workspace_id]) + _must(status_response.startswith("OK"), f"set-status should succeed, got {status_response!r}") + + status_list = _run_cli(cli, ["list-status", "--workspace", workspace_id]) + _must("build=compiling" in status_list, f"list-status should include the inserted status entry: {status_list!r}") + + progress_response = _run_cli(cli, ["set-progress", "0.5", "--workspace", workspace_id, "--label", "Building"]) + _must(progress_response.startswith("OK"), f"set-progress should succeed, got {progress_response!r}") + + log_response = _run_cli(cli, ["log", "--workspace", workspace_id, "--", "ship it"]) + _must(log_response.startswith("OK"), f"log should succeed, got {log_response!r}") + + env_log_response = _run_cli( + cli, + ["log", "--", "env scoped log"], + extra_env={"CMUX_WORKSPACE_ID": workspace_id}, + ) + _must(env_log_response.startswith("OK"), f"log with env workspace should succeed, got {env_log_response!r}") + + log_list = _run_cli(cli, ["list-log", "--workspace", workspace_id, "--limit", "5"]) + _must("ship it" in log_list, f"list-log should include the appended log entry: {log_list!r}") + _must("env scoped log" in log_list, f"list-log should include env-routed log entry: {log_list!r}") + + sidebar_state = _run_cli(cli, ["sidebar-state", "--workspace", workspace_id]) + _must("status_count=1" in sidebar_state, f"sidebar-state should include the status entry count: {sidebar_state!r}") + _must("progress=0.50 Building" in sidebar_state, f"sidebar-state should include the progress label: {sidebar_state!r}") + _must("[info] ship it" in sidebar_state, f"sidebar-state should include the recent log entry: {sidebar_state!r}") + + clear_status_response = _run_cli(cli, ["clear-status", "build", "--workspace", workspace_id]) + _must(clear_status_response.startswith("OK"), f"clear-status should succeed, got {clear_status_response!r}") + + clear_progress_response = _run_cli(cli, ["clear-progress", "--workspace", workspace_id]) + _must(clear_progress_response.startswith("OK"), f"clear-progress should succeed, got {clear_progress_response!r}") + + clear_log_response = _run_cli(cli, ["clear-log", "--workspace", workspace_id]) + _must(clear_log_response.startswith("OK"), f"clear-log should succeed, got {clear_log_response!r}") + + cleared_sidebar_state = _run_cli(cli, ["sidebar-state", "--workspace", workspace_id]) + _must("status_count=0" in cleared_sidebar_state, f"sidebar-state should clear status entries: {cleared_sidebar_state!r}") + _must("progress=none" in cleared_sidebar_state, f"sidebar-state should clear progress: {cleared_sidebar_state!r}") + _must("log_count=0" in cleared_sidebar_state, f"sidebar-state should clear log entries: {cleared_sidebar_state!r}") + + client.close_workspace(workspace_id) + workspace_id = "" + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + print("PASS: sidebar metadata CLI commands dispatch and update workspace state") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_pane_break_swap_preserve_focus.py b/tests_v2/test_pane_break_swap_preserve_focus.py new file mode 100644 index 00000000..c9cfe722 --- /dev/null +++ b/tests_v2/test_pane_break_swap_preserve_focus.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Regression: pane.swap and pane.break should not steal visible focus.""" + +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _focused_pane_id(client: cmux, workspace_id: str) -> str: + payload = client._call("pane.list", {"workspace_id": workspace_id}) or {} + for row in payload.get("panes") or []: + if bool(row.get("focused")): + return str(row.get("id") or "") + return "" + + +def main() -> int: + created_workspaces: list[str] = [] + + try: + with cmux(SOCKET_PATH) as client: + workspace_id = client.new_workspace() + created_workspaces.append(workspace_id) + client.select_workspace(workspace_id) + time.sleep(0.2) + + _ = client.new_split("right") + time.sleep(0.2) + + panes_payload = client._call("pane.list", {"workspace_id": workspace_id}) or {} + panes = panes_payload.get("panes") or [] + _must(len(panes) == 2, f"expected two panes after split: {panes_payload}") + + focused_row = next((row for row in panes if bool(row.get("focused"))), None) + _must(focused_row is not None, f"expected focused pane after split: {panes_payload}") + focused_pane_id = str(focused_row.get("id") or "") + other_row = next((row for row in panes if str(row.get("id") or "") != focused_pane_id), None) + _must(other_row is not None, f"expected non-focused pane after split: {panes_payload}") + other_pane_id = str(other_row.get("id") or "") + + client.focus_pane(other_pane_id) + time.sleep(0.2) + _must( + _focused_pane_id(client, workspace_id) == other_pane_id, + "expected explicit pane focus before pane.swap regression check", + ) + + client._call("pane.swap", {"pane_id": other_pane_id, "target_pane_id": focused_pane_id}) + time.sleep(0.2) + _must( + _focused_pane_id(client, workspace_id) == other_pane_id, + "pane.swap should preserve the currently focused pane when invoked over the socket", + ) + _must( + client.current_workspace() == workspace_id, + "pane.swap should not change the selected workspace", + ) + + broken_payload = client._call("pane.break", {"pane_id": other_pane_id}) or {} + broken_workspace_id = str(broken_payload.get("workspace_id") or "") + _must(bool(broken_workspace_id), f"pane.break returned no workspace_id: {broken_payload}") + created_workspaces.append(broken_workspace_id) + time.sleep(0.2) + + _must( + client.current_workspace() == workspace_id, + "pane.break should preserve the selected workspace when invoked over the socket", + ) + finally: + with cmux(SOCKET_PATH) as cleanup_client: + for workspace_id in reversed(created_workspaces): + try: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + print("PASS: pane.swap and pane.break preserve visible focus for socket callers") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_pane_resize_preserves_ls_scrollback.py b/tests_v2/test_pane_resize_preserves_ls_scrollback.py new file mode 100644 index 00000000..88c7511d --- /dev/null +++ b/tests_v2/test_pane_resize_preserves_ls_scrollback.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Regression: `ls` output remains in scrollback after pane.resize.""" + +from __future__ import annotations + +import os +import secrets +import shlex +import shutil +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError +from pane_resize_test_support import ( + clean_line as _clean_line, + focused_pane_id as _focused_pane_id, + pane_extent as _pane_extent, + pick_resize_direction_for_pane as _pick_resize_direction_for_pane, + scrollback_has_exact_line as _scrollback_has_exact_line, + surface_scrollback_text as _surface_scrollback_text, + wait_for as _wait_for, + wait_for_surface_command_roundtrip as _wait_for_surface_command_roundtrip, + workspace_panes as _workspace_panes, +) + + +DEFAULT_SOCKET_PATHS = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"] + + +def _has_exact_marker_lines( + client: cmux, + workspace_id: str, + surface_id: str, + start_marker: str, + end_marker: str, +) -> bool: + text = _surface_scrollback_text(client, workspace_id, surface_id) + lines = [_clean_line(raw) for raw in text.splitlines()] + return start_marker in lines and end_marker in lines + + +def _extract_segment_lines( + text: str, + start_marker: str, + end_marker: str, + *, + require_end: bool = True, +) -> list[str]: + lines = text.splitlines() + saw_start = False + saw_end = False + out: list[str] = [] + for raw in lines: + line = _clean_line(raw) + if not saw_start: + if line == start_marker: + saw_start = True + continue + if line == end_marker: + saw_end = True + break + if line: + out.append(line) + + if not saw_start: + raise cmuxError(f"start marker not found in scrollback: {start_marker}") + if require_end and not saw_end: + raise cmuxError(f"end marker not found in scrollback: {end_marker}") + return out + + +def _run_once(socket_path: str) -> int: + workspace_id = "" + fixture_dir = Path(tempfile.mkdtemp(prefix="cmux-ls-resize-regression-")) + try: + with cmux(socket_path) as client: + workspace_id = client.new_workspace() + client.select_workspace(workspace_id) + + surfaces = client.list_surfaces(workspace_id) + _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") + surface_id = surfaces[0][1] + _wait_for_surface_command_roundtrip(client, workspace_id, surface_id) + + expected_names = [f"entry-{index:04d}.txt" for index in range(1, 241)] + for name in expected_names: + (fixture_dir / name).write_text(name + "\n", encoding="utf-8") + + start_marker = f"CMUX_LS_SCROLLBACK_START_{secrets.token_hex(4)}" + end_marker = f"CMUX_LS_SCROLLBACK_END_{secrets.token_hex(4)}" + fixture_arg = shlex.quote(str(fixture_dir)) + run_ls = ( + f"cd {fixture_arg}; " + f"echo {start_marker}; " + f"LC_ALL=C CLICOLOR=0 ls -1; " + f"echo {end_marker}" + ) + client.send_surface(surface_id, run_ls + "\n") + _wait_for( + lambda: _has_exact_marker_lines(client, workspace_id, surface_id, start_marker, end_marker), + timeout_s=12.0, + ) + + pre_resize_scrollback = _surface_scrollback_text(client, workspace_id, surface_id) + pre_lines = _extract_segment_lines(pre_resize_scrollback, start_marker, end_marker) + expected_set = set(expected_names) + pre_found = [line for line in pre_lines if line in expected_set] + _must( + len(set(pre_found)) == len(expected_set), + f"pre-resize ls output incomplete: found={len(set(pre_found))} expected={len(expected_set)}", + ) + + split_payload = client._call( + "surface.split", + {"workspace_id": workspace_id, "surface_id": surface_id, "direction": "right"}, + ) or {} + _must(bool(split_payload.get("surface_id")), f"surface.split returned no surface_id: {split_payload}") + _wait_for(lambda: len(_workspace_panes(client, workspace_id)) >= 2, timeout_s=4.0) + + client.focus_surface(surface_id) + time.sleep(0.1) + panes = _workspace_panes(client, workspace_id) + pane_ids = [pid for pid, _focused, _surface_count in panes] + pane_id = _focused_pane_id(client, workspace_id) + resize_direction, resize_axis = _pick_resize_direction_for_pane(client, pane_ids, pane_id) + pre_extent = _pane_extent(client, pane_id, resize_axis) + + resize_result = client._call( + "pane.resize", + { + "workspace_id": workspace_id, + "pane_id": pane_id, + "direction": resize_direction, + "amount": 120, + }, + ) or {} + _must( + str(resize_result.get("pane_id") or "") == pane_id, + f"pane.resize response missing expected pane_id: {resize_result}", + ) + _wait_for(lambda: _pane_extent(client, pane_id, resize_axis) > pre_extent + 1.0, timeout_s=6.0) + + post_resize_scrollback = _surface_scrollback_text(client, workspace_id, surface_id) + # Prompt redraw after resize may repaint over trailing marker rows. + # The regression condition is loss of ls output entries. + post_lines = _extract_segment_lines( + post_resize_scrollback, + start_marker, + end_marker, + require_end=False, + ) + post_found = [line for line in post_lines if line in expected_set] + _must( + len(set(post_found)) == len(expected_set), + "post-resize ls output lost entries from scrollback", + ) + + client.close_workspace(workspace_id) + workspace_id = "" + + print("PASS: ls output remains fully present in scrollback after pane.resize") + return 0 + finally: + if workspace_id: + try: + with cmux(socket_path) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + shutil.rmtree(fixture_dir, ignore_errors=True) + + +def main() -> int: + env_socket = os.environ.get("CMUX_SOCKET") + if env_socket: + return _run_once(env_socket) + + last_error: Exception | None = None + for socket_path in DEFAULT_SOCKET_PATHS: + try: + return _run_once(socket_path) + except cmuxError as exc: + text = str(exc) + recoverable = ( + "Failed to connect", + "Socket not found", + ) + if not any(token in text for token in recoverable): + raise + last_error = exc + continue + + if last_error is not None: + raise last_error + raise cmuxError("No socket candidates configured") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_pane_resize_preserves_visible_content.py b/tests_v2/test_pane_resize_preserves_visible_content.py new file mode 100644 index 00000000..a249679b --- /dev/null +++ b/tests_v2/test_pane_resize_preserves_visible_content.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Regression: pane.resize preserves terminal content drawn before resize.""" + +from __future__ import annotations + +import os +import secrets +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError +from pane_resize_test_support import ( + focused_pane_id as _focused_pane_id, + pane_extent as _pane_extent, + pick_resize_direction_for_pane as _pick_resize_direction_for_pane, + scrollback_has_exact_line as _scrollback_has_exact_line, + surface_scrollback_lines as _surface_scrollback_lines, + wait_for as _wait_for, + wait_for_surface_command_roundtrip as _wait_for_surface_command_roundtrip, + workspace_panes as _workspace_panes, + must as _must, +) + + +DEFAULT_SOCKET_PATHS = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"] + + +def _run_once(socket_path: str) -> int: + workspace_id = "" + try: + with cmux(socket_path) as client: + workspace_id = client.new_workspace() + client.select_workspace(workspace_id) + + surfaces = client.list_surfaces(workspace_id) + _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") + surface_id = surfaces[0][1] + _wait_for_surface_command_roundtrip(client, workspace_id, surface_id) + + stamp = secrets.token_hex(4) + resize_lines = [f"CMUX_LOCAL_RESIZE_LINE_{stamp}_{index:02d}" for index in range(1, 33)] + clear_and_draw = ( + "clear; " + f"for i in $(seq 1 {len(resize_lines)}); do " + "n=$(printf '%02d' \"$i\"); " + f"echo CMUX_LOCAL_RESIZE_LINE_{stamp}_$n; " + "done" + ) + client.send_surface(surface_id, f"{clear_and_draw}\n") + _wait_for(lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, resize_lines[-1]), timeout_s=8.0) + pre_resize_scrollback_lines = _surface_scrollback_lines(client, workspace_id, surface_id) + pre_resize_anchors = [line for line in (resize_lines[0], resize_lines[-1]) if line in pre_resize_scrollback_lines] + _must( + len(pre_resize_anchors) == 2, + f"pre-resize scrollback missing anchor lines: anchors={pre_resize_anchors}", + ) + + pre_resize_visible = client.read_terminal_text(surface_id) + pre_visible_lines = [line for line in resize_lines if line in pre_resize_visible] + _must( + len(pre_visible_lines) >= 4, + f"pre-resize viewport did not contain enough lines: {pre_visible_lines}", + ) + + split_payload = client._call( + "surface.split", + {"workspace_id": workspace_id, "surface_id": surface_id, "direction": "right"}, + ) or {} + _must(bool(split_payload.get("surface_id")), f"surface.split returned no surface_id: {split_payload}") + _wait_for(lambda: len(_workspace_panes(client, workspace_id)) >= 2, timeout_s=4.0) + + client.focus_surface(surface_id) + time.sleep(0.1) + panes = _workspace_panes(client, workspace_id) + pane_ids = [pid for pid, _focused, _surface_count in panes] + pane_id = _focused_pane_id(client, workspace_id) + resize_direction, resize_axis = _pick_resize_direction_for_pane(client, pane_ids, pane_id) + pre_extent = _pane_extent(client, pane_id, resize_axis) + + resize_result = client._call( + "pane.resize", + { + "workspace_id": workspace_id, + "pane_id": pane_id, + "direction": resize_direction, + "amount": 80, + }, + ) or {} + _must( + str(resize_result.get("pane_id") or "") == pane_id, + f"pane.resize response missing expected pane_id: {resize_result}", + ) + _wait_for(lambda: _pane_extent(client, pane_id, resize_axis) > pre_extent + 1.0, timeout_s=5.0) + + post_resize_visible = client.read_terminal_text(surface_id) + visible_overlap = [line for line in pre_visible_lines if line in post_resize_visible] + _must( + bool(visible_overlap), + f"resize lost all pre-resize visible lines from viewport: {pre_visible_lines}", + ) + + post_token = f"CMUX_LOCAL_RESIZE_POST_{stamp}" + client.send_surface(surface_id, f"echo {post_token}\n") + _wait_for(lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, post_token), timeout_s=8.0) + + scrollback_lines = _surface_scrollback_lines(client, workspace_id, surface_id) + _must( + all(anchor in scrollback_lines for anchor in pre_resize_anchors), + "terminal scrollback lost pre-resize lines after pane resize", + ) + _must( + post_token in scrollback_lines, + "terminal scrollback missing post-resize token after pane resize", + ) + + client.close_workspace(workspace_id) + workspace_id = "" + + print("PASS: pane.resize preserves pre-resize visible content and scrollback anchors") + return 0 + finally: + if workspace_id: + try: + with cmux(socket_path) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + +def main() -> int: + env_socket = os.environ.get("CMUX_SOCKET") + if env_socket: + return _run_once(env_socket) + + last_error: Exception | None = None + for socket_path in DEFAULT_SOCKET_PATHS: + try: + return _run_once(socket_path) + except cmuxError as exc: + text = str(exc) + recoverable = ( + "Failed to connect", + "Socket not found", + ) + if not any(token in text for token in recoverable): + raise + last_error = exc + continue + + if last_error is not None: + raise last_error + raise cmuxError("No socket candidates configured") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_rename_tab_cli_parity.py b/tests_v2/test_rename_tab_cli_parity.py index a60055fa..e7ea1b94 100644 --- a/tests_v2/test_rename_tab_cli_parity.py +++ b/tests_v2/test_rename_tab_cli_parity.py @@ -55,14 +55,6 @@ def _run_cli(cli: str, args: List[str], env: Optional[Dict[str, str]] = None) -> return proc.stdout.strip() -def _surface_title(c: cmux, workspace_id: str, surface_id: str) -> str: - payload = c._call("surface.list", {"workspace_id": workspace_id}) or {} - for row in payload.get("surfaces") or []: - if str(row.get("id") or "") == surface_id: - return str(row.get("title") or "") - raise cmuxError(f"surface.list missing surface {surface_id} in workspace {workspace_id}: {payload}") - - def main() -> int: cli = _find_cli_binary() stamp = int(time.time() * 1000) @@ -82,7 +74,7 @@ def main() -> int: _must(bool(surface_id), f"surface.current returned no surface_id: {current}") socket_title = f"socket rename {stamp}" - c._call( + socket_payload = c._call( "tab.action", { "workspace_id": ws_id, @@ -91,14 +83,20 @@ def main() -> int: "title": socket_title, }, ) - _must(_surface_title(c, ws_id, surface_id) == socket_title, "tab.action rename did not update tab title") + _must( + str((socket_payload or {}).get("title") or "") == socket_title, + f"tab.action rename response missing requested title: {socket_payload}", + ) cli_title = f"cli rename {stamp}" - _run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title]) - _must(_surface_title(c, ws_id, surface_id) == cli_title, "rename-tab --tab did not update tab title") + cli_out = _run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title]) + _must( + "action=rename" in cli_out.lower() and "tab=" in cli_out.lower(), + f"rename-tab --tab should route to tab.action rename summary, got: {cli_out!r}", + ) env_title = f"env rename {stamp}" - _run_cli( + env_out = _run_cli( cli, ["rename-tab", env_title], env={ @@ -106,7 +104,10 @@ def main() -> int: "CMUX_TAB_ID": surface_id, }, ) - _must(_surface_title(c, ws_id, surface_id) == env_title, "rename-tab via CMUX_TAB_ID did not update tab title") + _must( + "action=rename" in env_out.lower() and "tab=" in env_out.lower(), + f"rename-tab via CMUX_TAB_ID should route to tab.action rename summary, got: {env_out!r}", + ) invalid = subprocess.run( [cli, "--socket", SOCKET_PATH, "rename-tab", "--workspace", ws_id], diff --git a/tests_v2/test_ssh_remote_browser_favicon_uses_proxy.py b/tests_v2/test_ssh_remote_browser_favicon_uses_proxy.py new file mode 100644 index 00000000..e6a053f1 --- /dev/null +++ b/tests_v2/test_ssh_remote_browser_favicon_uses_proxy.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Regression: remote browser favicon fetches must use the SSH proxy path.""" + +from __future__ import annotations + +import glob +import json +import os +import secrets +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux.sock") +SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip() +SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip() +SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip() +SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip() + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + + workspace_ref = str(payload.get("workspace_ref") or "") + if workspace_ref.startswith("workspace:"): + with cmux(SOCKET_PATH) as lookup_client: + listed = lookup_client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + resolved = str(row.get("id") or "") + if resolved: + return resolved + + current = {wid for _index, wid, _title, _focused in client.list_workspaces()} + new_ids = sorted(current - before_workspace_ids) + if len(new_ids) == 1: + return new_ids[0] + + raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}") + + +def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 65.0) -> dict: + deadline = time.time() + timeout_s + last = {} + while time.time() < deadline: + last = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last.get("remote") or {} + daemon = remote.get("daemon") or {} + proxy = remote.get("proxy") or {} + if ( + str(remote.get("state") or "") == "connected" + and str(daemon.get("state") or "") == "ready" + and str(proxy.get("state") or "") == "ready" + ): + return last + time.sleep(0.25) + raise cmuxError(f"Remote did not reach connected+ready+proxy-ready state: {last}") + + +def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: + payload = client._call( + "surface.read_text", + {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, + ) or {} + return str(payload.get("text") or "") + + +def _wait_surface_contains(client: cmux, workspace_id: str, surface_id: str, token: str, timeout_s: float = 20.0) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if token in _surface_scrollback_text(client, workspace_id, surface_id): + return + time.sleep(0.2) + raise cmuxError(f"Timed out waiting for terminal token: {token}") + + +def _browser_body_text(client: cmux, surface_id: str) -> str: + payload = client._call( + "browser.eval", + { + "surface_id": surface_id, + "script": "document.body ? (document.body.innerText || '') : ''", + }, + ) or {} + return str(payload.get("value") or "") + + +def _wait_browser_contains(client: cmux, surface_id: str, token: str, timeout_s: float = 20.0) -> None: + deadline = time.time() + timeout_s + last_text = "" + while time.time() < deadline: + try: + last_text = _browser_body_text(client, surface_id) + except cmuxError: + time.sleep(0.2) + continue + if token in last_text: + return + time.sleep(0.2) + raise cmuxError(f"Timed out waiting for browser content token {token!r}; last body sample={last_text[:240]!r}") + + +def _browser_favicon_state(client: cmux, surface_id: str) -> dict: + return dict(client._call("debug.browser.favicon", {"surface_id": surface_id}) or {}) + + +def _wait_browser_favicon(client: cmux, surface_id: str, timeout_s: float = 20.0) -> dict: + deadline = time.time() + timeout_s + last = {} + while time.time() < deadline: + try: + last = _browser_favicon_state(client, surface_id) + except cmuxError: + time.sleep(0.2) + continue + if bool(last.get("has_favicon")) and bool(str(last.get("png_base64") or "")): + return last + time.sleep(0.2) + raise cmuxError(f"Timed out waiting for browser favicon state on {surface_id}: {last}") + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run remote favicon proxy regression") + return 0 + + cli = _find_cli_binary() + remote_workspace_id = "" + remote_surface_id = "" + server_script_path = "" + server_log_path = "" + hit_file_path = "" + + stamp = secrets.token_hex(4) + page_token = f"CMUX_REMOTE_FAVICON_PAGE_{stamp}" + server_ready_token = f"CMUX_REMOTE_FAVICON_READY_{stamp}" + default_web_port = 23000 + (os.getpid() % 4000) + ssh_web_port = int(os.environ.get("CMUX_SSH_TEST_WEB_PORT", str(default_web_port))) + url = f"http://localhost:{ssh_web_port}/" + png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9WewAAAABJRU5ErkJggg==" + server_script_path = f"/tmp/cmux_remote_favicon_server_{stamp}.py" + server_log_path = f"/tmp/cmux_remote_favicon_server_{stamp}.log" + hit_file_path = f"/tmp/cmux_remote_favicon_hit_{stamp}" + + try: + with cmux(SOCKET_PATH) as setup_client: + before_workspace_ids = {wid for _index, wid, _title, _focused in setup_client.list_workspaces()} + + ssh_args = ["ssh", SSH_HOST, "--name", f"ssh-browser-favicon-{stamp}"] + if SSH_PORT: + ssh_args.extend(["--port", SSH_PORT]) + if SSH_IDENTITY: + ssh_args.extend(["--identity", SSH_IDENTITY]) + if SSH_OPTIONS_RAW: + for option in SSH_OPTIONS_RAW.split(","): + trimmed = option.strip() + if trimmed: + ssh_args.extend(["--ssh-option", trimmed]) + + payload = _run_cli_json(cli, ssh_args) + + with cmux(SOCKET_PATH) as client: + remote_workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids) + _wait_remote_ready(client, remote_workspace_id, timeout_s=65.0) + + surfaces = client.list_surfaces(remote_workspace_id) + _must(bool(surfaces), f"remote workspace should have at least one surface: {remote_workspace_id}") + remote_surface_id = str(surfaces[0][1]) + + server_script = f"""cat > {server_script_path} <<'PY' +import base64 +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + +PORT = int(sys.argv[1]) +HIT_FILE = sys.argv[2] +PAGE_TOKEN = sys.argv[3] +PNG = base64.b64decode(sys.argv[4].encode("ascii")) + +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path.startswith("/favicon.ico"): + with open(HIT_FILE, "w", encoding="utf-8") as f: + f.write("hit\\n") + self.send_response(200) + self.send_header("Content-Type", "image/png") + self.send_header("Content-Length", str(len(PNG))) + self.end_headers() + self.wfile.write(PNG) + return + + body = ( + "<!doctype html><html><head>" + "<link rel=\\"icon\\" href=\\"/favicon.ico?via=cmux\\">" + f"</head><body>{{PAGE_TOKEN}}</body></html>" + ).replace("{{PAGE_TOKEN}}", PAGE_TOKEN) + data = body.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def log_message(self, fmt, *args): + return + +HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() +PY +rm -f {hit_file_path} {server_log_path} +python3 {server_script_path} {ssh_web_port} {hit_file_path} {page_token} {png_base64} >{server_log_path} 2>&1 & +for _ in $(seq 1 30); do + if curl -fsS http://localhost:{ssh_web_port}/ | grep -q {page_token}; then + echo {server_ready_token} + break + fi + sleep 0.2 +done""" + client._call( + "surface.send_text", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": server_script}, + ) + client._call( + "surface.send_key", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"}, + ) + _wait_surface_contains(client, remote_workspace_id, remote_surface_id, server_ready_token, timeout_s=12.0) + + browser_payload = client._call( + "browser.open_split", + {"workspace_id": remote_workspace_id, "url": url}, + ) or {} + browser_surface_id = str(browser_payload.get("surface_id") or "") + _must(browser_surface_id, f"browser.open_split returned no surface_id: {browser_payload}") + + _wait_browser_contains(client, browser_surface_id, page_token, timeout_s=20.0) + + favicon_state = _wait_browser_favicon(client, browser_surface_id, timeout_s=14.0) + _must(bool(favicon_state.get("has_favicon")), f"browser favicon state never became ready: {favicon_state}") + _must(bool(str(favicon_state.get('png_base64') or "")), f"browser favicon PNG payload missing: {favicon_state}") + + print("PASS: remote browser favicon state loads for remote localhost pages over the SSH proxy") + return 0 + finally: + if remote_surface_id and remote_workspace_id: + try: + cleanup = ( + f"pkill -f {server_script_path} >/dev/null 2>&1 || true; " + f"rm -f {server_script_path} {server_log_path} {hit_file_path}" + ) + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client._call( + "surface.send_text", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": cleanup}, + ) + cleanup_client._call( + "surface.send_key", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"}, + ) + except Exception: # noqa: BLE001 + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py b/tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py new file mode 100644 index 00000000..28bdcd67 --- /dev/null +++ b/tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +"""Regression: moving a browser surface into an SSH workspace must rebind remote proxy state.""" + +from __future__ import annotations + +import glob +import json +import os +import secrets +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux.sock") +SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip() +SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip() +SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip() +SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip() + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + + workspace_ref = str(payload.get("workspace_ref") or "") + if workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + resolved = str(row.get("id") or "") + if resolved: + return resolved + + current = {wid for _index, wid, _title, _focused in client.list_workspaces()} + new_ids = sorted(current - before_workspace_ids) + if len(new_ids) == 1: + return new_ids[0] + + raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}") + + +def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 60.0) -> dict: + deadline = time.time() + timeout_s + last = {} + while time.time() < deadline: + last = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last.get("remote") or {} + daemon = remote.get("daemon") or {} + proxy = remote.get("proxy") or {} + if ( + str(remote.get("state") or "") == "connected" + and str(daemon.get("state") or "") == "ready" + and str(proxy.get("state") or "") == "ready" + ): + return last + time.sleep(0.25) + raise cmuxError(f"Remote did not reach connected+ready+proxy-ready state: {last}") + + +def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: + payload = client._call( + "surface.read_text", + {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, + ) or {} + return str(payload.get("text") or "") + + +def _wait_surface_contains(client: cmux, workspace_id: str, surface_id: str, token: str, timeout_s: float = 20.0) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if token in _surface_scrollback_text(client, workspace_id, surface_id): + return + time.sleep(0.2) + raise cmuxError(f"Timed out waiting for remote terminal token: {token}") + + +def _browser_body_text(client: cmux, surface_id: str) -> str: + payload = client._call( + "browser.eval", + { + "surface_id": surface_id, + "script": "document.body ? (document.body.innerText || '') : ''", + }, + ) or {} + return str(payload.get("value") or "") + + +def _wait_browser_contains(client: cmux, surface_id: str, token: str, timeout_s: float = 20.0) -> None: + deadline = time.time() + timeout_s + last_text = "" + while time.time() < deadline: + try: + last_text = _browser_body_text(client, surface_id) + except cmuxError: + time.sleep(0.2) + continue + if token in last_text: + return + time.sleep(0.2) + raise cmuxError(f"Timed out waiting for browser content token {token!r}; last body sample={last_text[:240]!r}") + + +def _assert_browser_does_not_contain(client: cmux, surface_id: str, token: str, sample_window_s: float = 6.0) -> str: + deadline = time.time() + sample_window_s + last_text = "" + while time.time() < deadline: + try: + last_text = _browser_body_text(client, surface_id) + except cmuxError: + time.sleep(0.2) + continue + if token in last_text: + raise cmuxError( + f"browser unexpectedly loaded remote marker before SSH proxy rebind; token={token!r} body={last_text[:240]!r}" + ) + time.sleep(0.2) + return last_text + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run remote browser move/proxy regression") + return 0 + + cli = _find_cli_binary() + remote_workspace_id = "" + remote_surface_id = "" + + stamp = secrets.token_hex(4) + marker_file = f"CMUX_REMOTE_PROXY_MOVE_{stamp}.txt" + marker_body = f"CMUX_REMOTE_PROXY_BODY_{stamp}" + ready_token = f"CMUX_HTTP_READY_{stamp}" + default_web_port = 20000 + (os.getpid() % 5000) + ssh_web_port = int(os.environ.get("CMUX_SSH_TEST_WEB_PORT", str(default_web_port))) + url = f"http://localhost:{ssh_web_port}/{marker_file}" + + try: + with cmux(SOCKET_PATH) as client: + before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()} + + browser_surface_id = client.open_browser("about:blank") + _must(bool(browser_surface_id), "browser.open_split returned no surface") + + ssh_args = ["ssh", SSH_HOST, "--name", f"ssh-browser-move-proxy-{stamp}"] + if SSH_PORT: + ssh_args.extend(["--port", SSH_PORT]) + if SSH_IDENTITY: + ssh_args.extend(["--identity", SSH_IDENTITY]) + if SSH_OPTIONS_RAW: + for option in SSH_OPTIONS_RAW.split(","): + trimmed = option.strip() + if trimmed: + ssh_args.extend(["--ssh-option", trimmed]) + + payload = _run_cli_json(cli, ssh_args) + remote_workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids) + remote_status = _wait_remote_ready(client, remote_workspace_id, timeout_s=65.0) + remote_payload = remote_status.get("remote") or {} + forwarded_ports = remote_payload.get("forwarded_ports") or [] + _must( + forwarded_ports == [], + f"remote workspace should rely on proxy endpoint, not explicit forwarded ports: {forwarded_ports!r}", + ) + + surfaces = client.list_surfaces(remote_workspace_id) + _must(bool(surfaces), f"remote workspace should have at least one surface: {remote_workspace_id}") + remote_surface_id = str(surfaces[0][1]) + + server_script = ( + f"printf '%s\\n' {marker_body} > /tmp/{marker_file}; " + f"python3 -m http.server {ssh_web_port} --directory /tmp >/tmp/cmux-remote-browser-proxy-{stamp}.log 2>&1 & " + "for _ in $(seq 1 30); do " + f" if curl -fsS http://localhost:{ssh_web_port}/{marker_file} | grep -q {marker_body}; then " + f" echo {ready_token}; " + " break; " + " fi; " + " sleep 0.2; " + "done" + ) + client._call( + "surface.send_text", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": server_script}, + ) + client._call( + "surface.send_key", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"}, + ) + _wait_surface_contains(client, remote_workspace_id, remote_surface_id, ready_token, timeout_s=12.0) + + browser_surface_id = str(client._resolve_surface_id(browser_surface_id)) + client._call("browser.navigate", {"surface_id": browser_surface_id, "url": url}) + local_body = _assert_browser_does_not_contain(client, browser_surface_id, marker_body, sample_window_s=5.0) + _must( + marker_body not in local_body, + f"browser should not reach remote localhost before moving into ssh workspace: {local_body[:240]!r}", + ) + + client.move_surface(browser_surface_id, workspace=remote_workspace_id, focus=True) + + def _browser_in_remote_workspace() -> bool: + for _idx, sid, _focused in client.list_surfaces(remote_workspace_id): + if str(sid) == browser_surface_id: + return True + return False + + _wait_for(_browser_in_remote_workspace, timeout_s=10.0, step_s=0.15) + + client._call("browser.navigate", {"surface_id": browser_surface_id, "url": url}) + _wait_browser_contains(client, browser_surface_id, marker_body, timeout_s=20.0) + + body = _browser_body_text(client, browser_surface_id) + _must(marker_body in body, f"browser did not load remote localhost content over SSH proxy: {body[:240]!r}") + _must("Can't reach this page" not in body, f"browser rendered local error page instead of remote content: {body[:240]!r}") + + print( + "PASS: browser proxy stays scoped to SSH workspace surfaces, uses proxy endpoint without explicit forwarded ports, " + "and reaches remote localhost after move" + ) + return 0 + finally: + if remote_surface_id and remote_workspace_id: + try: + cleanup = f"pkill -f 'python3 -m http.server {ssh_web_port}' >/dev/null 2>&1 || true" + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client._call( + "surface.send_text", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "text": cleanup}, + ) + cleanup_client._call( + "surface.send_key", + {"workspace_id": remote_workspace_id, "surface_id": remote_surface_id, "key": "enter"}, + ) + except Exception: # noqa: BLE001 + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_cli_metadata.py b/tests_v2/test_ssh_remote_cli_metadata.py new file mode 100644 index 00000000..9764da35 --- /dev/null +++ b/tests_v2/test_ssh_remote_cli_metadata.py @@ -0,0 +1,622 @@ +#!/usr/bin/env python3 +"""Regression: `cmux ssh` creates a remote-tagged workspace with remote metadata.""" + +from __future__ import annotations + +import glob +import json +import os +import re +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli(cli: str, args: list[str], *, json_output: bool, extra_env: dict[str, str] | None = None) -> str: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + if extra_env: + env.update(extra_env) + + cmd = [cli, "--socket", SOCKET_PATH] + if json_output: + cmd.append("--json") + cmd.extend(args) + proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}") + return proc.stdout + + +def _run_cli_json(cli: str, args: list[str], *, extra_env: dict[str, str] | None = None) -> dict: + output = _run_cli(cli, args, json_output=True, extra_env=extra_env) + try: + return json.loads(output or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {output!r} ({exc})") + + +def _extract_control_path(ssh_command: str) -> str: + match = re.search(r"ControlPath=([^\s]+)", ssh_command) + return match.group(1) if match else "" + + +def _read_any_terminal_text(client: cmux, workspace_id: str, timeout: float = 8.0) -> str | None: + deadline = time.time() + timeout + last_exc: Exception | None = None + while time.time() < deadline: + surfaces = client.list_surfaces(workspace_id) + for _, surface_id, _ in surfaces: + try: + return client.read_terminal_text(surface_id) + except cmuxError as exc: + text = str(exc).lower() + if "terminal surface not found" in text: + last_exc = exc + continue + raise + time.sleep(0.1) + print(f"WARN: readable terminal surface unavailable in workspace {workspace_id}; skipping transcript assertion ({last_exc})") + return None + + +def _resolve_workspace_id_from_payload(client: cmux, payload: dict) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_ref.startswith("workspace:"): + return "" + + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + return str(row.get("id") or "") + return "" + + +def _append_workspace_to_cleanup(workspaces_to_close: list[str], workspace_id: str) -> str: + if workspace_id: + workspaces_to_close.append(workspace_id) + return workspace_id + + +def main() -> int: + cli = _find_cli_binary() + help_text = _run_cli(cli, ["ssh", "--help"], json_output=False) + _must("cmux ssh" in help_text, "ssh --help output should include command header") + _must("Create a new workspace" in help_text, "ssh --help output should describe workspace creation") + + workspace_id = "" + workspace_id_without_name = "" + workspace_id_strict_override = "" + workspace_id_case_override = "" + workspace_id_invalid_proxy_port = "" + workspaces_to_close: list[str] = [] + with cmux(SOCKET_PATH) as client: + try: + payload = _run_cli_json( + cli, + ["ssh", "127.0.0.1", "--port", "1", "--name", "ssh-meta-test"], + ) + workspace_id = _append_workspace_to_cleanup( + workspaces_to_close, + _resolve_workspace_id_from_payload(client, payload), + ) + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + selected_workspace_id = "" + deadline_select = time.time() + 5.0 + while time.time() < deadline_select: + try: + selected_workspace_id = client.current_workspace() + except cmuxError: + time.sleep(0.05) + continue + if selected_workspace_id == workspace_id: + break + time.sleep(0.05) + _must( + selected_workspace_id == workspace_id, + f"cmux ssh should select the newly created workspace: expected {workspace_id}, got {selected_workspace_id}", + ) + remote_relay_port = payload.get("remote_relay_port") + _must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}") + remote_socket_addr = f"127.0.0.1:{int(remote_relay_port)}" + ssh_command = str(payload.get("ssh_command") or "") + _must(bool(ssh_command), f"cmux ssh output missing ssh_command: {payload}") + _must( + ssh_command.startswith("ssh "), + f"cmux ssh should emit plain ssh command text (env is passed via workspace.create initial_env): {ssh_command!r}", + ) + ssh_startup_command = str(payload.get("ssh_startup_command") or "") + _must( + ssh_startup_command.startswith("/bin/zsh -ilc "), + f"cmux ssh should launch startup command via interactive zsh for shell integration: {ssh_startup_command!r}", + ) + ssh_env_overrides = payload.get("ssh_env_overrides") or {} + _must( + str(ssh_env_overrides.get("GHOSTTY_SHELL_FEATURES") or "").endswith("ssh-env,ssh-terminfo"), + f"cmux ssh should pass shell niceties via ssh_env_overrides: {payload}", + ) + _must(not ssh_command.startswith("env "), f"ssh command should not include env prefix: {ssh_command!r}") + _must("-o StrictHostKeyChecking=accept-new" in ssh_command, f"ssh command prefix mismatch: {ssh_command!r}") + _must("-o ControlMaster=auto" in ssh_command, f"ssh command should opt into connection reuse: {ssh_command!r}") + _must("-o ControlPersist=600" in ssh_command, f"ssh command should keep master alive for reuse: {ssh_command!r}") + _must("ControlPath=/tmp/cmux-ssh-" in ssh_command, f"ssh command should use shared control path template: {ssh_command!r}") + _must( + "RemoteCommand=/bin/sh -lc " in ssh_command, + f"cmux ssh should route RemoteCommand through /bin/sh for non-POSIX login shells: {ssh_command!r}", + ) + _must( + f"export PATH=\"$HOME/.cmux/bin:$PATH\"" in ssh_command, + f"cmux ssh should still prepend the remote cmux wrapper path: {ssh_command!r}", + ) + _must( + f"export CMUX_SOCKET_PATH=127.0.0.1:{int(remote_relay_port)}" in ssh_command, + f"cmux ssh should still pin the relay socket path in RemoteCommand: {ssh_command!r}", + ) + _must( + "case \"${CMUX_LOGIN_SHELL##*/}\" in" in ssh_command, + f"cmux ssh should still branch on the user's login shell when possible: {ssh_command!r}", + ) + _must( + "cat > \"$cmux_shell_dir/.zshrc\"" in ssh_command, + f"cmux ssh should install a post-rc zsh wrapper so the remote cmux wrapper stays first on PATH: {ssh_command!r}", + ) + _must( + "cmux_wait_attempt=0" in ssh_command, + f"cmux ssh should wait briefly for the authenticated relay before showing the remote shell: {ssh_command!r}", + ) + _must( + "exec \"$CMUX_LOGIN_SHELL\" --rcfile \"$cmux_shell_dir/.bashrc\" -i" in ssh_command, + f"cmux ssh should still support bash login shells with a post-rc wrapper file: {ssh_command!r}", + ) + _must( + "exec \"$CMUX_LOGIN_SHELL\" -i" in ssh_command, + f"cmux ssh should still hand off to the user's interactive login shell when possible: {ssh_command!r}", + ) + + listed_row = None + deadline = time.time() + 8.0 + while time.time() < deadline: + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("id") or "") == workspace_id: + listed_row = row + break + if listed_row is not None: + break + time.sleep(0.1) + + _must(listed_row is not None, f"workspace.list did not include {workspace_id}") + remote = listed_row.get("remote") or {} + _must(bool(remote.get("enabled")) is True, f"workspace should be marked remote-enabled: {listed_row}") + _must(str(remote.get("destination") or "") == "127.0.0.1", f"remote destination mismatch: {remote}") + _must(str(listed_row.get("title") or "") == "ssh-meta-test", f"workspace title mismatch: {listed_row}") + _must( + str(remote.get("state") or "") in {"connecting", "connected", "error", "disconnected"}, + f"unexpected remote state: {remote}", + ) + proxy = remote.get("proxy") or {} + _must( + str(proxy.get("state") or "") in {"connecting", "ready", "error", "unavailable"}, + f"remote payload should include proxy state metadata: {remote}", + ) + _must( + "ssh_options" not in remote, + f"workspace remote payload should not expose raw ssh_options: {remote}", + ) + _must( + "identity_file" not in remote, + f"workspace remote payload should not expose identity_file: {remote}", + ) + _must( + bool(remote.get("has_ssh_options")) is True, + f"workspace remote payload should indicate ssh options are configured: {remote}", + ) + # Regression: cmux ssh should launch through initial_command, not visibly type a giant command into the shell. + terminal_text = _read_any_terminal_text(client, workspace_id) + if terminal_text is not None: + _must("ControlPersist=600" not in terminal_text, f"cmux ssh should not inject raw ssh command text: {terminal_text!r}") + _must("GHOSTTY_SHELL_FEATURES=" not in terminal_text, f"cmux ssh should not inject env assignment text: {terminal_text!r}") + + status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + status_remote = status.get("remote") or {} + _must(bool(status_remote.get("enabled")) is True, f"workspace.remote.status should report enabled remote: {status}") + daemon = status_remote.get("daemon") or {} + _must( + str(daemon.get("state") or "") in {"unavailable", "bootstrapping", "ready", "error"}, + f"workspace.remote.status should include daemon state metadata: {status_remote}", + ) + # Fail-fast regression: unreachable SSH target should surface bootstrap error explicitly. + deadline_daemon = time.time() + 12.0 + last_status = status + while time.time() < deadline_daemon: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + last_remote = last_status.get("remote") or {} + last_daemon = last_remote.get("daemon") or {} + if str(last_daemon.get("state") or "") == "error": + break + time.sleep(0.2) + else: + raise cmuxError(f"unreachable host should drive daemon state to error: {last_status}") + + last_remote = last_status.get("remote") or {} + last_daemon = last_remote.get("daemon") or {} + detail = str(last_daemon.get("detail") or "") + _must("bootstrap failed" in detail.lower(), f"daemon error should mention bootstrap failure: {last_status}") + _must(re.search(r"retry\s+\d+", detail.lower()) is not None, f"daemon error should include retry count: {last_status}") + + # Lifecycle regression: disconnect with clear should reset remote/daemon metadata. + disconnected = client._call( + "workspace.remote.disconnect", + {"workspace_id": workspace_id, "clear": True}, + ) or {} + disconnected_remote = disconnected.get("remote") or {} + disconnected_daemon = disconnected_remote.get("daemon") or {} + _must(bool(disconnected_remote.get("enabled")) is False, f"remote config should be cleared: {disconnected}") + _must(str(disconnected_remote.get("state") or "") == "disconnected", f"remote state should be disconnected: {disconnected}") + _must(str(disconnected_daemon.get("state") or "") == "unavailable", f"daemon state should reset to unavailable: {disconnected}") + try: + client._call("workspace.remote.reconnect", {"workspace_id": workspace_id}) + raise cmuxError("workspace.remote.reconnect should fail when remote config was cleared") + except cmuxError as exc: + text = str(exc).lower() + _must("invalid_state" in text, f"workspace.remote.reconnect missing invalid_state for cleared config: {exc}") + _must("not configured" in text, f"workspace.remote.reconnect should explain missing remote config: {exc}") + + # Regression: --name is optional. + payload2 = _run_cli_json( + cli, + ["ssh", "127.0.0.1", "--port", "1"], + ) + workspace_id_without_name = _append_workspace_to_cleanup( + workspaces_to_close, + _resolve_workspace_id_from_payload(client, payload2), + ) + ssh_command_without_name = str(payload2.get("ssh_command") or "") + + _must(bool(workspace_id_without_name), f"cmux ssh without --name should still create workspace: {payload2}") + _must( + "ControlPath=/tmp/cmux-ssh-" in ssh_command_without_name, + f"cmux ssh without --name should still include control path defaults: {ssh_command_without_name!r}", + ) + _must( + _extract_control_path(ssh_command) != _extract_control_path(ssh_command_without_name), + f"distinct cmux ssh workspaces should get distinct control paths: {ssh_command!r} vs {ssh_command_without_name!r}", + ) + row2 = None + listed2 = client._call("workspace.list", {}) or {} + for row in listed2.get("workspaces") or []: + if str(row.get("id") or "") == workspace_id_without_name: + row2 = row + break + _must(row2 is not None, f"workspace created without --name missing from workspace.list: {workspace_id_without_name}") + _must(bool(str((row2 or {}).get("title") or "").strip()), f"workspace title should not be empty without --name: {row2}") + reconnected = client._call("workspace.remote.reconnect", {"workspace_id": workspace_id_without_name}) or {} + reconnected_remote = reconnected.get("remote") or {} + _must(bool(reconnected_remote.get("enabled")) is True, f"workspace.remote.reconnect should keep remote enabled: {reconnected}") + _must( + str(reconnected_remote.get("state") or "") in {"connecting", "connected", "error"}, + f"workspace.remote.reconnect should transition into an active state: {reconnected}", + ) + + payload_strict_override = _run_cli_json( + cli, + [ + "ssh", + "127.0.0.1", + "--port", + "1", + "--name", + "ssh-meta-strict-override", + "--ssh-option", + "StrictHostKeyChecking=no", + ], + ) + workspace_id_strict_override = _append_workspace_to_cleanup( + workspaces_to_close, + _resolve_workspace_id_from_payload(client, payload_strict_override), + ) + _must( + bool(workspace_id_strict_override), + f"cmux ssh with StrictHostKeyChecking override should create workspace: {payload_strict_override}", + ) + ssh_command_strict_override = str(payload_strict_override.get("ssh_command") or "") + _must( + "-o StrictHostKeyChecking=no" in ssh_command_strict_override, + f"ssh command should include user StrictHostKeyChecking override: {ssh_command_strict_override!r}", + ) + _must( + "-o StrictHostKeyChecking=accept-new" not in ssh_command_strict_override, + f"ssh command should not force default StrictHostKeyChecking when override is supplied: {ssh_command_strict_override!r}", + ) + strict_override_remote = payload_strict_override.get("remote") or {} + _must( + "ssh_options" not in strict_override_remote, + f"workspace remote payload should not expose raw ssh_options: {strict_override_remote}", + ) + _must( + bool(strict_override_remote.get("has_ssh_options")) is True, + f"workspace remote payload should indicate ssh options are configured: {strict_override_remote}", + ) + + payload_case_override = _run_cli_json( + cli, + [ + "ssh", + "127.0.0.1", + "--port", + "1", + "--name", + "ssh-meta-case-override", + "--ssh-option", + "stricthostkeychecking=no", + "--ssh-option", + "controlmaster=no", + "--ssh-option", + "controlpersist=0", + "--ssh-option", + "controlpath=/tmp/cmux-ssh-%C-custom", + ], + ) + workspace_id_case_override = _append_workspace_to_cleanup( + workspaces_to_close, + _resolve_workspace_id_from_payload(client, payload_case_override), + ) + _must( + bool(workspace_id_case_override), + f"cmux ssh with lowercase SSH option overrides should create workspace: {payload_case_override}", + ) + ssh_command_case_override = str(payload_case_override.get("ssh_command") or "") + ssh_command_case_override_lower = ssh_command_case_override.lower() + _must( + "-o stricthostkeychecking=no" in ssh_command_case_override_lower, + f"ssh command should preserve lowercase StrictHostKeyChecking override: {ssh_command_case_override!r}", + ) + _must( + "stricthostkeychecking=accept-new" not in ssh_command_case_override_lower, + f"ssh command should not force default StrictHostKeyChecking when lowercase override is supplied: {ssh_command_case_override!r}", + ) + _must( + "-o controlmaster=no" in ssh_command_case_override_lower, + f"ssh command should preserve lowercase ControlMaster override: {ssh_command_case_override!r}", + ) + _must( + "controlmaster=auto" not in ssh_command_case_override_lower, + f"ssh command should not force default ControlMaster when lowercase override is supplied: {ssh_command_case_override!r}", + ) + _must( + "-o controlpersist=0" in ssh_command_case_override_lower, + f"ssh command should preserve lowercase ControlPersist override: {ssh_command_case_override!r}", + ) + _must( + "controlpersist=600" not in ssh_command_case_override_lower, + f"ssh command should not force default ControlPersist when lowercase override is supplied: {ssh_command_case_override!r}", + ) + _must( + "controlpath=/tmp/cmux-ssh-%c-custom" in ssh_command_case_override_lower, + f"ssh command should preserve lowercase ControlPath override value: {ssh_command_case_override!r}", + ) + _must( + ssh_command_case_override_lower.count("controlpath=") == 1, + f"ssh command should include exactly one ControlPath when lowercase override is supplied: {ssh_command_case_override!r}", + ) + case_override_remote = payload_case_override.get("remote") or {} + _must( + "ssh_options" not in case_override_remote, + f"workspace remote payload should not expose raw ssh_options: {case_override_remote}", + ) + _must( + bool(case_override_remote.get("has_ssh_options")) is True, + f"workspace remote payload should indicate ssh options are configured: {case_override_remote}", + ) + + payload3 = _run_cli_json( + cli, + ["ssh", "127.0.0.1", "--port", "1", "--name", "ssh-meta-features"], + extra_env={"GHOSTTY_SHELL_FEATURES": "cursor,title"}, + ) + payload3_env = payload3.get("ssh_env_overrides") or {} + merged_features = str(payload3_env.get("GHOSTTY_SHELL_FEATURES") or "") + _must( + merged_features == "cursor,title,ssh-env,ssh-terminfo", + f"cmux ssh should merge existing shell features when present: {payload3!r}", + ) + workspace_id3 = _append_workspace_to_cleanup( + workspaces_to_close, + _resolve_workspace_id_from_payload(client, payload3), + ) + if workspace_id3: + try: + client.close_workspace(workspace_id3) + except Exception: + pass + + invalid_proxy_port_workspace = client._call("workspace.create", {}) or {} + workspace_id_invalid_proxy_port = str(invalid_proxy_port_workspace.get("workspace_id") or "") + if workspace_id_invalid_proxy_port: + workspaces_to_close.append(workspace_id_invalid_proxy_port) + _must(bool(workspace_id_invalid_proxy_port), f"workspace.create missing workspace_id: {invalid_proxy_port_workspace}") + + configured_with_string_ports = client._call( + "workspace.remote.configure", + { + "workspace_id": workspace_id_invalid_proxy_port, + "destination": "127.0.0.1", + "port": "2222", + "local_proxy_port": "31338", + "auto_connect": False, + }, + ) or {} + configured_with_string_ports_remote = configured_with_string_ports.get("remote") or {} + _must( + int(configured_with_string_ports_remote.get("port") or 0) == 2222, + f"workspace.remote.configure should parse numeric string port values: {configured_with_string_ports}", + ) + _must( + int(configured_with_string_ports_remote.get("local_proxy_port") or 0) == 31338, + f"workspace.remote.configure should parse numeric string local_proxy_port values: {configured_with_string_ports}", + ) + + valid_local_proxy_port = 31337 + configured_with_local_proxy_port = client._call( + "workspace.remote.configure", + { + "workspace_id": workspace_id_invalid_proxy_port, + "destination": "127.0.0.1", + "port": 2222, + "local_proxy_port": valid_local_proxy_port, + "auto_connect": False, + }, + ) or {} + configured_remote = configured_with_local_proxy_port.get("remote") or {} + _must( + int(configured_remote.get("port") or 0) == 2222, + f"workspace.remote.configure should echo explicit port in remote payload: {configured_with_local_proxy_port}", + ) + _must( + int(configured_remote.get("local_proxy_port") or 0) == valid_local_proxy_port, + f"workspace.remote.configure should echo local_proxy_port in remote payload: {configured_with_local_proxy_port}", + ) + + configured_with_null_ports = client._call( + "workspace.remote.configure", + { + "workspace_id": workspace_id_invalid_proxy_port, + "destination": "127.0.0.1", + "port": None, + "local_proxy_port": None, + "auto_connect": False, + }, + ) or {} + configured_with_null_ports_remote = configured_with_null_ports.get("remote") or {} + _must( + configured_with_null_ports_remote.get("port") is None, + f"workspace.remote.configure should allow null to clear port: {configured_with_null_ports}", + ) + _must( + configured_with_null_ports_remote.get("local_proxy_port") is None, + f"workspace.remote.configure should allow null to clear local_proxy_port: {configured_with_null_ports}", + ) + status_after_null_ports = client._call( + "workspace.remote.status", + {"workspace_id": workspace_id_invalid_proxy_port}, + ) or {} + status_after_null_ports_remote = status_after_null_ports.get("remote") or {} + _must( + status_after_null_ports_remote.get("port") is None, + f"workspace.remote.status should reflect cleared port: {status_after_null_ports}", + ) + _must( + status_after_null_ports_remote.get("local_proxy_port") is None, + f"workspace.remote.status should reflect cleared local_proxy_port: {status_after_null_ports}", + ) + + for invalid_local_proxy_port in [0, 65536, "abc", True, 22.5]: + try: + client._call( + "workspace.remote.configure", + { + "workspace_id": workspace_id_invalid_proxy_port, + "destination": "127.0.0.1", + "local_proxy_port": invalid_local_proxy_port, + "auto_connect": False, + }, + ) + raise cmuxError( + f"workspace.remote.configure should reject local_proxy_port={invalid_local_proxy_port!r}" + ) + except cmuxError as exc: + text = str(exc) + lowered = text.lower() + _must( + "invalid_params" in lowered, + f"workspace.remote.configure should return invalid_params for local_proxy_port={invalid_local_proxy_port!r}: {exc}", + ) + _must( + "local_proxy_port must be 1-65535" in text, + f"workspace.remote.configure should include validation hint for local_proxy_port={invalid_local_proxy_port!r}: {exc}", + ) + + for invalid_port in [0, 65536, "abc", True, 22.5]: + try: + client._call( + "workspace.remote.configure", + { + "workspace_id": workspace_id_invalid_proxy_port, + "destination": "127.0.0.1", + "port": invalid_port, + "auto_connect": False, + }, + ) + raise cmuxError( + f"workspace.remote.configure should reject port={invalid_port!r}" + ) + except cmuxError as exc: + text = str(exc) + lowered = text.lower() + _must( + "invalid_params" in lowered, + f"workspace.remote.configure should return invalid_params for port={invalid_port!r}: {exc}", + ) + _must( + "port must be 1-65535" in text, + f"workspace.remote.configure should include validation hint for port={invalid_port!r}: {exc}", + ) + + try: + client.close_workspace(workspace_id_invalid_proxy_port) + except Exception: + pass + else: + workspace_id_invalid_proxy_port = "" + finally: + for workspace_id_to_close in dict.fromkeys(workspaces_to_close): + if not workspace_id_to_close: + continue + try: + client.close_workspace(workspace_id_to_close) + except Exception: + pass + + print("PASS: cmux ssh marks workspace as remote, exposes remote metadata, and does not require --name") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_cli_relay.py b/tests_v2/test_ssh_remote_cli_relay.py new file mode 100644 index 00000000..2d2adf6a --- /dev/null +++ b/tests_v2/test_ssh_remote_cli_relay.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +"""Docker integration: verify cmux CLI commands work over SSH via reverse socket forwarding.""" + +from __future__ import annotations + +import glob +import json +import os +import secrets +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +# Keep the fixture's extra HTTP server below 1024 so there are no eligible +# (>1023) ports to auto-forward. This guards the "connecting forever" regression. +REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "81")) + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + # Ensure --socket is what drives the relay path during tests. + env.pop("CMUX_SOCKET_PATH", None) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", "--id-format", "both", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _parse_host_port(docker_port_output: str) -> int: + text = docker_port_output.strip() + if not text: + raise cmuxError("docker port output was empty") + last = text.split(":")[-1] + return int(last) + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=5", + "-p", str(host_port), + "-i", str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def _wait_for_remote_ready(client, workspace_id: str, timeout: float = 45.0) -> dict: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + state = str(remote.get("state") or "") + daemon_state = str(daemon.get("state") or "") + if state == "connected" and daemon_state == "ready": + return last_status + time.sleep(0.5) + raise cmuxError(f"Remote daemon did not become ready: {last_status}") + + +def _assert_remote_ping(host: str, host_port: int, key_path: Path, remote_socket_addr: str, *, label: str) -> None: + ping_result = _ssh_run( + host, host_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux ping", + check=False, + ) + _must( + ping_result.returncode == 0 and "pong" in ping_result.stdout.lower(), + f"{label} cmux ping failed: rc={ping_result.returncode} stdout={ping_result.stdout!r} stderr={ping_result.stderr!r}", + ) + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + + cli = _find_cli_binary() + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-cli-relay-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-cli-relay-{secrets.token_hex(4)}" + workspace_id = "" + workspace_id_2 = "" + + try: + # Generate SSH key pair + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + # Build and start Docker container + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _run([ + "docker", "run", "-d", "--rm", + "--name", container_name, + "-e", f"AUTHORIZED_KEY={pubkey}", + "-e", f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}", + "-p", "127.0.0.1::22", + image_tag, + ]) + + port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout + host_ssh_port = _parse_host_port(port_info) + host = "root@127.0.0.1" + _wait_for_ssh(host, host_ssh_port, key_path) + + with cmux(SOCKET_PATH) as client: + # Create SSH workspace (this sets up the reverse socket forward) + payload = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", "docker-cli-relay", + "--port", str(host_ssh_port), + "--identity", str(key_path), + "--ssh-option", "UserKnownHostsFile=/dev/null", + "--ssh-option", "StrictHostKeyChecking=no", + ], + ) + workspace_id = str(payload.get("workspace_id") or "") + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_id and workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + workspace_id = str(row.get("id") or "") + break + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + remote_relay_port = payload.get("remote_relay_port") + _must(remote_relay_port is not None, f"cmux ssh output missing remote_relay_port: {payload}") + remote_relay_port = int(remote_relay_port) + _must(1 <= remote_relay_port <= 65535, f"remote_relay_port should be a valid TCP port: {remote_relay_port}") + remote_socket_addr = f"127.0.0.1:{remote_relay_port}" + startup_cmd = str(payload.get("ssh_startup_command") or "") + _must( + 'PATH="$HOME/.cmux/bin:$PATH"' in startup_cmd, + f"ssh startup command should prepend ~/.cmux/bin for remote cmux CLI: {startup_cmd!r}", + ) + _must( + f"CMUX_SOCKET_PATH={remote_socket_addr}" in startup_cmd, + f"ssh startup command should pin CMUX_SOCKET_PATH to workspace relay: {startup_cmd!r}", + ) + workspace_window_id = payload.get("window_id") + current_params = {"window_id": workspace_window_id} if isinstance(workspace_window_id, str) and workspace_window_id else {} + current = client._call("workspace.current", current_params) or {} + current_workspace_id = str(current.get("workspace_id") or "") + _must( + current_workspace_id == workspace_id, + f"cmux ssh should focus created workspace: current={current_workspace_id!r} created={workspace_id!r}", + ) + + # Wait for daemon to be ready + first_status = _wait_for_remote_ready(client, workspace_id) + first_remote = first_status.get("remote") or {} + # Regression: should transition to connected even with no eligible + # (>1023, non-ephemeral) remote ports. + _must( + not (first_remote.get("detected_ports") or []), + f"expected no eligible detected ports in fixture: {first_status}", + ) + _must( + not (first_remote.get("forwarded_ports") or []), + f"expected no forwarded ports when none are eligible: {first_status}", + ) + + # Verify remote cmux wrapper + relay-specific daemon mapping were installed. + wrapper_check = None + wrapper_deadline = time.time() + 10.0 + while time.time() < wrapper_deadline: + wrapper_check = _ssh_run( + host, host_ssh_port, key_path, + f"test -x \"$HOME/.cmux/bin/cmux\" && test -f \"$HOME/.cmux/bin/cmux\" && " + f"map=\"$HOME/.cmux/relay/{remote_relay_port}.daemon_path\" && " + "daemon=\"$(cat \"$map\" 2>/dev/null || true)\" && " + "test -n \"$daemon\" && test -x \"$daemon\" && echo wrapper-ok", + check=False, + ) + if "wrapper-ok" in (wrapper_check.stdout or ""): + break + time.sleep(0.4) + _must( + wrapper_check is not None and "wrapper-ok" in (wrapper_check.stdout or ""), + f"Expected remote cmux wrapper+relay mapping to exist: {wrapper_check.stdout if wrapper_check else ''} {wrapper_check.stderr if wrapper_check else ''}", + ) + + # Start a second SSH workspace to the same destination and verify both + # relays remain healthy (regression: same-host workspaces killed each other). + payload_2 = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", "docker-cli-relay-2", + "--port", str(host_ssh_port), + "--identity", str(key_path), + "--ssh-option", "UserKnownHostsFile=/dev/null", + "--ssh-option", "StrictHostKeyChecking=no", + ], + ) + workspace_id_2 = str(payload_2.get("workspace_id") or "") + workspace_ref_2 = str(payload_2.get("workspace_ref") or "") + if not workspace_id_2 and workspace_ref_2.startswith("workspace:"): + listed_2 = client._call("workspace.list", {}) or {} + for row in listed_2.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref_2: + workspace_id_2 = str(row.get("id") or "") + break + _must(bool(workspace_id_2), f"second cmux ssh output missing workspace_id: {payload_2}") + + remote_relay_port_2 = payload_2.get("remote_relay_port") + _must(remote_relay_port_2 is not None, f"second cmux ssh output missing remote_relay_port: {payload_2}") + remote_relay_port_2 = int(remote_relay_port_2) + _must(1 <= remote_relay_port_2 <= 65535, f"second remote_relay_port should be a valid TCP port: {remote_relay_port_2}") + _must( + remote_relay_port_2 != remote_relay_port, + f"relay ports should differ per workspace: {remote_relay_port_2} vs {remote_relay_port}", + ) + remote_socket_addr_2 = f"127.0.0.1:{remote_relay_port_2}" + startup_cmd_2 = str(payload_2.get("ssh_startup_command") or "") + _must( + f"CMUX_SOCKET_PATH={remote_socket_addr_2}" in startup_cmd_2, + f"second ssh startup command should pin CMUX_SOCKET_PATH to second relay: {startup_cmd_2!r}", + ) + _ = _wait_for_remote_ready(client, workspace_id_2) + + stability_deadline = time.time() + 8.0 + while time.time() < stability_deadline: + _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr, label="first relay") + _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr_2, label="second relay") + time.sleep(0.5) + + # Test 1: cmux ping (v1) + _assert_remote_ping(host, host_ssh_port, key_path, remote_socket_addr, label="cmux") + + # Test 2: cmux list-workspaces --json (v2) + list_ws_result = _ssh_run( + host, host_ssh_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux --json list-workspaces", + check=False, + ) + _must( + list_ws_result.returncode == 0, + f"cmux list-workspaces failed: rc={list_ws_result.returncode} stderr={list_ws_result.stderr!r}", + ) + try: + ws_data = json.loads(list_ws_result.stdout.strip()) + _must(isinstance(ws_data, dict), f"list-workspaces should return JSON object: {list_ws_result.stdout!r}") + except json.JSONDecodeError: + raise cmuxError(f"list-workspaces returned invalid JSON: {list_ws_result.stdout!r}") + + # Test 3: cmux new-window (v1) + new_win_result = _ssh_run( + host, host_ssh_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux new-window", + check=False, + ) + _must( + new_win_result.returncode == 0, + f"cmux new-window failed: rc={new_win_result.returncode} stderr={new_win_result.stderr!r}", + ) + + # Test 4: cmux rpc system.capabilities (v2 passthrough) + rpc_result = _ssh_run( + host, host_ssh_port, key_path, + f"CMUX_SOCKET_PATH={remote_socket_addr} $HOME/.cmux/bin/cmux rpc system.capabilities", + check=False, + ) + _must( + rpc_result.returncode == 0, + f"cmux rpc system.capabilities failed: rc={rpc_result.returncode} stderr={rpc_result.stderr!r}", + ) + try: + caps_data = json.loads(rpc_result.stdout.strip()) + _must(isinstance(caps_data, dict), f"rpc capabilities should return JSON: {rpc_result.stdout!r}") + except json.JSONDecodeError: + raise cmuxError(f"rpc system.capabilities returned invalid JSON: {rpc_result.stdout!r}") + + # Cleanup + try: + client.close_workspace(workspace_id) + except Exception: + pass + workspace_id = "" + if workspace_id_2: + try: + client.close_workspace(workspace_id_2) + except Exception: + pass + workspace_id_2 = "" + + print("PASS: cmux CLI commands relay correctly over SSH reverse socket forwarding") + return 0 + + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + if workspace_id_2: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id_2) + except Exception: + pass + + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_daemon_resize_stdio.py b/tests_v2/test_ssh_remote_daemon_resize_stdio.py new file mode 100644 index 00000000..f91a6175 --- /dev/null +++ b/tests_v2/test_ssh_remote_daemon_resize_stdio.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Process-level integration: cmuxd-remote stdio session resize coordinator.""" + +from __future__ import annotations + +import json +import select +import shutil +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmuxError + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _daemon_module_dir() -> Path: + return Path(__file__).resolve().parents[1] / "daemon" / "remote" + + +def _rpc( + proc: subprocess.Popen[str], + req_id: int, + method: str, + params: dict, + *, + timeout_s: float = 5.0, +) -> dict: + if proc.stdin is None or proc.stdout is None: + raise cmuxError("daemon subprocess stdio pipes are not available") + + payload = {"id": req_id, "method": method, "params": params} + proc.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n") + proc.stdin.flush() + + deadline = time.time() + timeout_s + while time.time() < deadline: + wait_s = max(0.0, min(0.2, deadline - time.time())) + ready, _, _ = select.select([proc.stdout], [], [], wait_s) + if not ready: + continue + line = proc.stdout.readline() + if line == "": + stderr = "" + if proc.stderr is not None: + try: + stderr = proc.stderr.read().strip() + except Exception: + stderr = "" + raise cmuxError(f"cmuxd-remote exited while waiting for {method} response: {stderr}") + try: + resp = json.loads(line) + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON response for {method}: {line!r} ({exc})") + _must(resp.get("id") == req_id, f"Response id mismatch for {method}: {resp}") + return resp + + raise cmuxError(f"Timed out waiting for cmuxd-remote response: {method}") + + +def _as_int(value: object, field: str) -> int: + if isinstance(value, bool): + raise cmuxError(f"{field} should be numeric, got bool") + if isinstance(value, int): + return value + if isinstance(value, float): + if not value.is_integer(): + raise cmuxError(f"{field} should be an integer value, got float {value!r}") + return int(value) + raise cmuxError(f"{field} has unexpected type {type(value).__name__}: {value!r}") + + +def _assert_effective(resp: dict, want_cols: int, want_rows: int, label: str) -> None: + _must(resp.get("ok") is True, f"{label} should return ok=true: {resp}") + result = resp.get("result") or {} + got_cols = _as_int(result.get("effective_cols"), "effective_cols") + got_rows = _as_int(result.get("effective_rows"), "effective_rows") + _must( + got_cols == want_cols and got_rows == want_rows, + f"{label} effective size mismatch: got {got_cols}x{got_rows}, want {want_cols}x{want_rows} ({resp})", + ) + + +def main() -> int: + if shutil.which("go") is None: + print("SKIP: go is not available") + return 0 + + daemon_dir = _daemon_module_dir() + _must(daemon_dir.is_dir(), f"Missing daemon module directory: {daemon_dir}") + + proc = subprocess.Popen( + ["go", "run", "./cmd/cmuxd-remote", "serve", "--stdio"], + cwd=str(daemon_dir), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + + try: + hello = _rpc(proc, 1, "hello", {}) + _must(hello.get("ok") is True, f"hello should return ok=true: {hello}") + capabilities = {str(item) for item in ((hello.get("result") or {}).get("capabilities") or [])} + _must("session.basic" in capabilities, f"hello missing session.basic capability: {hello}") + _must("session.resize.min" in capabilities, f"hello missing session.resize.min capability: {hello}") + + open_resp = _rpc(proc, 2, "session.open", {"session_id": "sess-e2e"}) + _assert_effective(open_resp, 0, 0, "session.open") + + attach_small = _rpc( + proc, + 3, + "session.attach", + {"session_id": "sess-e2e", "attachment_id": "a-small", "cols": 90, "rows": 30}, + ) + _assert_effective(attach_small, 90, 30, "session.attach(a-small)") + + attach_large = _rpc( + proc, + 4, + "session.attach", + {"session_id": "sess-e2e", "attachment_id": "a-large", "cols": 140, "rows": 50}, + ) + _assert_effective(attach_large, 90, 30, "session.attach(a-large)") + + resize_large = _rpc( + proc, + 5, + "session.resize", + {"session_id": "sess-e2e", "attachment_id": "a-large", "cols": 200, "rows": 80}, + ) + _assert_effective(resize_large, 90, 30, "session.resize(a-large)") + + detach_small = _rpc( + proc, + 6, + "session.detach", + {"session_id": "sess-e2e", "attachment_id": "a-small"}, + ) + _assert_effective(detach_small, 200, 80, "session.detach(a-small)") + + detach_large = _rpc( + proc, + 7, + "session.detach", + {"session_id": "sess-e2e", "attachment_id": "a-large"}, + ) + _assert_effective(detach_large, 200, 80, "session.detach(a-large)") + + reattach = _rpc( + proc, + 8, + "session.attach", + {"session_id": "sess-e2e", "attachment_id": "a-reconnect", "cols": 110, "rows": 40}, + ) + _assert_effective(reattach, 110, 40, "session.attach(a-reconnect)") + + status = _rpc(proc, 9, "session.status", {"session_id": "sess-e2e"}) + _assert_effective(status, 110, 40, "session.status") + attachments = (status.get("result") or {}).get("attachments") or [] + _must(len(attachments) == 1, f"session.status should report one active attachment after reattach: {status}") + + print("PASS: cmuxd-remote stdio session.resize coordinator enforces smallest-screen-wins semantics") + return 0 + finally: + try: + if proc.stdin is not None: + proc.stdin.close() + except Exception: + pass + try: + proc.terminate() + proc.wait(timeout=2.0) + except Exception: + try: + proc.kill() + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py b/tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py new file mode 100644 index 00000000..63162e76 --- /dev/null +++ b/tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Docker integration: remote daemon bootstrap must not depend on login-shell startup files.""" + +from __future__ import annotations + +import os +import secrets +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1") +DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _parse_host_port(docker_port_output: str) -> int: + text = docker_port_output.strip() + if not text: + raise cmuxError("docker port output was empty") + return int(text.split(":")[-1]) + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=5", + "-p", + str(host_port), + "-i", + str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def _wait_for_remote_connected(client: cmux, workspace_id: str, timeout: float = 45.0) -> dict: + deadline = time.time() + timeout + last_status: dict = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + proxy = remote.get("proxy") or {} + if ( + str(remote.get("state") or "") == "connected" + and str(daemon.get("state") or "") == "ready" + and str(proxy.get("state") or "") == "ready" + ): + return last_status + time.sleep(0.5) + raise cmuxError(f"Remote did not converge to connected/ready under slow login profile: {last_status}") + + +def _heartbeat_count(status: dict) -> int: + remote = status.get("remote") or {} + heartbeat = remote.get("heartbeat") or {} + raw = heartbeat.get("count") + try: + return int(raw or 0) + except Exception: # noqa: BLE001 + return 0 + + +def _wait_for_heartbeat_advance(client: cmux, workspace_id: str, minimum_count: int, timeout: float = 20.0) -> dict: + deadline = time.time() + timeout + last_status: dict = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + if _heartbeat_count(last_status) >= minimum_count: + return last_status + time.sleep(0.5) + raise cmuxError( + f"Remote heartbeat did not advance to >= {minimum_count} within {timeout:.1f}s: {last_status}" + ) + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-bootstrap-nonlogin-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-bootstrap-nonlogin-{secrets.token_hex(4)}" + workspace_id = "" + + try: + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _run( + [ + "docker", + "run", + "-d", + "--rm", + "--name", + container_name, + "-e", + f"AUTHORIZED_KEY={pubkey}", + "-p", + f"{DOCKER_PUBLISH_ADDR}::22", + image_tag, + ] + ) + + port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout + host_ssh_port = _parse_host_port(port_info) + host = f"root@{DOCKER_SSH_HOST}" + _wait_for_ssh(host, host_ssh_port, key_path) + + # Regression fixture: a slow login profile that should not block non-interactive daemon bootstrap. + _ssh_run( + host, + host_ssh_port, + key_path, + """ +cat > "$HOME/.profile" <<'EOF' +sleep 15 +echo profile-sourced >&2 +EOF +chmod 0644 "$HOME/.profile" +""", + check=True, + ) + + with cmux(SOCKET_PATH) as client: + created = client._call("workspace.create", {"initial_command": "echo ssh-bootstrap-nonlogin"}) + workspace_id = str((created or {}).get("workspace_id") or "") + _must(bool(workspace_id), f"workspace.create did not return workspace_id: {created}") + + configured = client._call( + "workspace.remote.configure", + { + "workspace_id": workspace_id, + "destination": host, + "port": host_ssh_port, + "identity_file": str(key_path), + "ssh_options": ["UserKnownHostsFile=/dev/null", "StrictHostKeyChecking=no"], + "auto_connect": True, + }, + ) + _must(bool(configured), "workspace.remote.configure returned empty response") + + status = _wait_for_remote_connected(client, workspace_id, timeout=45.0) + remote = status.get("remote") or {} + detail = str(remote.get("detail") or "").lower() + _must("timed out" not in detail, f"remote detail should not report bootstrap timeout: {status}") + + baseline_heartbeat = _heartbeat_count(status) + status = _wait_for_heartbeat_advance( + client, + workspace_id, + minimum_count=max(1, baseline_heartbeat + 1), + timeout=15.0, + ) + + opened = client._call("browser.open_split", {"workspace_id": workspace_id}) or {} + browser_surface_id = str(opened.get("surface_id") or "") + _must(bool(browser_surface_id), f"browser.open_split returned no surface_id: {opened}") + + after_open_heartbeat = _heartbeat_count(status) + status_after_blank_tab = _wait_for_heartbeat_advance( + client, + workspace_id, + minimum_count=after_open_heartbeat + 2, + timeout=20.0, + ) + remote_after_blank_tab = status_after_blank_tab.get("remote") or {} + _must( + str(remote_after_blank_tab.get("state") or "") == "connected", + f"remote should remain connected after blank browser open: {status_after_blank_tab}", + ) + heartbeat_payload = remote_after_blank_tab.get("heartbeat") or {} + _must( + heartbeat_payload.get("last_seen_at") is not None, + f"remote heartbeat should expose last_seen_at after bootstrap: {status_after_blank_tab}", + ) + + try: + client.close_workspace(workspace_id) + except Exception: + pass + workspace_id = "" + + print("PASS: remote daemon bootstrap remains healthy even when ~/.profile is slow") + return 0 + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_docker_forwarding.py b/tests_v2/test_ssh_remote_docker_forwarding.py new file mode 100644 index 00000000..6661aa5c --- /dev/null +++ b/tests_v2/test_ssh_remote_docker_forwarding.py @@ -0,0 +1,742 @@ +#!/usr/bin/env python3 +"""Docker integration: remote SSH proxy endpoint via `cmux ssh`.""" + +from __future__ import annotations + +import glob +import hashlib +import json +import os +import secrets +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import time +from base64 import b64encode +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "43173")) +REMOTE_WS_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_WS_PORT", "43174")) +MAX_REMOTE_DAEMON_SIZE_BYTES = int(os.environ.get("CMUX_SSH_TEST_MAX_DAEMON_SIZE_BYTES", "15000000")) +DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1") +DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _parse_host_port(docker_port_output: str) -> int: + # docker port output form: "127.0.0.1:49154\n" or ":::\d+". + text = docker_port_output.strip() + if not text: + raise cmuxError("docker port output was empty") + last = text.split(":")[-1] + return int(last) + + +def _curl_via_socks(proxy_port: int, target_url: str) -> str: + if shutil.which("curl") is None: + raise cmuxError("curl is required for SOCKS proxy verification") + proc = _run( + [ + "curl", + "--silent", + "--show-error", + "--max-time", + "5", + "--socks5-hostname", + f"127.0.0.1:{proxy_port}", + target_url, + ], + check=False, + ) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"curl via SOCKS proxy failed: {merged}") + return proc.stdout + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _recv_exact(sock: socket.socket, n: int) -> bytes: + out = bytearray() + while len(out) < n: + chunk = sock.recv(n - len(out)) + if not chunk: + raise cmuxError("unexpected EOF while reading socket") + out.extend(chunk) + return bytes(out) + + +def _recv_until(sock: socket.socket, marker: bytes, limit: int = 16384) -> bytes: + out = bytearray() + while marker not in out: + chunk = sock.recv(1024) + if not chunk: + raise cmuxError("unexpected EOF while reading response headers") + out.extend(chunk) + if len(out) > limit: + raise cmuxError("response headers too large") + return bytes(out) + + +def _read_socks5_connect_reply(sock: socket.socket) -> None: + head = _recv_exact(sock, 4) + if len(head) != 4 or head[0] != 0x05: + raise cmuxError(f"invalid SOCKS5 reply: {head!r}") + if head[1] != 0x00: + raise cmuxError(f"SOCKS5 connect failed with status=0x{head[1]:02x}") + + atyp = head[3] + if atyp == 0x01: + _ = _recv_exact(sock, 4) + elif atyp == 0x03: + ln = _recv_exact(sock, 1)[0] + _ = _recv_exact(sock, ln) + elif atyp == 0x04: + _ = _recv_exact(sock, 16) + else: + raise cmuxError(f"invalid SOCKS5 atyp in reply: 0x{atyp:02x}") + _ = _recv_exact(sock, 2) # bound port + + +def _read_http_response_from_connected_socket(sock: socket.socket) -> str: + response = _recv_until(sock, b"\r\n\r\n") + header_end = response.index(b"\r\n\r\n") + 4 + header_blob = response[:header_end] + body = bytearray(response[header_end:]) + header_text = header_blob.decode("utf-8", errors="replace") + + status_line = header_text.split("\r\n", 1)[0] + if "200" not in status_line: + raise cmuxError(f"HTTP over SOCKS tunnel failed: {status_line!r}") + + content_length: int | None = None + for line in header_text.split("\r\n")[1:]: + if line.lower().startswith("content-length:"): + try: + content_length = int(line.split(":", 1)[1].strip()) + except Exception: # noqa: BLE001 + content_length = None + break + + if content_length is not None: + while len(body) < content_length: + chunk = sock.recv(4096) + if not chunk: + break + body.extend(chunk) + else: + while True: + try: + chunk = sock.recv(4096) + except socket.timeout: + break + if not chunk: + break + body.extend(chunk) + + return bytes(body).decode("utf-8", errors="replace") + + +def _http_get_on_connected_socket(sock: socket.socket, host: str, port: int, path: str = "/") -> str: + request = ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + "Connection: close\r\n" + "\r\n" + ).encode("utf-8") + sock.sendall(request) + return _read_http_response_from_connected_socket(sock) + + +def _socks5_connect(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket: + sock = socket.create_connection((proxy_host, proxy_port), timeout=6) + sock.settimeout(6) + + # greeting: no-auth only + sock.sendall(b"\x05\x01\x00") + greeting = _recv_exact(sock, 2) + if greeting != b"\x05\x00": + sock.close() + raise cmuxError(f"SOCKS5 greeting failed: {greeting!r}") + + try: + host_bytes = socket.inet_aton(target_host) + atyp = b"\x01" # IPv4 + addr = host_bytes + except OSError: + host_encoded = target_host.encode("utf-8") + if len(host_encoded) > 255: + sock.close() + raise cmuxError("target host too long for SOCKS5 domain form") + atyp = b"\x03" # domain + addr = bytes([len(host_encoded)]) + host_encoded + + req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port) + sock.sendall(req) + + try: + _read_socks5_connect_reply(sock) + except Exception: + sock.close() + raise + return sock + + +def _socks5_http_get_pipelined(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str: + sock = socket.create_connection((proxy_host, proxy_port), timeout=6) + sock.settimeout(6) + try: + try: + host_bytes = socket.inet_aton(target_host) + atyp = b"\x01" + addr = host_bytes + except OSError: + host_encoded = target_host.encode("utf-8") + if len(host_encoded) > 255: + raise cmuxError("target host too long for SOCKS5 domain form") + atyp = b"\x03" + addr = bytes([len(host_encoded)]) + host_encoded + + greeting = b"\x05\x01\x00" + connect_req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port) + http_get = ( + "GET / HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n" + "Connection: close\r\n" + "\r\n" + ).encode("utf-8") + + # Send greeting + CONNECT + first upstream payload in one write to exercise + # SOCKS request parsing when pending bytes already exist in the handshake buffer. + sock.sendall(greeting + connect_req + http_get) + + greeting_reply = _recv_exact(sock, 2) + if greeting_reply != b"\x05\x00": + raise cmuxError(f"SOCKS5 greeting failed: {greeting_reply!r}") + _read_socks5_connect_reply(sock) + return _read_http_response_from_connected_socket(sock) + finally: + try: + sock.close() + except Exception: + pass + + +def _http_connect_tunnel(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket: + sock = socket.create_connection((proxy_host, proxy_port), timeout=6) + sock.settimeout(6) + request = ( + f"CONNECT {target_host}:{target_port} HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n" + "Proxy-Connection: Keep-Alive\r\n" + "\r\n" + ).encode("utf-8") + sock.sendall(request) + header_blob = _recv_until(sock, b"\r\n\r\n") + header_text = header_blob.decode("utf-8", errors="replace") + status_line = header_text.split("\r\n", 1)[0] + if "200" not in status_line: + sock.close() + raise cmuxError(f"HTTP CONNECT tunnel failed: {status_line!r}") + return sock + + +def _encode_client_text_frame(payload: str) -> bytes: + data = payload.encode("utf-8") + first = 0x81 # FIN + text + mask = secrets.token_bytes(4) + length = len(data) + if length < 126: + header = bytes([first, 0x80 | length]) + elif length <= 0xFFFF: + header = bytes([first, 0x80 | 126]) + struct.pack("!H", length) + else: + header = bytes([first, 0x80 | 127]) + struct.pack("!Q", length) + masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data)) + return header + mask + masked + + +def _read_server_text_frame(sock: socket.socket) -> str: + first, second = _recv_exact(sock, 2) + opcode = first & 0x0F + masked = (second & 0x80) != 0 + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", _recv_exact(sock, 2))[0] + elif length == 127: + length = struct.unpack("!Q", _recv_exact(sock, 8))[0] + mask = _recv_exact(sock, 4) if masked else b"" + payload = _recv_exact(sock, length) if length else b"" + if masked and payload: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + + if opcode != 0x1: + raise cmuxError(f"Expected websocket text frame opcode=0x1, got opcode=0x{opcode:x}") + try: + return payload.decode("utf-8") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"WebSocket response payload is not valid UTF-8: {exc}") + + +def _websocket_echo_on_connected_socket(sock: socket.socket, ws_host: str, ws_port: int, message: str, path_label: str) -> str: + ws_key = b64encode(secrets.token_bytes(16)).decode("ascii") + request = ( + "GET /echo HTTP/1.1\r\n" + f"Host: {ws_host}:{ws_port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {ws_key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n" + ).encode("utf-8") + sock.sendall(request) + header_blob = _recv_until(sock, b"\r\n\r\n") + header_text = header_blob.decode("utf-8", errors="replace") + status_line = header_text.split("\r\n", 1)[0] + if "101" not in status_line: + raise cmuxError(f"WebSocket handshake failed over {path_label}: {status_line!r}") + + expected_accept = b64encode( + hashlib.sha1((ws_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("utf-8")).digest() + ).decode("ascii") + lowered_headers = { + line.split(":", 1)[0].strip().lower(): line.split(":", 1)[1].strip() + for line in header_text.split("\r\n")[1:] + if ":" in line + } + if lowered_headers.get("sec-websocket-accept", "") != expected_accept: + raise cmuxError(f"WebSocket handshake over {path_label} returned invalid Sec-WebSocket-Accept") + + sock.sendall(_encode_client_text_frame(message)) + return _read_server_text_frame(sock) + + +def _websocket_echo_via_socks(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str: + sock = _socks5_connect("127.0.0.1", proxy_port, ws_host, ws_port) + try: + return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "SOCKS proxy") + finally: + try: + sock.close() + except Exception: + pass + + +def _websocket_echo_via_connect(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str: + sock = _http_connect_tunnel("127.0.0.1", proxy_port, ws_host, ws_port) + try: + return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "HTTP CONNECT proxy") + finally: + try: + sock.close() + except Exception: + pass + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=5", + "-p", + str(host_port), + "-i", + str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def _remote_binary_size_bytes(host: str, host_port: int, key_path: Path, remote_path: str) -> int: + script = f""" +set -eu +p={_shell_single_quote(remote_path)} +case "$p" in + /*) full="$p" ;; + *) full="$HOME/$p" ;; +esac +test -x "$full" +wc -c < "$full" +""" + proc = _ssh_run(host, host_port, key_path, script, check=True) + text = proc.stdout.strip().splitlines()[-1].strip() + return int(text) + + +def _extract_daemon_version_platform(remote_path: str) -> tuple[str, str]: + parts = [segment for segment in remote_path.strip().split("/") if segment] + try: + marker_index = parts.index("cmuxd-remote") + except ValueError as exc: + raise cmuxError(f"remote daemon path missing cmuxd-remote marker: {remote_path!r}") from exc + + required_len = marker_index + 4 + _must( + len(parts) >= required_len, + f"remote daemon path should include version/platform/binary: {remote_path!r}", + ) + version = parts[marker_index + 1] + platform = parts[marker_index + 2] + binary_name = parts[marker_index + 3] + _must(binary_name == "cmuxd-remote", f"unexpected daemon binary name in remote path: {remote_path!r}") + _must(bool(version), f"daemon version should not be empty in remote path: {remote_path!r}") + _must(bool(platform), f"daemon platform should not be empty in remote path: {remote_path!r}") + return version, platform + + +def _local_cached_daemon_binary(version: str, platform: str) -> Path: + return Path(tempfile.gettempdir()) / "cmux-remote-daemon-build" / version / platform / "cmuxd-remote" + + +def _local_file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _local_binary_contains_version_marker(path: Path, version: str) -> bool: + marker = version.encode("utf-8") + tail = b"" + with path.open("rb") as handle: + while True: + chunk = handle.read(1024 * 1024) + if not chunk: + return False + haystack = tail + chunk + if marker in haystack: + return True + tail = haystack[-max(len(marker) - 1, 0) :] + + +def _remote_binary_sha256(host: str, host_port: int, key_path: Path, remote_path: str) -> str: + script = f""" +set -eu +p={_shell_single_quote(remote_path)} +case "$p" in + /*) full="$p" ;; + *) full="$HOME/$p" ;; +esac +test -x "$full" +if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$full" | awk '{{print $1}}' +elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$full" | awk '{{print $1}}' +else + openssl dgst -sha256 "$full" | awk '{{print $NF}}' +fi +""" + proc = _ssh_run(host, host_port, key_path, script, check=True) + digest = proc.stdout.strip().splitlines()[-1].strip().lower() + _must(len(digest) == 64 and all(ch in "0123456789abcdef" for ch in digest), f"invalid remote SHA256 digest: {digest!r}") + return digest + + +def _wait_connected_proxy_port(client: cmux, workspace_id: str, timeout: float = 45.0) -> tuple[dict, int]: + deadline = time.time() + timeout + last_status = {} + proxy_port: int | None = None + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + state = str(remote.get("state") or "") + proxy = remote.get("proxy") or {} + port_value = proxy.get("port") + if isinstance(port_value, int): + proxy_port = port_value + elif isinstance(port_value, str) and port_value.isdigit(): + proxy_port = int(port_value) + if state == "connected" and proxy_port is not None: + return last_status, proxy_port + time.sleep(0.5) + raise cmuxError(f"Remote proxy did not converge to connected state: {last_status}") + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + + cli = _find_cli_binary() + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-docker-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-test-{secrets.token_hex(4)}" + workspace_id = "" + workspace_id_shared = "" + + try: + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _run([ + "docker", "run", "-d", "--rm", + "--name", container_name, + "-e", f"AUTHORIZED_KEY={pubkey}", + "-e", f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}", + "-p", f"{DOCKER_PUBLISH_ADDR}::22", + image_tag, + ]) + + port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout + host_ssh_port = _parse_host_port(port_info) + host = f"root@{DOCKER_SSH_HOST}" + _wait_for_ssh(host, host_ssh_port, key_path) + + fresh_check = _ssh_run( + host, + host_ssh_port, + key_path, + "test ! -e \"$HOME/.cmux/bin/cmuxd-remote\" && echo fresh", + check=True, + ) + _must("fresh" in fresh_check.stdout, "Fresh container should not have preinstalled cmuxd-remote") + + with cmux(SOCKET_PATH) as client: + payload = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", "docker-ssh-forward", + "--port", str(host_ssh_port), + "--identity", str(key_path), + "--ssh-option", "UserKnownHostsFile=/dev/null", + "--ssh-option", "StrictHostKeyChecking=no", + ], + ) + workspace_id = str(payload.get("workspace_id") or "") + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_id and workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + workspace_id = str(row.get("id") or "") + break + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + + last_status, proxy_port = _wait_connected_proxy_port(client, workspace_id) + + daemon = ((last_status.get("remote") or {}).get("daemon") or {}) + _must(str(daemon.get("state") or "") == "ready", f"daemon should be ready in connected state: {last_status}") + capabilities = daemon.get("capabilities") or [] + _must("proxy.stream" in capabilities, f"daemon hello capabilities missing proxy.stream: {daemon}") + _must("proxy.socks5" in capabilities, f"daemon hello capabilities missing proxy.socks5: {daemon}") + _must("session.basic" in capabilities, f"daemon hello capabilities missing session.basic: {daemon}") + _must("session.resize.min" in capabilities, f"daemon hello capabilities missing session.resize.min: {daemon}") + remote_path = str(daemon.get("remote_path") or "").strip() + _must(bool(remote_path), f"daemon ready state should include remote_path: {daemon}") + + binary_size_bytes = _remote_binary_size_bytes(host, host_ssh_port, key_path, remote_path) + _must(binary_size_bytes > 0, f"uploaded daemon binary should be non-empty: {binary_size_bytes}") + _must( + binary_size_bytes <= MAX_REMOTE_DAEMON_SIZE_BYTES, + f"uploaded daemon binary too large: {binary_size_bytes} bytes > {MAX_REMOTE_DAEMON_SIZE_BYTES}", + ) + daemon_version, daemon_platform = _extract_daemon_version_platform(remote_path) + local_cached_binary = _local_cached_daemon_binary(daemon_version, daemon_platform) + _must( + local_cached_binary.is_file(), + f"expected local daemon cache artifact at {local_cached_binary} after bootstrap upload", + ) + _must( + os.access(local_cached_binary, os.X_OK), + f"local daemon cache artifact must be executable: {local_cached_binary}", + ) + _must( + _local_binary_contains_version_marker(local_cached_binary, daemon_version), + f"local cached daemon binary should embed daemon version marker {daemon_version!r}: {local_cached_binary}", + ) + local_sha256 = _local_file_sha256(local_cached_binary) + remote_sha256 = _remote_binary_sha256(host, host_ssh_port, key_path, remote_path) + _must( + local_sha256 == remote_sha256, + "uploaded daemon binary hash should match local cached build artifact " + f"(local={local_sha256}, remote={remote_sha256})", + ) + + body = "" + deadline_http = time.time() + 15.0 + while time.time() < deadline_http: + try: + body = _curl_via_socks(proxy_port, f"http://127.0.0.1:{REMOTE_HTTP_PORT}/") + except Exception: + time.sleep(0.5) + continue + if "cmux-ssh-forward-ok" in body: + break + time.sleep(0.3) + + _must("cmux-ssh-forward-ok" in body, f"Forwarded HTTP endpoint returned unexpected body: {body[:120]!r}") + pipelined_body = _socks5_http_get_pipelined("127.0.0.1", proxy_port, "127.0.0.1", REMOTE_HTTP_PORT) + _must( + "cmux-ssh-forward-ok" in pipelined_body, + f"SOCKS pipelined greeting/connect+payload path returned unexpected body: {pipelined_body[:120]!r}", + ) + + ws_message = "cmux-ws-over-socks-ok" + echoed_message = _websocket_echo_via_socks(proxy_port, "127.0.0.1", REMOTE_WS_PORT, ws_message) + _must( + echoed_message == ws_message, + f"WebSocket echo over SOCKS proxy mismatch: {echoed_message!r} != {ws_message!r}", + ) + + ws_connect_message = "cmux-ws-over-connect-ok" + echoed_connect = _websocket_echo_via_connect(proxy_port, "127.0.0.1", REMOTE_WS_PORT, ws_connect_message) + _must( + echoed_connect == ws_connect_message, + f"WebSocket echo over CONNECT proxy mismatch: {echoed_connect!r} != {ws_connect_message!r}", + ) + + payload_shared = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", "docker-ssh-forward-shared", + "--port", str(host_ssh_port), + "--identity", str(key_path), + "--ssh-option", "UserKnownHostsFile=/dev/null", + "--ssh-option", "StrictHostKeyChecking=no", + ], + ) + workspace_id_shared = str(payload_shared.get("workspace_id") or "") + workspace_ref_shared = str(payload_shared.get("workspace_ref") or "") + if not workspace_id_shared and workspace_ref_shared.startswith("workspace:"): + listed_shared = client._call("workspace.list", {}) or {} + for row in listed_shared.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref_shared: + workspace_id_shared = str(row.get("id") or "") + break + _must(bool(workspace_id_shared), f"cmux ssh output missing workspace_id for shared transport test: {payload_shared}") + + _, shared_proxy_port = _wait_connected_proxy_port(client, workspace_id_shared) + _must( + shared_proxy_port == proxy_port, + f"identical SSH transports should share one local proxy endpoint: {proxy_port} vs {shared_proxy_port}", + ) + + try: + client.close_workspace(workspace_id_shared) + workspace_id_shared = "" + except Exception: + pass + + try: + client.close_workspace(workspace_id) + workspace_id = "" + except Exception: + pass + + print( + "PASS: docker SSH proxy endpoint is reachable, handles HTTP + WebSocket egress over SOCKS and CONNECT through remote host, and is shared across identical transports; " + f"uploaded cmuxd-remote size={binary_size_bytes} bytes, version={daemon_version}, platform={daemon_platform}" + ) + return 0 + + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + if workspace_id_shared: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id_shared) + except Exception: + pass + + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_docker_reconnect.py b/tests_v2/test_ssh_remote_docker_reconnect.py new file mode 100644 index 00000000..43c0e3cd --- /dev/null +++ b/tests_v2/test_ssh_remote_docker_reconnect.py @@ -0,0 +1,612 @@ +#!/usr/bin/env python3 +"""Docker integration: remote SSH reconnect after host restart.""" + +from __future__ import annotations + +import glob +import hashlib +import json +import os +import secrets +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import time +from base64 import b64encode +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "43173")) +REMOTE_WS_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_WS_PORT", "43174")) +DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1") +DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _curl_via_socks(proxy_port: int, target_url: str) -> str: + if shutil.which("curl") is None: + raise cmuxError("curl is required for SOCKS proxy verification") + proc = _run( + [ + "curl", + "--silent", + "--show-error", + "--max-time", + "5", + "--socks5-hostname", + f"127.0.0.1:{proxy_port}", + target_url, + ], + check=False, + ) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"curl via SOCKS proxy failed: {merged}") + return proc.stdout + + +def _find_free_loopback_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _recv_exact(sock: socket.socket, n: int) -> bytes: + out = bytearray() + while len(out) < n: + chunk = sock.recv(n - len(out)) + if not chunk: + raise cmuxError("unexpected EOF while reading socket") + out.extend(chunk) + return bytes(out) + + +def _recv_until(sock: socket.socket, marker: bytes, limit: int = 16384) -> bytes: + out = bytearray() + while marker not in out: + chunk = sock.recv(1024) + if not chunk: + raise cmuxError("unexpected EOF while reading response headers") + out.extend(chunk) + if len(out) > limit: + raise cmuxError("response headers too large") + return bytes(out) + + +def _read_socks5_connect_reply(sock: socket.socket) -> None: + head = _recv_exact(sock, 4) + if len(head) != 4 or head[0] != 0x05: + raise cmuxError(f"invalid SOCKS5 reply: {head!r}") + if head[1] != 0x00: + raise cmuxError(f"SOCKS5 connect failed with status=0x{head[1]:02x}") + + reply_atyp = head[3] + if reply_atyp == 0x01: + _ = _recv_exact(sock, 4) + elif reply_atyp == 0x03: + ln = _recv_exact(sock, 1)[0] + _ = _recv_exact(sock, ln) + elif reply_atyp == 0x04: + _ = _recv_exact(sock, 16) + else: + raise cmuxError(f"invalid SOCKS5 atyp in reply: 0x{reply_atyp:02x}") + _ = _recv_exact(sock, 2) + + +def _read_http_response_from_connected_socket(sock: socket.socket) -> str: + response = _recv_until(sock, b"\r\n\r\n") + header_end = response.index(b"\r\n\r\n") + 4 + header_blob = response[:header_end] + body = bytearray(response[header_end:]) + header_text = header_blob.decode("utf-8", errors="replace") + + status_line = header_text.split("\r\n", 1)[0] + if "200" not in status_line: + raise cmuxError(f"HTTP over SOCKS tunnel failed: {status_line!r}") + + content_length: int | None = None + for line in header_text.split("\r\n")[1:]: + if line.lower().startswith("content-length:"): + try: + content_length = int(line.split(":", 1)[1].strip()) + except Exception: # noqa: BLE001 + content_length = None + break + + if content_length is not None: + while len(body) < content_length: + chunk = sock.recv(4096) + if not chunk: + break + body.extend(chunk) + else: + while True: + try: + chunk = sock.recv(4096) + except socket.timeout: + break + if not chunk: + break + body.extend(chunk) + + return bytes(body).decode("utf-8", errors="replace") + + +def _socks5_connect(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket: + sock = socket.create_connection((proxy_host, proxy_port), timeout=6) + sock.settimeout(6) + + sock.sendall(b"\x05\x01\x00") + greeting = _recv_exact(sock, 2) + if greeting != b"\x05\x00": + sock.close() + raise cmuxError(f"SOCKS5 greeting failed: {greeting!r}") + + try: + host_bytes = socket.inet_aton(target_host) + atyp = b"\x01" + addr = host_bytes + except OSError: + host_encoded = target_host.encode("utf-8") + if len(host_encoded) > 255: + sock.close() + raise cmuxError("target host too long for SOCKS5 domain form") + atyp = b"\x03" + addr = bytes([len(host_encoded)]) + host_encoded + + req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port) + sock.sendall(req) + + try: + _read_socks5_connect_reply(sock) + except Exception: + sock.close() + raise + return sock + + +def _socks5_http_get_pipelined(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str: + sock = socket.create_connection((proxy_host, proxy_port), timeout=6) + sock.settimeout(6) + try: + try: + host_bytes = socket.inet_aton(target_host) + atyp = b"\x01" + addr = host_bytes + except OSError: + host_encoded = target_host.encode("utf-8") + if len(host_encoded) > 255: + raise cmuxError("target host too long for SOCKS5 domain form") + atyp = b"\x03" + addr = bytes([len(host_encoded)]) + host_encoded + + greeting = b"\x05\x01\x00" + connect_req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port) + http_get = ( + "GET / HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n" + "Connection: close\r\n" + "\r\n" + ).encode("utf-8") + + sock.sendall(greeting + connect_req + http_get) + + greeting_reply = _recv_exact(sock, 2) + if greeting_reply != b"\x05\x00": + raise cmuxError(f"SOCKS5 greeting failed: {greeting_reply!r}") + _read_socks5_connect_reply(sock) + return _read_http_response_from_connected_socket(sock) + finally: + try: + sock.close() + except Exception: + pass + + +def _http_connect_tunnel(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket: + sock = socket.create_connection((proxy_host, proxy_port), timeout=6) + sock.settimeout(6) + request = ( + f"CONNECT {target_host}:{target_port} HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n" + "Proxy-Connection: Keep-Alive\r\n" + "\r\n" + ).encode("utf-8") + sock.sendall(request) + header_blob = _recv_until(sock, b"\r\n\r\n") + header_text = header_blob.decode("utf-8", errors="replace") + status_line = header_text.split("\r\n", 1)[0] + if "200" not in status_line: + sock.close() + raise cmuxError(f"HTTP CONNECT tunnel failed: {status_line!r}") + return sock + + +def _encode_client_text_frame(payload: str) -> bytes: + data = payload.encode("utf-8") + first = 0x81 + mask = secrets.token_bytes(4) + length = len(data) + if length < 126: + header = bytes([first, 0x80 | length]) + elif length <= 0xFFFF: + header = bytes([first, 0x80 | 126]) + struct.pack("!H", length) + else: + header = bytes([first, 0x80 | 127]) + struct.pack("!Q", length) + masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data)) + return header + mask + masked + + +def _read_server_text_frame(sock: socket.socket) -> str: + first, second = _recv_exact(sock, 2) + opcode = first & 0x0F + masked = (second & 0x80) != 0 + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", _recv_exact(sock, 2))[0] + elif length == 127: + length = struct.unpack("!Q", _recv_exact(sock, 8))[0] + mask = _recv_exact(sock, 4) if masked else b"" + payload = _recv_exact(sock, length) if length else b"" + if masked and payload: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + + if opcode != 0x1: + raise cmuxError(f"Expected websocket text frame opcode=0x1, got opcode=0x{opcode:x}") + try: + return payload.decode("utf-8") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"WebSocket response payload is not valid UTF-8: {exc}") + + +def _websocket_echo_on_connected_socket(sock: socket.socket, ws_host: str, ws_port: int, message: str, path_label: str) -> str: + ws_key = b64encode(secrets.token_bytes(16)).decode("ascii") + request = ( + "GET /echo HTTP/1.1\r\n" + f"Host: {ws_host}:{ws_port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {ws_key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n" + ).encode("utf-8") + sock.sendall(request) + header_blob = _recv_until(sock, b"\r\n\r\n") + header_text = header_blob.decode("utf-8", errors="replace") + status_line = header_text.split("\r\n", 1)[0] + if "101" not in status_line: + raise cmuxError(f"WebSocket handshake failed over {path_label}: {status_line!r}") + + expected_accept = b64encode( + hashlib.sha1((ws_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("utf-8")).digest() + ).decode("ascii") + lowered_headers = { + line.split(":", 1)[0].strip().lower(): line.split(":", 1)[1].strip() + for line in header_text.split("\r\n")[1:] + if ":" in line + } + if lowered_headers.get("sec-websocket-accept", "") != expected_accept: + raise cmuxError(f"WebSocket handshake over {path_label} returned invalid Sec-WebSocket-Accept") + + sock.sendall(_encode_client_text_frame(message)) + return _read_server_text_frame(sock) + + +def _websocket_echo_via_socks(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str: + sock = _socks5_connect("127.0.0.1", proxy_port, ws_host, ws_port) + try: + return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "SOCKS proxy") + finally: + try: + sock.close() + except Exception: + pass + + +def _websocket_echo_via_connect(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str: + sock = _http_connect_tunnel("127.0.0.1", proxy_port, ws_host, ws_port) + try: + return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "HTTP CONNECT proxy") + finally: + try: + sock.close() + except Exception: + pass + + +def _start_container(image_tag: str, container_name: str, pubkey: str, host_ssh_port: int) -> None: + for _ in range(20): + proc = _run( + [ + "docker", + "run", + "-d", + "--rm", + "--name", + container_name, + "-e", + f"AUTHORIZED_KEY={pubkey}", + "-e", + f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}", + "-e", + f"REMOTE_WS_PORT={REMOTE_WS_PORT}", + "-p", + f"{DOCKER_PUBLISH_ADDR}:{host_ssh_port}:22", + image_tag, + ], + check=False, + ) + if proc.returncode == 0: + return + time.sleep(0.5) + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Failed to start ssh test container on fixed port {host_ssh_port}: {merged}") + + +def _wait_remote_connected(client: cmux, workspace_id: str, timeout: float) -> dict: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + proxy = remote.get("proxy") or {} + port_value = proxy.get("port") + proxy_port: int | None + if isinstance(port_value, int): + proxy_port = port_value + elif isinstance(port_value, str) and port_value.isdigit(): + proxy_port = int(port_value) + else: + proxy_port = None + if str(remote.get("state") or "") == "connected" and proxy_port is not None: + return last_status + time.sleep(0.5) + raise cmuxError(f"Remote did not reach connected+proxy-ready state: {last_status}") + + +def _wait_remote_degraded(client: cmux, workspace_id: str, timeout: float) -> dict: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + state = str(remote.get("state") or "") + if state in {"error", "connecting", "disconnected"}: + return last_status + time.sleep(0.5) + raise cmuxError(f"Remote did not enter reconnecting/degraded state: {last_status}") + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + + cli = _find_cli_binary() + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-reconnect-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-reconnect-{secrets.token_hex(4)}" + host_ssh_port = _find_free_loopback_port() + workspace_id = "" + container_running = False + + try: + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _start_container(image_tag, container_name, pubkey, host_ssh_port) + container_running = True + + with cmux(SOCKET_PATH) as client: + payload = _run_cli_json( + cli, + [ + "ssh", + f"root@{DOCKER_SSH_HOST}", + "--name", + "docker-ssh-reconnect", + "--port", + str(host_ssh_port), + "--identity", + str(key_path), + "--ssh-option", + "UserKnownHostsFile=/dev/null", + "--ssh-option", + "StrictHostKeyChecking=no", + ], + ) + workspace_id = str(payload.get("workspace_id") or "") + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_id and workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + workspace_id = str(row.get("id") or "") + break + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + + first_status = _wait_remote_connected(client, workspace_id, timeout=45.0) + first_daemon = ((first_status.get("remote") or {}).get("daemon") or {}) + _must(str(first_daemon.get("state") or "") == "ready", f"daemon should be ready after first connect: {first_status}") + first_capabilities = {str(item) for item in (first_daemon.get("capabilities") or [])} + _must("proxy.stream" in first_capabilities, f"daemon should advertise proxy.stream: {first_status}") + _must("proxy.socks5" in first_capabilities, f"daemon should advertise proxy.socks5: {first_status}") + _must("proxy.http_connect" in first_capabilities, f"daemon should advertise proxy.http_connect: {first_status}") + first_proxy = ((first_status.get("remote") or {}).get("proxy") or {}) + first_proxy_port = first_proxy.get("port") + if isinstance(first_proxy_port, str) and first_proxy_port.isdigit(): + first_proxy_port = int(first_proxy_port) + _must(isinstance(first_proxy_port, int), f"connected status should include proxy port: {first_status}") + + first_body = "" + first_deadline_http = time.time() + 15.0 + while time.time() < first_deadline_http: + try: + first_body = _curl_via_socks(int(first_proxy_port), f"http://127.0.0.1:{REMOTE_HTTP_PORT}/") + except Exception: + time.sleep(0.5) + continue + if "cmux-ssh-forward-ok" in first_body: + break + time.sleep(0.3) + _must("cmux-ssh-forward-ok" in first_body, f"Forwarded HTTP endpoint failed before reconnect: {first_body[:120]!r}") + first_pipelined_body = _socks5_http_get_pipelined("127.0.0.1", int(first_proxy_port), "127.0.0.1", REMOTE_HTTP_PORT) + _must( + "cmux-ssh-forward-ok" in first_pipelined_body, + f"SOCKS pipelined greeting/connect+payload failed before reconnect: {first_pipelined_body[:120]!r}", + ) + + first_ws_socks_message = "cmux-reconnect-before-over-socks" + echoed_before_socks = _websocket_echo_via_socks(int(first_proxy_port), "127.0.0.1", REMOTE_WS_PORT, first_ws_socks_message) + _must( + echoed_before_socks == first_ws_socks_message, + f"WebSocket echo over SOCKS proxy failed before reconnect: {echoed_before_socks!r} != {first_ws_socks_message!r}", + ) + + first_ws_connect_message = "cmux-reconnect-before-over-connect" + echoed_before_connect = _websocket_echo_via_connect(int(first_proxy_port), "127.0.0.1", REMOTE_WS_PORT, first_ws_connect_message) + _must( + echoed_before_connect == first_ws_connect_message, + f"WebSocket echo over CONNECT proxy failed before reconnect: {echoed_before_connect!r} != {first_ws_connect_message!r}", + ) + + _run(["docker", "rm", "-f", container_name], check=False) + container_running = False + _wait_remote_degraded(client, workspace_id, timeout=20.0) + + _start_container(image_tag, container_name, pubkey, host_ssh_port) + container_running = True + + second_status = _wait_remote_connected(client, workspace_id, timeout=60.0) + second_daemon = ((second_status.get("remote") or {}).get("daemon") or {}) + _must(str(second_daemon.get("state") or "") == "ready", f"daemon should be ready after reconnect: {second_status}") + second_capabilities = {str(item) for item in (second_daemon.get("capabilities") or [])} + _must("proxy.stream" in second_capabilities, f"daemon should advertise proxy.stream after reconnect: {second_status}") + _must("proxy.socks5" in second_capabilities, f"daemon should advertise proxy.socks5 after reconnect: {second_status}") + _must("proxy.http_connect" in second_capabilities, f"daemon should advertise proxy.http_connect after reconnect: {second_status}") + second_proxy = ((second_status.get("remote") or {}).get("proxy") or {}) + second_proxy_port = second_proxy.get("port") + if isinstance(second_proxy_port, str) and second_proxy_port.isdigit(): + second_proxy_port = int(second_proxy_port) + _must(isinstance(second_proxy_port, int), f"reconnected status should include proxy port: {second_status}") + + second_body = "" + deadline_http = time.time() + 15.0 + while time.time() < deadline_http: + try: + second_body = _curl_via_socks(int(second_proxy_port), f"http://127.0.0.1:{REMOTE_HTTP_PORT}/") + except Exception: + time.sleep(0.5) + continue + if "cmux-ssh-forward-ok" in second_body: + break + time.sleep(0.3) + _must("cmux-ssh-forward-ok" in second_body, f"Forwarded HTTP endpoint failed after reconnect: {second_body[:120]!r}") + second_pipelined_body = _socks5_http_get_pipelined("127.0.0.1", int(second_proxy_port), "127.0.0.1", REMOTE_HTTP_PORT) + _must( + "cmux-ssh-forward-ok" in second_pipelined_body, + f"SOCKS pipelined greeting/connect+payload failed after reconnect: {second_pipelined_body[:120]!r}", + ) + + second_ws_socks_message = "cmux-reconnect-after-over-socks" + echoed_after_socks = _websocket_echo_via_socks(int(second_proxy_port), "127.0.0.1", REMOTE_WS_PORT, second_ws_socks_message) + _must( + echoed_after_socks == second_ws_socks_message, + f"WebSocket echo over SOCKS proxy failed after reconnect: {echoed_after_socks!r} != {second_ws_socks_message!r}", + ) + + second_ws_connect_message = "cmux-reconnect-after-over-connect" + echoed_after_connect = _websocket_echo_via_connect(int(second_proxy_port), "127.0.0.1", REMOTE_WS_PORT, second_ws_connect_message) + _must( + echoed_after_connect == second_ws_connect_message, + f"WebSocket echo over CONNECT proxy failed after reconnect: {echoed_after_connect!r} != {second_ws_connect_message!r}", + ) + + try: + client.close_workspace(workspace_id) + except Exception: + pass + workspace_id = "" + + print("PASS: docker SSH remote reconnects and re-establishes HTTP + WebSocket egress over SOCKS and CONNECT") + return 0 + + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + if container_running: + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_interactive_cmux_command_regression.py b/tests_v2/test_ssh_remote_interactive_cmux_command_regression.py new file mode 100644 index 00000000..a064115c --- /dev/null +++ b/tests_v2/test_ssh_remote_interactive_cmux_command_regression.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Regression: interactive `cmux ssh` shells must resolve `cmux` to the relay wrapper.""" + +from __future__ import annotations + +import glob +import json +import os +import re +import secrets +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip() + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + import subprocess + + proc = subprocess.run( + [cli, "--socket", SOCKET_PATH, "--json", *args], + capture_output=True, + text=True, + check=False, + env=env, + ) + if proc.returncode != 0: + raise cmuxError(f"CLI failed ({' '.join(args)}): {(proc.stdout + proc.stderr).strip()}") + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _workspace_id_from_payload(client: cmux, payload: dict) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + workspace_ref = str(payload.get("workspace_ref") or "") + if workspace_ref.startswith("workspace:"): + rows = (client._call("workspace.list", {}) or {}).get("workspaces") or [] + for row in rows: + if str(row.get("ref") or "") == workspace_ref: + return str(row.get("id") or "") + return "" + + +def _wait_remote_ready(client: cmux, workspace_id: str, timeout: float = 25.0) -> None: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": + return + time.sleep(0.25) + raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}") + + +def _wait_surface_id(client: cmux, workspace_id: str, timeout: float = 10.0) -> str: + deadline = time.time() + timeout + while time.time() < deadline: + surfaces = client.list_surfaces(workspace_id) + if surfaces: + return str(surfaces[0][1]) + time.sleep(0.1) + raise cmuxError(f"No terminal surface appeared for workspace {workspace_id}") + + +def _wait_text(client: cmux, surface_id: str, token: str, timeout: float = 12.0) -> str: + deadline = time.time() + timeout + last = "" + while time.time() < deadline: + last = client.read_terminal_text(surface_id) + if token in last: + return last + time.sleep(0.15) + raise cmuxError(f"Timed out waiting for {token!r} in surface {surface_id}: {last[-1200:]!r}") + + +def _wait_shell_ready(client: cmux, surface_id: str, timeout: float = 20.0) -> None: + token = f"__CMUX_SHELL_READY_{secrets.token_hex(6)}__" + client.send_surface(surface_id, f"printf '{token}'; echo") + client.send_key_surface(surface_id, "enter") + _wait_text(client, surface_id, token, timeout=timeout) + + +def _assert_no_login_profile_noise(text: str) -> None: + _must( + "/Users/cmux/.profile:" not in text, + f"interactive ssh shell should not source ~/.profile via the bootstrap wrapper: {text[-1200:]!r}", + ) + _must( + "No such file or directory" not in text, + f"interactive ssh shell still emitted startup file noise: {text[-1200:]!r}", + ) + + +def _run_remote_shell_command(client: cmux, surface_id: str, command: str, timeout: float = 12.0) -> tuple[int, str, str]: + token = f"__CMUX_REMOTE_CMD_{secrets.token_hex(6)}__" + start_marker = f"{token}:START" + status_marker = f"{token}:STATUS" + end_marker = f"{token}:END" + client.send_surface( + surface_id, + ( + f"printf '{start_marker}'; echo; " + f"{command}; " + "__cmux_status=$?; " + f"printf '{status_marker}:%s' \"$__cmux_status\"; echo; " + f"printf '{end_marker}'; echo" + ), + ) + client.send_key_surface(surface_id, "enter") + deadline = time.time() + timeout + text = "" + while time.time() < deadline: + text = client.read_terminal_text(surface_id) + if ( + text.count(start_marker) >= 2 + and text.count(status_marker) >= 2 + and text.count(end_marker) >= 2 + ): + break + time.sleep(0.15) + pattern = re.compile( + re.escape(start_marker) + r"\n(.*?)" + re.escape(status_marker) + r":(\d+)\n" + re.escape(end_marker), + re.S, + ) + matches = pattern.findall(text) + if not matches: + raise cmuxError(f"Missing command result token for {command!r}: {text[-1200:]!r}") + output, status_raw = matches[-1] + return int(status_raw), output, text + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run interactive ssh cmux command regression") + return 0 + + cli = _find_cli_binary() + workspace_ids: list[str] = [] + try: + with cmux(SOCKET_PATH) as client: + payload = _run_cli_json(cli, ["ssh", SSH_HOST]) + workspace_id = _workspace_id_from_payload(client, payload) + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + workspace_ids.append(workspace_id) + + _wait_remote_ready(client, workspace_id) + surface_id = _wait_surface_id(client, workspace_id) + initial_text = client.read_terminal_text(surface_id) + _assert_no_login_profile_noise(initial_text) + _wait_shell_ready(client, surface_id) + shell_ready_text = client.read_terminal_text(surface_id) + _assert_no_login_profile_noise(shell_ready_text) + + which_status, which_output, which_text = _run_remote_shell_command(client, surface_id, "command -v cmux") + _must(which_status == 0, f"`command -v cmux` failed: output={which_output!r} tail={which_text[-1200:]!r}") + _must( + "/.cmux/bin/cmux" in which_output, + f"interactive ssh shell should resolve cmux to relay wrapper, got {which_output!r}", + ) + + ping_status, ping_output, ping_text = _run_remote_shell_command(client, surface_id, "cmux ping") + _must(ping_status == 0, f"`cmux ping` failed in interactive shell: output={ping_output!r} tail={ping_text[-1200:]!r}") + _must("pong" in ping_output.lower(), f"`cmux ping` should return pong, got {ping_output!r}") + _must( + "Socket not found at 127.0.0.1:" not in ping_text, + f"interactive ssh shell still routed cmux to a unix-socket-only binary: {ping_text[-1200:]!r}", + ) + _must( + "waiting for relay on 127.0.0.1:" not in ping_text and "failed to connect to 127.0.0.1:" not in ping_text, + f"`cmux ping` hit a dead ssh relay instead of the local app socket: {ping_text[-1200:]!r}", + ) + + notify_status, notify_output, notify_text = _run_remote_shell_command( + client, + surface_id, + "cmux notify --body interactive-ssh-regression", + ) + _must( + notify_status == 0, + f"`cmux notify` failed in interactive shell: output={notify_output!r} tail={notify_text[-1200:]!r}", + ) + _must( + "Socket not found at 127.0.0.1:" not in notify_text, + f"`cmux notify` still failed via wrong cmux binary: {notify_text[-1200:]!r}", + ) + _must( + "waiting for relay on 127.0.0.1:" not in notify_text and "failed to connect to 127.0.0.1:" not in notify_text, + f"`cmux notify` still failed because the ssh relay listener was not running: {notify_text[-1200:]!r}", + ) + + shell_status, shell_output, shell_text = _run_remote_shell_command( + client, + surface_id, + r'''printf 'TERM=%s\n' "${TERM:-}"; printf 'TERM_PROGRAM=%s\n' "${TERM_PROGRAM:-}"; printf 'TERM_PROGRAM_VERSION=%s\n' "${TERM_PROGRAM_VERSION:-}"; printf 'GHOSTTY_SHELL_FEATURES=%s\n' "${GHOSTTY_SHELL_FEATURES:-}"; bindkey "^A"; bindkey "^K"; bindkey "^[^?"; bindkey "^[b"; bindkey "^[f"''', + ) + _must(shell_status == 0, f"ssh shell env/bindkey probe failed: output={shell_output!r} tail={shell_text[-1200:]!r}") + _must("TERM=xterm-ghostty" in shell_output, f"ssh shell lost TERM=xterm-ghostty: {shell_output!r}") + _must("TERM_PROGRAM=ghostty" in shell_output, f"ssh shell lost TERM_PROGRAM=ghostty: {shell_output!r}") + _must("GHOSTTY_SHELL_FEATURES=" in shell_output, f"ssh shell lost GHOSTTY_SHELL_FEATURES: {shell_output!r}") + _must("ssh-env" in shell_output, f"ssh shell missing ssh-env feature: {shell_output!r}") + _must("ssh-terminfo" in shell_output, f"ssh shell missing ssh-terminfo feature: {shell_output!r}") + _must('"^A" beginning-of-line' in shell_output, f"Ctrl-A binding regressed in ssh shell: {shell_output!r}") + _must('"^K" kill-line' in shell_output, f"Ctrl-K binding regressed in ssh shell: {shell_output!r}") + _must('"^[^?" backward-kill-word' in shell_output, f"Opt-Backspace binding regressed in ssh shell: {shell_output!r}") + _must('"^[b" backward-word' in shell_output, f"Opt-Left binding regressed in ssh shell: {shell_output!r}") + _must('"^[f" forward-word' in shell_output, f"Opt-Right binding regressed in ssh shell: {shell_output!r}") + finally: + if workspace_ids: + try: + with cmux(SOCKET_PATH) as client: + for workspace_id in workspace_ids: + try: + client._call("workspace.close", {"workspace_id": workspace_id}) + except Exception: + pass + except Exception: + pass + + print("PASS: interactive ssh shell resolves cmux to relay wrapper and remote cmux commands succeed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_last_surface_clears_remote_state.py b/tests_v2/test_ssh_remote_last_surface_clears_remote_state.py new file mode 100644 index 00000000..91af772d --- /dev/null +++ b/tests_v2/test_ssh_remote_last_surface_clears_remote_state.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Regression: closing the last SSH surface should clear remote workspace state.""" + +from __future__ import annotations + +import glob +import json +import os +import re +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip() +SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip() +SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip() +SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip() + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 45.0) -> None: + deadline = time.time() + timeout_s + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": + return + time.sleep(0.25) + raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}") + + +def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + + workspace_ref = str(payload.get("workspace_ref") or "") + if workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + resolved = str(row.get("id") or "") + if resolved: + return resolved + + current = {wid for _index, wid, _title, _focused in client.list_workspaces()} + new_ids = sorted(current - before_workspace_ids) + if len(new_ids) == 1: + return new_ids[0] + + raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}") + + +def _workspace_row(client: cmux, workspace_id: str) -> dict: + rows = (client._call("workspace.list", {}) or {}).get("workspaces") or [] + for row in rows: + if str(row.get("id") or "") == workspace_id: + return row + raise cmuxError(f"workspace.list missing {workspace_id}: {rows}") + + +def _remote_session_count(client: cmux, workspace_id: str) -> int: + row = _workspace_row(client, workspace_id) + remote = row.get("remote") or {} + return int(remote.get("active_terminal_sessions") or 0) + + +def _run_surface_probe(client: cmux, surface_id: str, command: str, token_prefix: str, timeout_s: float = 12.0) -> str: + token = f"__CMUX_{token_prefix}_{int(time.time() * 1000)}__" + client.send_surface( + surface_id, + ( + f"printf '{token}:START'; echo; " + f"{command}; " + f"printf '{token}:END'; echo" + ), + ) + client.send_key_surface(surface_id, "enter") + deadline = time.time() + timeout_s + last = "" + pattern = re.compile(re.escape(token) + r":START\n(.*?)" + re.escape(token) + r":END", re.S) + while time.time() < deadline: + last = client.read_terminal_text(surface_id) + matches = pattern.findall(last) + if matches: + return matches[-1] + time.sleep(0.15) + raise cmuxError(f"Timed out waiting for probe {token!r}: {last[-1200:]!r}") + + +def _open_ssh_workspace(client: cmux, cli: str, *, name: str) -> str: + before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()} + + ssh_args = ["ssh", SSH_HOST, "--name", name] + if SSH_PORT: + ssh_args.extend(["--port", SSH_PORT]) + if SSH_IDENTITY: + ssh_args.extend(["--identity", SSH_IDENTITY]) + if SSH_OPTIONS_RAW: + for option in SSH_OPTIONS_RAW.split(","): + trimmed = option.strip() + if trimmed: + ssh_args.extend(["--ssh-option", trimmed]) + + payload = _run_cli_json(cli, ssh_args) + workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids) + _wait_remote_ready(client, workspace_id) + client.select_workspace(workspace_id) + _wait_for(lambda: client.current_workspace() == workspace_id, timeout_s=8.0) + return workspace_id + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run ssh last-surface remote state regression") + return 0 + + cli = _find_cli_binary() + workspace_id = "" + + try: + with cmux(SOCKET_PATH) as client: + workspace_id = _open_ssh_workspace( + client, + cli, + name=f"ssh-last-surface-{int(time.time())}", + ) + + row = _workspace_row(client, workspace_id) + remote = row.get("remote") or {} + _must(bool(remote.get("enabled")) is True, f"workspace should start as remote-enabled: {row}") + _must(int(remote.get("active_terminal_sessions") or 0) == 1, f"workspace should start with one active ssh terminal session: {row}") + + surfaces = client.list_surfaces(workspace_id) + _must(len(surfaces) == 1, f"expected one initial ssh surface, got {surfaces}") + + split_surface_id = client.new_split("right") + _wait_for(lambda: len(client.list_surfaces(workspace_id)) == 2, timeout_s=10.0, step_s=0.1) + _wait_for(lambda: _remote_session_count(client, workspace_id) == 2, timeout_s=10.0, step_s=0.1) + + client.send_surface(split_surface_id, "exit") + client.send_key_surface(split_surface_id, "enter") + _wait_for(lambda: _remote_session_count(client, workspace_id) == 1, timeout_s=15.0, step_s=0.15) + + row_after_first_exit = _workspace_row(client, workspace_id) + remote_after_first_exit = row_after_first_exit.get("remote") or {} + _must(bool(remote_after_first_exit.get("enabled")) is True, f"workspace should stay remote while one ssh terminal remains: {row_after_first_exit}") + + remaining_surface_id = next( + surface_id + for _index, surface_id, _focused in client.list_surfaces(workspace_id) + if surface_id != split_surface_id + ) + client.send_surface(remaining_surface_id, "exit") + client.send_key_surface(remaining_surface_id, "enter") + + def _remote_cleared() -> bool: + row_now = _workspace_row(client, workspace_id) + remote_now = row_now.get("remote") or {} + if bool(remote_now.get("enabled")): + return False + surfaces_now = client.list_surfaces(workspace_id) + return len(surfaces_now) == 2 + + _wait_for(_remote_cleared, timeout_s=15.0, step_s=0.15) + + final_row = _workspace_row(client, workspace_id) + final_remote = final_row.get("remote") or {} + _must(bool(final_remote.get("enabled")) is False, f"workspace remote metadata should clear after last ssh surface closes: {final_row}") + _must(str(final_remote.get("state") or "") == "disconnected", f"workspace should end disconnected after remote metadata clears: {final_row}") + _must(int(final_remote.get("active_terminal_sessions") or 0) == 0, f"workspace should report zero active ssh terminal sessions after last ssh surface closes: {final_row}") + + local_surface_ids = [surface_id for _index, surface_id, _focused in client.list_surfaces(workspace_id)] + _must(len(local_surface_ids) == 2, f"expected both panes to remain as local terminals after ssh exits, got {local_surface_ids}") + for idx, surface_id in enumerate(local_surface_ids): + socket_output = _run_surface_probe( + client, + surface_id, + r'''printf '%s' "${CMUX_SOCKET_PATH:-}"''', + f"SSH_LAST_SURFACE_SOCKET_{idx}", + ).strip() + _must( + not socket_output.startswith("127.0.0.1:"), + f"surface {surface_id} should be local after clearing remote state, got CMUX_SOCKET_PATH={socket_output!r}", + ) + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client._call("workspace.close", {"workspace_id": workspace_id}) + except Exception: + pass + + print("PASS: exiting all ssh panes clears remote workspace state while fallback local panes remain local") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_proxy_bind_conflict.py b/tests_v2/test_ssh_remote_proxy_bind_conflict.py new file mode 100644 index 00000000..4828c20e --- /dev/null +++ b/tests_v2/test_ssh_remote_proxy_bind_conflict.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Docker integration: local proxy bind conflict surfaces proxy_unavailable.""" + +from __future__ import annotations + +import glob +import os +import secrets +import shutil +import socket +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1") +DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _parse_host_port(docker_port_output: str) -> int: + text = docker_port_output.strip() + if not text: + raise cmuxError("docker port output was empty") + last = text.split(":")[-1] + return int(last) + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=5", + "-p", + str(host_port), + "-i", + str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def _find_free_loopback_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _wait_for_proxy_conflict_status(client: cmux, workspace_id: str, expected_local_proxy_port: int, timeout: float = 30.0) -> dict: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + proxy = remote.get("proxy") or {} + daemon = remote.get("daemon") or {} + if str(remote.get("state") or "") == "error" and str(proxy.get("state") or "") == "error": + detail = str(remote.get("detail") or "") + _must( + proxy.get("error_code") == "proxy_unavailable", + f"proxy error should be proxy_unavailable under bind conflict: {last_status}", + ) + _must( + int(remote.get("local_proxy_port") or 0) == expected_local_proxy_port, + f"remote status should retain configured local_proxy_port under bind conflict: {last_status}", + ) + _must( + ( + "Failed to start local daemon proxy" in detail + or "Local proxy listener failed" in detail + ), + f"remote detail should surface local proxy bind failure: {last_status}", + ) + _must( + "Address already in use" in detail, + f"remote detail should preserve bind-conflict root cause: {last_status}", + ) + _must( + str(daemon.get("state") or "") == "ready", + f"daemon should remain ready for local-only bind conflicts: {last_status}", + ) + return last_status + time.sleep(0.5) + + raise cmuxError(f"Remote did not reach structured proxy_unavailable status for bind conflict: {last_status}") + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + + _ = _find_cli_binary() # enforce same test prerequisites as other SSH remote suites + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-proxy-conflict-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-proxy-conflict-{secrets.token_hex(4)}" + workspace_id = "" + conflict_listener: socket.socket | None = None + + try: + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _run([ + "docker", "run", "-d", "--rm", + "--name", container_name, + "-e", f"AUTHORIZED_KEY={pubkey}", + "-p", f"{DOCKER_PUBLISH_ADDR}::22", + image_tag, + ]) + + port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout + host_ssh_port = _parse_host_port(port_info) + host = f"root@{DOCKER_SSH_HOST}" + _wait_for_ssh(host, host_ssh_port, key_path) + + conflict_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + conflict_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + conflict_listener.bind(("127.0.0.1", 0)) + conflict_port = int(conflict_listener.getsockname()[1]) + conflict_listener.listen(1) + + with cmux(SOCKET_PATH) as client: + created = client._call("workspace.create", {"initial_command": "echo ssh-proxy-conflict"}) + workspace_id = str((created or {}).get("workspace_id") or "") + _must(bool(workspace_id), f"workspace.create did not return workspace_id: {created}") + + configured = client._call("workspace.remote.configure", { + "workspace_id": workspace_id, + "destination": host, + "port": host_ssh_port, + "identity_file": str(key_path), + "ssh_options": ["UserKnownHostsFile=/dev/null", "StrictHostKeyChecking=no"], + "auto_connect": True, + "local_proxy_port": conflict_port, + }) + _must(bool(configured), "workspace.remote.configure returned empty response") + + _ = _wait_for_proxy_conflict_status( + client, + workspace_id, + expected_local_proxy_port=conflict_port, + timeout=30.0, + ) + + try: + client.close_workspace(workspace_id) + except Exception: + pass + workspace_id = "" + + print("PASS: local proxy bind conflict surfaces structured proxy_unavailable without degrading daemon readiness") + return 0 + + finally: + if conflict_listener is not None: + try: + conflict_listener.close() + except Exception: + pass + + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_resize_scrollback_regression.py b/tests_v2/test_ssh_remote_resize_scrollback_regression.py new file mode 100644 index 00000000..ff70110e --- /dev/null +++ b/tests_v2/test_ssh_remote_resize_scrollback_regression.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +"""Regression: ssh workspace keeps large pre-resize scrollback across split resize churn.""" + +from __future__ import annotations + +import glob +import json +import os +import re +import secrets +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux.sock") +SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip() +SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip() +SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip() +SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip() +LS_ENTRY_COUNT = int(os.environ.get("CMUX_SSH_TEST_LS_COUNT", "320")) +RESIZE_ITERATIONS = int(os.environ.get("CMUX_SSH_TEST_RESIZE_ITERATIONS", "48")) + +ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _wait_remote_connected(client: cmux, workspace_id: str, timeout_s: float = 45.0) -> None: + deadline = time.time() + timeout_s + last = {} + while time.time() < deadline: + last = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last.get("remote") or {} + daemon = remote.get("daemon") or {} + if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": + return + time.sleep(0.25) + raise cmuxError(f"Remote did not reach connected+ready state: {last}") + + +def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + + workspace_ref = str(payload.get("workspace_ref") or "") + if workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + resolved = str(row.get("id") or "") + if resolved: + return resolved + + current = {wid for _index, wid, _title, _focused in client.list_workspaces()} + new_ids = sorted(current - before_workspace_ids) + if len(new_ids) == 1: + return new_ids[0] + + raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}") + + +def _clean_line(raw: str) -> str: + line = OSC_ESCAPE_RE.sub("", raw) + line = ANSI_ESCAPE_RE.sub("", line) + line = line.replace("\r", "") + return line.strip() + + +def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str: + payload = client._call( + "surface.read_text", + {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, + ) or {} + return str(payload.get("text") or "") + + +def _surface_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]: + return [_clean_line(raw) for raw in _surface_scrollback_text(client, workspace_id, surface_id).splitlines()] + + +def _wait_surface_contains( + client: cmux, + workspace_id: str, + surface_id: str, + token: str, + *, + exact_line: bool = False, + timeout_s: float = 25.0, +) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if exact_line: + if token in _surface_scrollback_lines(client, workspace_id, surface_id): + return + elif token in _surface_scrollback_text(client, workspace_id, surface_id): + return + time.sleep(0.2) + raise cmuxError(f"Timed out waiting for terminal token: {token}") + + +def _pane_for_surface(client: cmux, surface_id: str) -> str: + target_id = str(client._resolve_surface_id(surface_id)) + for _idx, pane_id, _count, _focused in client.list_panes(): + rows = client.list_pane_surfaces(pane_id) + for _row_idx, sid, _title, _selected in rows: + try: + candidate_id = str(client._resolve_surface_id(sid)) + except cmuxError: + continue + if candidate_id == target_id: + return pane_id + raise cmuxError(f"Surface {surface_id} is not present in current workspace panes") + + +def _valid_resize_directions(client: cmux, workspace_id: str, pane_id: str) -> list[str]: + valid: list[str] = [] + for direction in ("left", "right", "up", "down"): + try: + client._call( + "pane.resize", + { + "workspace_id": workspace_id, + "pane_id": pane_id, + "direction": direction, + "amount": 10, + }, + ) + valid.append(direction) + except cmuxError: + pass + return valid + + +def _choose_resize_pair(client: cmux, workspace_id: str, pane_ids: list[str]) -> list[tuple[str, str]]: + by_pane: dict[str, list[str]] = {} + for pane_id in pane_ids: + by_pane[pane_id] = _valid_resize_directions(client, workspace_id, pane_id) + + for pane_a, directions_a in by_pane.items(): + if "right" not in directions_a: + continue + for pane_b, directions_b in by_pane.items(): + if pane_b == pane_a: + continue + if "left" in directions_b: + return [(pane_a, "right"), (pane_b, "left")] + + for pane_a, directions_a in by_pane.items(): + if "down" not in directions_a: + continue + for pane_b, directions_b in by_pane.items(): + if pane_b == pane_a: + continue + if "up" in directions_b: + return [(pane_a, "down"), (pane_b, "up")] + + raise cmuxError(f"Could not find oscillating resize pair across panes: {by_pane}") + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run remote resize scrollback regression") + return 0 + if LS_ENTRY_COUNT < 64: + print("SKIP: CMUX_SSH_TEST_LS_COUNT must be >= 64 for meaningful scrollback coverage") + return 0 + + cli = _find_cli_binary() + workspace_id = "" + + try: + with cmux(SOCKET_PATH) as client: + before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()} + + ssh_args = ["ssh", SSH_HOST, "--name", f"ssh-resize-regression-{secrets.token_hex(4)}"] + if SSH_PORT: + ssh_args.extend(["--port", SSH_PORT]) + if SSH_IDENTITY: + ssh_args.extend(["--identity", SSH_IDENTITY]) + if SSH_OPTIONS_RAW: + for option in SSH_OPTIONS_RAW.split(","): + trimmed = option.strip() + if trimmed: + ssh_args.extend(["--ssh-option", trimmed]) + + payload = _run_cli_json(cli, ssh_args) + workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids) + _wait_remote_connected(client, workspace_id, timeout_s=50.0) + + surfaces = client.list_surfaces(workspace_id) + _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") + surface_id = surfaces[0][1] + + stamp = secrets.token_hex(4) + ls_entries = [f"CMUX_REMOTE_RESIZE_LS_{stamp}_{index:04d}.txt" for index in range(1, LS_ENTRY_COUNT + 1)] + ls_start = f"CMUX_REMOTE_RESIZE_LS_START_{stamp}" + ls_end = f"CMUX_REMOTE_RESIZE_LS_END_{stamp}" + + ls_prefix = f"CMUX_REMOTE_RESIZE_LS_{stamp}_" + ls_script = ( + "tmpdir=$(mktemp -d); " + f"echo {ls_start}; " + f"for i in $(seq 1 {LS_ENTRY_COUNT}); do " + "n=$(printf '%04d' \"$i\"); " + f"touch \"$tmpdir/{ls_prefix}$n.txt\"; " + "done; " + "LC_ALL=C CLICOLOR=0 ls -1 \"$tmpdir\"; " + f"echo {ls_end}; " + "rm -rf \"$tmpdir\"" + ) + client.send_surface(surface_id, f"{ls_script}\n") + _wait_surface_contains( + client, + workspace_id, + surface_id, + ls_end, + exact_line=True, + timeout_s=45.0, + ) + + pre_resize_lines = _surface_scrollback_lines(client, workspace_id, surface_id) + _must( + all(entry in pre_resize_lines for entry in ls_entries), + "pre-resize scrollback missing ls fixture lines in ssh workspace", + ) + pre_resize_anchors = [ls_entries[0], ls_entries[len(ls_entries) // 2], ls_entries[-1]] + + client.select_workspace(workspace_id) + client.activate_app() + pane_count_before_split = len(client.list_panes()) + client.simulate_shortcut("cmd+d") + _wait_for(lambda: len(client.list_panes()) >= pane_count_before_split + 1, timeout_s=10.0) + + # Ensure the original surface remains selected before resize churn. + client.focus_surface(surface_id) + pane_ids = [pid for _idx, pid, _count, _focused in client.list_panes()] + _must(len(pane_ids) >= 2, f"expected split workspace with >=2 panes: {pane_ids}") + _ = _pane_for_surface(client, surface_id) + resize_pair = _choose_resize_pair(client, workspace_id, pane_ids) + + for iteration in range(1, RESIZE_ITERATIONS + 1): + pane_id, direction = resize_pair[(iteration - 1) % len(resize_pair)] + _ = client._call( + "pane.resize", + { + "workspace_id": workspace_id, + "pane_id": pane_id, + "direction": direction, + "amount": 80, + }, + ) + if iteration % 8 == 0: + sampled_lines = _surface_scrollback_lines(client, workspace_id, surface_id) + _must( + all(anchor in sampled_lines for anchor in pre_resize_anchors), + f"resize iteration {iteration} lost pre-resize anchor lines in ssh workspace", + ) + + post_token = f"CMUX_REMOTE_RESIZE_POST_{secrets.token_hex(6)}" + client.send_surface(surface_id, f"echo {post_token}\n") + _wait_surface_contains( + client, + workspace_id, + surface_id, + post_token, + exact_line=True, + timeout_s=25.0, + ) + + post_resize_lines = _surface_scrollback_lines(client, workspace_id, surface_id) + _must( + all(entry in post_resize_lines for entry in ls_entries), + "post-resize scrollback lost ls fixture lines in ssh workspace", + ) + _must( + post_token in post_resize_lines, + f"post-resize scrollback missing post token: {post_token}", + ) + + client.close_workspace(workspace_id) + workspace_id = "" + + print( + "PASS: cmux ssh split+resize churn preserved large pre-resize scrollback " + f"(entries={LS_ENTRY_COUNT}, iterations={RESIZE_ITERATIONS})" + ) + return 0 + + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_second_session_mux_regression.py b/tests_v2/test_ssh_remote_second_session_mux_regression.py new file mode 100644 index 00000000..d17b23ae --- /dev/null +++ b/tests_v2/test_ssh_remote_second_session_mux_regression.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Regression: opening a second `cmux ssh` workspace to the same host must not mux-refuse.""" + +from __future__ import annotations + +import glob +import json +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip() + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + import subprocess + + proc = subprocess.run( + [cli, "--socket", SOCKET_PATH, "--json", *args], + capture_output=True, + text=True, + check=False, + env=env, + ) + if proc.returncode != 0: + raise cmuxError(f"CLI failed ({' '.join(args)}): {(proc.stdout + proc.stderr).strip()}") + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _wait_remote_ready(client: cmux, workspace_id: str, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": + return + time.sleep(0.25) + raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}") + + +def _wait_surface_id(client: cmux, workspace_id: str, timeout: float = 10.0) -> str: + deadline = time.time() + timeout + while time.time() < deadline: + surfaces = client.list_surfaces(workspace_id) + if surfaces: + return str(surfaces[0][1]) + time.sleep(0.1) + raise cmuxError(f"No terminal surface appeared for workspace {workspace_id}") + + +def _workspace_id_from_payload(client: cmux, payload: dict) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + workspace_ref = str(payload.get("workspace_ref") or "") + if workspace_ref.startswith("workspace:"): + rows = (client._call("workspace.list", {}) or {}).get("workspaces") or [] + for row in rows: + if str(row.get("ref") or "") == workspace_ref: + return str(row.get("id") or "") + return "" + + +def _wait_text_contains(client: cmux, surface_id: str, needle: str, timeout: float = 8.0) -> str: + deadline = time.time() + timeout + last = "" + while time.time() < deadline: + last = client.read_terminal_text(surface_id) + if needle in last: + return last + time.sleep(0.1) + raise cmuxError(f"Timed out waiting for {needle!r} in surface {surface_id}: {last[-800:]!r}") + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run second-session ssh mux regression") + return 0 + + cli = _find_cli_binary() + workspace_ids: list[str] = [] + try: + with cmux(SOCKET_PATH) as client: + first = _run_cli_json(cli, ["ssh", SSH_HOST]) + first_workspace_id = _workspace_id_from_payload(client, first) + _must(bool(first_workspace_id), f"first cmux ssh output missing workspace_id: {first}") + workspace_ids.append(first_workspace_id) + _wait_remote_ready(client, first_workspace_id) + first_surface_id = _wait_surface_id(client, first_workspace_id) + _wait_text_contains(client, first_surface_id, "cmux in ~", timeout=12.0) + + second = _run_cli_json(cli, ["ssh", SSH_HOST]) + second_workspace_id = _workspace_id_from_payload(client, second) + _must(bool(second_workspace_id), f"second cmux ssh output missing workspace_id: {second}") + _must( + second_workspace_id != first_workspace_id, + f"second cmux ssh should create a distinct workspace: {first_workspace_id} vs {second_workspace_id}", + ) + workspace_ids.append(second_workspace_id) + _wait_remote_ready(client, second_workspace_id) + + second_surface_id = _wait_surface_id(client, second_workspace_id) + text = _wait_text_contains(client, second_surface_id, "cmux in ~", timeout=12.0) + + refusal_markers = [ + "mux_client_request_session: session request failed: Session open refused by peer", + "ControlSocket ", + "disabling multiplexing", + ] + hits = [marker for marker in refusal_markers if marker in text] + _must( + not hits, + "second cmux ssh session printed mux refusal text instead of starting cleanly: " + f"markers={hits!r} tail={text[-1200:]!r}", + ) + + client.send_surface(second_surface_id, "printf '__SECOND_SESSION_OK__\\n'") + text = _wait_text_contains(client, second_surface_id, "__SECOND_SESSION_OK__", timeout=6.0) + _must( + "command not found" not in text, + f"second cmux ssh session accepted corrupted input after startup: {text[-1200:]!r}", + ) + finally: + if workspace_ids: + try: + with cmux(SOCKET_PATH) as client: + for workspace_id in workspace_ids: + try: + client._call("workspace.close", {"workspace_id": workspace_id}) + except Exception: + pass + except Exception: + pass + + print("PASS: second cmux ssh session opens cleanly without mux refusal") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_shell_integration.py b/tests_v2/test_ssh_remote_shell_integration.py new file mode 100755 index 00000000..3d632b84 --- /dev/null +++ b/tests_v2/test_ssh_remote_shell_integration.py @@ -0,0 +1,577 @@ +#!/usr/bin/env python3 +"""Docker integration: prove cmux ssh applies Ghostty ssh-env/ssh-terminfo niceties.""" + +from __future__ import annotations + +import glob +import json +import os +import re +import secrets +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1") +DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") +ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _docker_available() -> bool: + if shutil.which("docker") is None: + return False + probe = _run(["docker", "info"], check=False) + return probe.returncode == 0 + + +def _parse_host_port(docker_port_output: str) -> int: + text = docker_port_output.strip() + if not text: + raise cmuxError("docker port output was empty") + return int(text.split(":")[-1]) + + +def _shell_single_quote(value: str) -> str: + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]: + return _run( + [ + "ssh", + "-o", + "UserKnownHostsFile=/dev/null", + "-o", + "StrictHostKeyChecking=no", + "-o", + "ConnectTimeout=5", + "-p", + str(host_port), + "-i", + str(key_path), + host, + f"sh -lc {_shell_single_quote(script)}", + ], + check=check, + ) + + +def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + probe = _ssh_run(host, host_port, key_path, "echo ready", check=False) + if probe.returncode == 0 and "ready" in probe.stdout: + return + time.sleep(0.5) + raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready") + + +def _wait_remote_connected(client: cmux, workspace_id: str, timeout: float) -> dict: + deadline = time.time() + timeout + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": + return last_status + time.sleep(0.4) + raise cmuxError(f"Remote did not reach connected+ready state: {last_status}") + + +def _is_terminal_surface_not_found(exc: Exception) -> bool: + return "terminal surface not found" in str(exc).lower() + + +def _read_probe_value(client: cmux, surface_id: str, command: str, timeout: float = 20.0) -> str: + token = f"__CMUX_PROBE_{secrets.token_hex(6)}__" + client.send_surface(surface_id, f"{command}; printf '{token}%s\\n' $?\\n") + + pattern = re.compile(re.escape(token) + r"([^\r\n]*)") + deadline = time.time() + timeout + saw_missing_surface = False + while time.time() < deadline: + try: + text = client.read_terminal_text(surface_id) + except cmuxError as exc: + if _is_terminal_surface_not_found(exc): + saw_missing_surface = True + time.sleep(0.2) + continue + raise + matches = pattern.findall(text) + for raw in reversed(matches): + value = raw.strip() + if value and value != "%s" and "$(" not in value and "printf" not in value: + return value + time.sleep(0.2) + + if saw_missing_surface: + raise cmuxError("terminal surface not found") + raise cmuxError(f"Timed out waiting for probe token for command: {command}") + + +def _read_probe_payload(client: cmux, surface_id: str, payload_command: str, timeout: float = 20.0) -> str: + token = f"__CMUX_PAYLOAD_{secrets.token_hex(6)}__" + client.send_surface(surface_id, f"printf '{token}%s\\n' \"$({payload_command})\"\\n") + + pattern = re.compile(re.escape(token) + r"([^\r\n]*)") + deadline = time.time() + timeout + saw_missing_surface = False + while time.time() < deadline: + try: + text = client.read_terminal_text(surface_id) + except cmuxError as exc: + if _is_terminal_surface_not_found(exc): + saw_missing_surface = True + time.sleep(0.2) + continue + raise + matches = pattern.findall(text) + for raw in reversed(matches): + value = raw.strip() + if value and value != "%s" and "$(" not in value and "printf" not in value: + return value + time.sleep(0.2) + + if saw_missing_surface: + raise cmuxError("terminal surface not found") + raise cmuxError(f"Timed out waiting for payload token for command: {payload_command}") + + +def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _wait_for_pane_count(client: cmux, minimum_count: int, timeout: float = 8.0) -> list[str]: + deadline = time.time() + timeout + last: list[str] = [] + while time.time() < deadline: + last = [pid for _idx, pid, _count, _focused in client.list_panes()] + if len(last) >= minimum_count: + return last + time.sleep(0.1) + raise cmuxError(f"Timed out waiting for pane count >= {minimum_count}; saw {len(last)} panes: {last}") + + +def _surface_text_scrollback(client: cmux, workspace_id: str, surface_id: str) -> str: + payload = client._call( + "surface.read_text", + {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}, + ) or {} + return str(payload.get("text") or "") + + +def _clean_line(raw: str) -> str: + line = OSC_ESCAPE_RE.sub("", raw) + line = ANSI_ESCAPE_RE.sub("", line) + line = line.replace("\r", "") + return line.strip() + + +def _surface_text_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]: + return [_clean_line(raw) for raw in _surface_text_scrollback(client, workspace_id, surface_id).splitlines()] + + +def _scrollback_has_all_lines( + client: cmux, + workspace_id: str, + surface_id: str, + lines: list[str], +) -> bool: + available = set(_surface_text_scrollback_lines(client, workspace_id, surface_id)) + return all(line in available for line in lines) + + +def _wait_surface_contains( + client: cmux, + workspace_id: str, + surface_id: str, + token: str, + *, + timeout: float = 20.0, +) -> None: + deadline = time.time() + timeout + saw_missing_surface = False + while time.time() < deadline: + try: + if token in _surface_text_scrollback(client, workspace_id, surface_id): + return + except cmuxError as exc: + if _is_terminal_surface_not_found(exc): + saw_missing_surface = True + time.sleep(0.2) + continue + raise + time.sleep(0.2) + + if saw_missing_surface: + raise cmuxError("terminal surface not found") + raise cmuxError(f"Timed out waiting for terminal token: {token}") + + +def _layout_panes(client: cmux) -> list[dict]: + layout_payload = client.layout_debug() or {} + layout = layout_payload.get("layout") or {} + return list(layout.get("panes") or []) + + +def _pane_extent(client: cmux, pane_id: str, axis: str) -> float: + panes = _layout_panes(client) + for pane in panes: + pid = str(pane.get("paneId") or pane.get("pane_id") or "") + if pid != pane_id: + continue + frame = pane.get("frame") or {} + return float(frame.get(axis) or 0.0) + raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}") + + +def _pane_for_surface(client: cmux, surface_id: str) -> str: + target_id = str(client._resolve_surface_id(surface_id)) + for _idx, pane_id, _count, _focused in client.list_panes(): + rows = client.list_pane_surfaces(pane_id) + for _row_idx, sid, _title, _selected in rows: + try: + candidate_id = str(client._resolve_surface_id(sid)) + except cmuxError: + continue + if candidate_id == target_id: + return pane_id + raise cmuxError(f"Surface {surface_id} is not present in current workspace panes") + + +def _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]: + panes = [p for p in _layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids] + if len(panes) < 2: + raise cmuxError(f"Need >=2 panes for resize test, got {panes}") + + def x_of(p: dict) -> float: + return float((p.get("frame") or {}).get("x") or 0.0) + + def y_of(p: dict) -> float: + return float((p.get("frame") or {}).get("y") or 0.0) + + x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes) + y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes) + + if x_span >= y_span: + left_pane = min(panes, key=x_of) + left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "") + return ("right" if target_pane == left_id else "left"), "width" + + top_pane = min(panes, key=y_of) + top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "") + return ("down" if target_pane == top_id else "up"), "height" + + +def main() -> int: + if not _docker_available(): + print("SKIP: docker is not available") + return 0 + if shutil.which("infocmp") is None: + print("SKIP: local infocmp is not available (required for ssh-terminfo)") + return 0 + + cli = _find_cli_binary() + repo_root = Path(__file__).resolve().parents[1] + fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote" + _must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}") + + temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-shell-integration-")) + image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}" + container_name = f"cmux-ssh-shell-{secrets.token_hex(4)}" + workspace_id = "" + + try: + key_path = temp_dir / "id_ed25519" + _run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)]) + pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip() + _must(bool(pubkey), "Generated SSH public key was empty") + + _run(["docker", "build", "-t", image_tag, str(fixture_dir)]) + _run([ + "docker", + "run", + "-d", + "--rm", + "--name", + container_name, + "-e", + f"AUTHORIZED_KEY={pubkey}", + "-p", + f"{DOCKER_PUBLISH_ADDR}::22", + image_tag, + ]) + + port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout + host_ssh_port = _parse_host_port(port_info) + host = f"root@{DOCKER_SSH_HOST}" + if shutil.which("ghostty") is not None: + _run(["ghostty", "+ssh-cache", f"--remove={host}"], check=False) + _wait_for_ssh(host, host_ssh_port, key_path) + + pre = _ssh_run(host, host_ssh_port, key_path, "if infocmp xterm-ghostty >/dev/null 2>&1; then echo present; else echo missing; fi") + _must("missing" in pre.stdout, f"Fresh container should not have xterm-ghostty terminfo preinstalled: {pre.stdout!r}") + + with cmux(SOCKET_PATH) as client: + payload = _run_cli_json( + cli, + [ + "ssh", + host, + "--name", + "docker-ssh-shell-integration", + "--port", + str(host_ssh_port), + "--identity", + str(key_path), + "--ssh-option", + "UserKnownHostsFile=/dev/null", + "--ssh-option", + "StrictHostKeyChecking=no", + ], + ) + workspace_id = str(payload.get("workspace_id") or "") + workspace_ref = str(payload.get("workspace_ref") or "") + if not workspace_id and workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + workspace_id = str(row.get("id") or "") + break + _must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}") + + _wait_remote_connected(client, workspace_id, timeout=45.0) + + surfaces = client.list_surfaces(workspace_id) + _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") + surface_id = surfaces[0][1] + terminal_text = client.read_terminal_text(surface_id) + _must( + "Reconstructed via infocmp" not in terminal_text, + "ssh-terminfo bootstrap should not leak raw infocmp output into the interactive shell", + ) + _must( + "Warning: Failed to install terminfo." not in terminal_text, + "ssh shell bootstrap should not show a false terminfo failure warning", + ) + + try: + term_value = _read_probe_payload(client, surface_id, "printf '%s' \"$TERM\"") + terminfo_state = _read_probe_value(client, surface_id, "infocmp xterm-ghostty >/dev/null 2>&1") + except cmuxError as exc: + if _is_terminal_surface_not_found(exc): + print("SKIP: terminal surface unavailable for shell integration probes") + return 0 + raise + _must(terminfo_state in {"0", "1"}, f"unexpected terminfo probe exit status: {terminfo_state!r}") + if terminfo_state == "0": + _must( + term_value == "xterm-ghostty", + f"when terminfo install succeeds, TERM should remain xterm-ghostty (got {term_value!r})", + ) + else: + _must( + term_value == "xterm-256color", + f"when terminfo is unavailable, ssh-env fallback should use TERM=xterm-256color (got {term_value!r})", + ) + + colorterm_value = _read_probe_payload(client, surface_id, "printf '%s' \"${COLORTERM:-}\"") + _must( + colorterm_value == "truecolor", + f"ssh-env should propagate COLORTERM=truecolor, got: {colorterm_value!r}", + ) + + term_program = _read_probe_payload(client, surface_id, "printf '%s' \"${TERM_PROGRAM:-}\"") + _must( + term_program == "ghostty", + f"ssh-env should propagate TERM_PROGRAM=ghostty when AcceptEnv allows it, got: {term_program!r}", + ) + + term_program_version = _read_probe_payload(client, surface_id, "printf '%s' \"${TERM_PROGRAM_VERSION:-}\"") + _must(bool(term_program_version), "ssh-env should propagate non-empty TERM_PROGRAM_VERSION") + + ls_stamp = secrets.token_hex(4) + ls_entries = [f"CMUX_RESIZE_LS_{ls_stamp}_{index:02d}" for index in range(1, 17)] + ls_start = f"CMUX_RESIZE_LS_START_{ls_stamp}" + ls_end = f"CMUX_RESIZE_LS_END_{ls_stamp}" + names = " ".join(ls_entries) + ls_script = ( + "tmpdir=$(mktemp -d); " + f"echo {ls_start}; " + f"for name in {names}; do touch \"$tmpdir/$name\"; done; " + "ls -1 \"$tmpdir\"; " + f"echo {ls_end}; " + "rm -rf \"$tmpdir\"" + ) + client.send_surface(surface_id, f"{ls_script}\n") + _wait_surface_contains(client, workspace_id, surface_id, ls_end) + pre_resize_scrollback_lines = _surface_text_scrollback_lines(client, workspace_id, surface_id) + _must( + all(line in pre_resize_scrollback_lines for line in ls_entries), + "pre-resize scrollback missing ls output fixture lines", + ) + pre_resize_anchors = [ls_entries[0], ls_entries[len(ls_entries) // 2], ls_entries[-1]] + _must( + len(pre_resize_anchors) == 3, + f"pre-resize scrollback missing anchor lines: {pre_resize_anchors}", + ) + pre_resize_visible = client.read_terminal_text(surface_id) + pre_visible_lines = [line for line in ls_entries if line in pre_resize_visible] + _must( + len(pre_visible_lines) >= 2, + "pre-resize viewport did not contain enough reference lines for continuity checks", + ) + + client.select_workspace(workspace_id) + client.activate_app() + pane_count_before_split = len(client.list_panes()) + client.simulate_shortcut("cmd+d") + pane_ids = _wait_for_pane_count(client, pane_count_before_split + 1, timeout=8.0) + + pane_id = _pane_for_surface(client, surface_id) + resize_direction, resize_axis = _pick_resize_direction_for_pane(client, pane_ids, pane_id) + opposite_direction = { + "left": "right", + "right": "left", + "up": "down", + "down": "up", + }[resize_direction] + expected_sign_by_direction = { + resize_direction: +1, + opposite_direction: -1, + } + + resize_sequence = [resize_direction, opposite_direction] * 8 + current_extent = _pane_extent(client, pane_id, resize_axis) + for index, direction in enumerate(resize_sequence, start=1): + resize_result = client._call( + "pane.resize", + { + "workspace_id": workspace_id, + "pane_id": pane_id, + "direction": direction, + "amount": 80, + }, + ) or {} + _must( + str(resize_result.get("pane_id") or "") == pane_id, + f"pane.resize response missing expected pane_id: {resize_result}", + ) + if expected_sign_by_direction[direction] > 0: + _wait_for(lambda: _pane_extent(client, pane_id, resize_axis) > current_extent + 1.0, timeout_s=5.0) + else: + _wait_for(lambda: _pane_extent(client, pane_id, resize_axis) < current_extent - 1.0, timeout_s=5.0) + current_extent = _pane_extent(client, pane_id, resize_axis) + _must( + _scrollback_has_all_lines(client, workspace_id, surface_id, pre_resize_anchors), + f"resize iteration {index} lost pre-resize scrollback anchors", + ) + + post_resize_visible = client.read_terminal_text(surface_id) + visible_overlap = [line for line in pre_visible_lines if line in post_resize_visible] + _must( + bool(visible_overlap), + f"resize lost all pre-resize visible lines from viewport: {pre_visible_lines}", + ) + + resize_post_token = f"CMUX_RESIZE_POST_{secrets.token_hex(6)}" + client.send_surface(surface_id, f"echo {resize_post_token}\n") + _wait_surface_contains(client, workspace_id, surface_id, resize_post_token) + + scrollback_lines = _surface_text_scrollback_lines(client, workspace_id, surface_id) + _must( + all(anchor in scrollback_lines for anchor in pre_resize_anchors), + "terminal scrollback lost pre-resize lines after pane resize", + ) + _must( + resize_post_token in scrollback_lines, + f"terminal scrollback missing post-resize token after pane resize: {resize_post_token}", + ) + + try: + client.close_workspace(workspace_id) + workspace_id = "" + except Exception: + pass + + print( + "PASS: cmux ssh enables Ghostty shell integration niceties and preserves pre-resize terminal content " + f"(TERM={term_value}, COLORTERM={colorterm_value}, TERM_PROGRAM={term_program})" + ) + return 0 + + finally: + if workspace_id: + try: + with cmux(SOCKET_PATH) as cleanup_client: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + _run(["docker", "rm", "-f", container_name], check=False) + _run(["docker", "rmi", "-f", image_tag], check=False) + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_ssh_remote_shortcuts_stay_remote.py b/tests_v2/test_ssh_remote_shortcuts_stay_remote.py new file mode 100644 index 00000000..fa5d9199 --- /dev/null +++ b/tests_v2/test_ssh_remote_shortcuts_stay_remote.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Regression: new tabs and splits from an ssh terminal must stay on the remote shell.""" + +from __future__ import annotations + +import glob +import json +import os +import re +import secrets +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip() +SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip() +SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip() +SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip() + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]: + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False) + if check and proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}") + return proc + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + env = dict(os.environ) + env.pop("CMUX_WORKSPACE_ID", None) + env.pop("CMUX_SURFACE_ID", None) + env.pop("CMUX_TAB_ID", None) + + proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env) + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + +def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + if pred(): + return + time.sleep(step_s) + raise cmuxError("Timed out waiting for condition") + + +def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 45.0) -> None: + deadline = time.time() + timeout_s + last_status = {} + while time.time() < deadline: + last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {} + remote = last_status.get("remote") or {} + daemon = remote.get("daemon") or {} + if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready": + return + time.sleep(0.25) + raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}") + + +def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str: + workspace_id = str(payload.get("workspace_id") or "") + if workspace_id: + return workspace_id + + workspace_ref = str(payload.get("workspace_ref") or "") + if workspace_ref.startswith("workspace:"): + listed = client._call("workspace.list", {}) or {} + for row in listed.get("workspaces") or []: + if str(row.get("ref") or "") == workspace_ref: + resolved = str(row.get("id") or "") + if resolved: + return resolved + + current = {wid for _index, wid, _title, _focused in client.list_workspaces()} + new_ids = sorted(current - before_workspace_ids) + if len(new_ids) == 1: + return new_ids[0] + + raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}") + + +def _focused_surface_id(client: cmux) -> str: + ident = client.identify() + focused = ident.get("focused") or {} + surface_id = str(focused.get("surface_id") or "") + if not surface_id: + raise cmuxError(f"Missing focused surface in identify payload: {ident}") + return surface_id + + +def _run_remote_shell_probe(client: cmux, surface_id: str, probe_label: str) -> str: + token = f"__CMUX_REMOTE_SOCKET_{probe_label}_{secrets.token_hex(4)}__" + client.send_surface( + surface_id, + ( + f"__cmux_socket_path=\"${{CMUX_SOCKET_PATH:-}}\"; " + f"printf '{token}:%s:__CMUX_REMOTE_SOCKET_END__\\n' \"$__cmux_socket_path\"\n" + ), + ) + deadline = time.time() + 15.0 + last = "" + pattern = re.compile(re.escape(token) + r":(.*?):__CMUX_REMOTE_SOCKET_END__") + while time.time() < deadline: + last = client.read_terminal_text(surface_id) + matches = pattern.findall(last) + if matches: + for candidate in reversed(matches): + cleaned = candidate.strip() + if cleaned and cleaned != "%s": + return cleaned + time.sleep(0.15) + raise cmuxError(f"Timed out waiting for socket token {token!r}: {last[-1200:]!r}") + + +def _assert_remote_socket_path(client: cmux, surface_id: str, shortcut_name: str) -> None: + socket_path = _run_remote_shell_probe(client, surface_id, shortcut_name) + _must( + socket_path.startswith("127.0.0.1:"), + f"{shortcut_name} should keep the new terminal on the ssh relay, got CMUX_SOCKET_PATH={socket_path!r}", + ) + + +def _open_ssh_workspace(client: cmux, cli: str, *, name: str) -> str: + before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()} + + ssh_args = ["ssh", SSH_HOST, "--name", name] + if SSH_PORT: + ssh_args.extend(["--port", SSH_PORT]) + if SSH_IDENTITY: + ssh_args.extend(["--identity", SSH_IDENTITY]) + if SSH_OPTIONS_RAW: + for option in SSH_OPTIONS_RAW.split(","): + trimmed = option.strip() + if trimmed: + ssh_args.extend(["--ssh-option", trimmed]) + + payload = _run_cli_json(cli, ssh_args) + workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids) + _wait_remote_ready(client, workspace_id) + client.select_workspace(workspace_id) + _wait_for(lambda: client.current_workspace() == workspace_id, timeout_s=8.0) + return workspace_id + + +def _assert_shortcut_creates_remote_terminal( + client: cmux, + workspace_id: str, + shortcut: str, + shortcut_name: str, + *, + expect_new_pane: bool, +) -> None: + before_surfaces = {sid for _index, sid, _focused in client.list_surfaces(workspace_id)} + before_pane_count = len(client.list_panes()) + + client.activate_app() + client.simulate_app_active() + client.simulate_shortcut(shortcut) + + _wait_for( + lambda: len({sid for _index, sid, _focused in client.list_surfaces(workspace_id)} - before_surfaces) == 1, + timeout_s=12.0, + ) + + if expect_new_pane: + _wait_for(lambda: len(client.list_panes()) >= before_pane_count + 1, timeout_s=12.0) + + after_surfaces = {sid for _index, sid, _focused in client.list_surfaces(workspace_id)} + new_surface_ids = sorted(after_surfaces - before_surfaces) + _must(len(new_surface_ids) == 1, f"{shortcut_name} should create exactly one new surface: {new_surface_ids}") + + focused_surface_id = _focused_surface_id(client) + _must( + focused_surface_id == new_surface_ids[0], + f"{shortcut_name} should focus the new terminal surface: focused={focused_surface_id!r} new={new_surface_ids[0]!r}", + ) + _assert_remote_socket_path(client, focused_surface_id, shortcut_name) + + +def main() -> int: + if not SSH_HOST: + print("SKIP: set CMUX_SSH_TEST_HOST to run ssh shortcut inheritance regression") + return 0 + + cli = _find_cli_binary() + workspace_ids: list[str] = [] + + try: + with cmux(SOCKET_PATH) as client: + workspace_id = _open_ssh_workspace( + client, + cli, + name=f"ssh-shortcut-cmdt-{secrets.token_hex(4)}", + ) + workspace_ids.append(workspace_id) + _assert_shortcut_creates_remote_terminal( + client, + workspace_id, + "cmd+t", + "cmd+t", + expect_new_pane=False, + ) + + workspace_id = _open_ssh_workspace( + client, + cli, + name=f"ssh-shortcut-cmdd-{secrets.token_hex(4)}", + ) + workspace_ids.append(workspace_id) + _assert_shortcut_creates_remote_terminal( + client, + workspace_id, + "cmd+d", + "cmd+d", + expect_new_pane=True, + ) + + workspace_id = _open_ssh_workspace( + client, + cli, + name=f"ssh-shortcut-cmdshiftd-{secrets.token_hex(4)}", + ) + workspace_ids.append(workspace_id) + _assert_shortcut_creates_remote_terminal( + client, + workspace_id, + "cmd+shift+d", + "cmd+shift+d", + expect_new_pane=True, + ) + finally: + if workspace_ids: + try: + with cmux(SOCKET_PATH) as client: + for workspace_id in workspace_ids: + try: + client._call("workspace.close", {"workspace_id": workspace_id}) + except Exception: + pass + except Exception: + pass + + print("PASS: cmd+t/cmd+d/cmd+shift+d keep ssh terminals on the remote relay") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_surface_list_custom_titles.py b/tests_v2/test_surface_list_custom_titles.py new file mode 100644 index 00000000..3b93e635 --- /dev/null +++ b/tests_v2/test_surface_list_custom_titles.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Regression: surface.list and list-panels should return custom tab titles.""" + +from __future__ import annotations + +import glob +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run_cli_json(cli: str, args: list[str]) -> dict: + proc = subprocess.run( + [cli, "--socket", SOCKET_PATH, "--json", *args], + capture_output=True, + text=True, + check=False, + ) + if proc.returncode != 0: + merged = f"{proc.stdout}\n{proc.stderr}".strip() + raise cmuxError(f"CLI failed ({' '.join(args)}): {merged}") + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid JSON output: {proc.stdout!r} ({exc})") + + +def main() -> int: + cli = _find_cli_binary() + workspace_id = "" + + try: + with cmux(SOCKET_PATH) as client: + workspace_id = client.new_workspace() + client.select_workspace(workspace_id) + time.sleep(0.2) + + current_payload = client._call("surface.current", {"workspace_id": workspace_id}) or {} + surface_id = str(current_payload.get("surface_id") or "") + _must(bool(surface_id), f"surface.current returned no surface_id: {current_payload}") + + title = f"renamed-surface-{int(time.time() * 1000)}" + renamed = client._call( + "surface.action", + {"surface_id": surface_id, "action": "rename", "title": title}, + ) or {} + _must(str(renamed.get("title") or "") == title, f"surface.action rename failed: {renamed}") + + listed = client._call("surface.list", {"workspace_id": workspace_id}) or {} + row = next((item for item in listed.get("surfaces") or [] if str(item.get("id") or "") == surface_id), None) + _must(row is not None, f"surface.list missing renamed surface: {listed}") + _must(str(row.get("title") or "") == title, f"surface.list should return custom title {title!r}: {row}") + + cli_listed = _run_cli_json(cli, ["list-panels", "--workspace", workspace_id]) + cli_row = next((item for item in cli_listed.get("surfaces") or [] if str(item.get("title") or "") == title), None) + _must(cli_row is not None, f"list-panels missing renamed surface: {cli_listed}") + _must(str(cli_row.get("title") or "") == title, f"list-panels should return custom title {title!r}: {cli_row}") + finally: + if workspace_id: + with cmux(SOCKET_PATH) as cleanup_client: + try: + cleanup_client.close_workspace(workspace_id) + except Exception: + pass + + print("PASS: surface.list and list-panels return custom surface titles") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_v1_panel_creation_preserves_focus.py b/tests_v2/test_v1_panel_creation_preserves_focus.py new file mode 100644 index 00000000..21c66267 --- /dev/null +++ b/tests_v2/test_v1_panel_creation_preserves_focus.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Regression: legacy v1 panel-creation socket commands must not steal focus.""" + +from __future__ import annotations + +import os +import socket +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _send_v1(command: str, *, expect_ok: bool = True) -> str: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.settimeout(5.0) + sock.connect(SOCKET_PATH) + sock.sendall((command + "\n").encode("utf-8")) + chunks: list[bytes] = [] + while True: + try: + chunk = sock.recv(4096) + except socket.timeout: + break + if not chunk: + break + chunks.append(chunk) + sock.settimeout(0.1) + payload = b"".join(chunks).decode("utf-8", errors="replace").strip() + if expect_ok and not payload.startswith("OK"): + raise cmuxError(f"{command!r} failed: {payload!r}") + return payload + + +def _focused_surface_id(client: cmux, workspace_id: str) -> str: + surfaces = client.list_surfaces(workspace=workspace_id) + for _, surface_id, focused in surfaces: + if focused: + return surface_id + raise cmuxError(f"no focused surface in workspace {workspace_id}: {surfaces}") + + +def _surface_ids(client: cmux, workspace_id: str) -> set[str]: + return {surface_id for _, surface_id, _ in client.list_surfaces(workspace=workspace_id)} + + +def _created_surface_id(response: str) -> str: + parts = response.split(" ", 1) + _must(len(parts) == 2 and parts[1], f"expected surface id in response: {response!r}") + return parts[1] + + +def _sidebar_state(workspace_id: str) -> str: + payload = _send_v1(f"sidebar_state --tab={workspace_id}", expect_ok=False) + if payload.startswith("ERROR"): + raise cmuxError(f"sidebar_state failed: {payload!r}") + return payload + + +def main() -> int: + created_workspaces: list[str] = [] + with cmux(SOCKET_PATH) as client: + try: + created_workspace = client.new_workspace() + created_workspaces.append(created_workspace) + client.select_workspace(created_workspace) + time.sleep(0.2) + + baseline_workspace = client.current_workspace() + baseline_focused_surface = _focused_surface_id(client, created_workspace) + baseline_surfaces = _surface_ids(client, created_workspace) + + new_surface_response = _send_v1("new_surface") + time.sleep(0.2) + new_surface_id = _created_surface_id(new_surface_response) + _must(new_surface_id in _surface_ids(client, created_workspace), "new_surface should create a surface") + _must(client.current_workspace() == baseline_workspace, "new_surface should not retarget workspace selection") + _must( + _focused_surface_id(client, created_workspace) == baseline_focused_surface, + "new_surface should preserve the focused surface for v1 callers", + ) + + open_browser_response = _send_v1("open_browser") + time.sleep(0.2) + browser_surface_id = _created_surface_id(open_browser_response) + _must(browser_surface_id in _surface_ids(client, created_workspace), "open_browser should create a browser surface") + _must(client.current_workspace() == baseline_workspace, "open_browser should not retarget workspace selection") + _must( + _focused_surface_id(client, created_workspace) == baseline_focused_surface, + "open_browser should preserve the focused surface for v1 callers", + ) + + new_pane_response = _send_v1("new_pane --direction=right") + time.sleep(0.2) + split_surface_id = _created_surface_id(new_pane_response) + current_surfaces = _surface_ids(client, created_workspace) + _must( + len(current_surfaces - baseline_surfaces) >= 3, + f"expected all v1 panel creation commands to add surfaces: {current_surfaces}", + ) + _must(split_surface_id in current_surfaces, "new_pane should create a split surface") + _must(client.current_workspace() == baseline_workspace, "new_pane should not retarget workspace selection") + _must( + _focused_surface_id(client, created_workspace) == baseline_focused_surface, + "new_pane should preserve the focused surface for v1 callers", + ) + + background_workspace = client.new_workspace() + created_workspaces.append(background_workspace) + client.select_workspace(background_workspace) + time.sleep(0.2) + + target_directory = f"/tmp/cmux-v1-report-pwd-{int(time.time() * 1000)}" + _send_v1( + f"report_pwd {target_directory} --tab={created_workspace} --panel={baseline_focused_surface}" + ) + deadline = time.time() + 5.0 + sidebar_state = "" + while time.time() < deadline: + sidebar_state = _sidebar_state(created_workspace) + if f"focused_cwd={target_directory}" in sidebar_state: + break + time.sleep(0.1) + _must( + f"focused_cwd={target_directory}" in sidebar_state, + f"report_pwd should update the targeted background workspace: {sidebar_state!r}", + ) + _must( + client.current_workspace() == background_workspace, + "report_pwd with explicit scope should not retarget workspace selection", + ) + finally: + for workspace_id in reversed(created_workspaces): + try: + client.close_workspace(workspace_id) + except Exception: + pass + + print("PASS: legacy v1 panel creation and prompt telemetry preserve focus and workspace selection") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_workspace_create_background_starts_terminal.py b/tests_v2/test_workspace_create_background_starts_terminal.py new file mode 100644 index 00000000..f68c72b8 --- /dev/null +++ b/tests_v2/test_workspace_create_background_starts_terminal.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Regression: background workspace.create should start its initial terminal before selection.""" + +from __future__ import annotations + +import os +import shlex +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_for_file_text(path: Path, needle: str, timeout_s: float = 8.0) -> str: + deadline = time.time() + timeout_s + last_text = "" + while time.time() < deadline: + if path.exists(): + last_text = path.read_text(encoding="utf-8", errors="replace") + if needle in last_text: + return last_text + time.sleep(0.1) + raise cmuxError(f"Timed out waiting for {needle!r} in background workspace file: {last_text!r}") + + +def main() -> int: + with cmux(SOCKET_PATH) as c: + baseline_workspace = c.current_workspace() + created_workspace = "" + marker_path = Path(tempfile.gettempdir()) / f"cmux-bg-start-{int(time.time() * 1000)}.txt" + try: + token = f"CMUX_BG_START_{int(time.time() * 1000)}" + initial_command = ( + "python3 -c " + + shlex.quote( + f"from pathlib import Path; Path({marker_path.as_posix()!r}).write_text({token!r}, encoding='utf-8')" + ) + ) + payload = c._call( + "workspace.create", + {"initial_command": initial_command}, + ) or {} + created_workspace = str(payload.get("workspace_id") or "") + _must(bool(created_workspace), f"workspace.create returned no workspace_id: {payload}") + _must( + c.current_workspace() == baseline_workspace, + "workspace.create should preserve selected workspace", + ) + + text = _wait_for_file_text(marker_path, token) + _must(token in text, f"Background workspace did not run its initial command: {text!r}") + _must( + c.current_workspace() == baseline_workspace, + "background eager load should not switch the selected workspace", + ) + finally: + try: + marker_path.unlink() + except FileNotFoundError: + pass + if created_workspace: + try: + c.close_workspace(created_workspace) + except Exception: + pass + + print("PASS: workspace.create eager background load starts the initial terminal without focus") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_workspace_create_initial_env.py b/tests_v2/test_workspace_create_initial_env.py new file mode 100644 index 00000000..33b56c2e --- /dev/null +++ b/tests_v2/test_workspace_create_initial_env.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Regression: workspace.create must apply initial_env to the initial terminal.""" + +import os +import sys +import time +import base64 +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _wait_for_text(c: cmux, workspace_id: str, needle: str, timeout_s: float = 8.0) -> str: + deadline = time.time() + timeout_s + last_text = "" + while time.time() < deadline: + payload = c._call( + "surface.read_text", + {"workspace_id": workspace_id}, + ) or {} + if "text" in payload: + last_text = str(payload.get("text") or "") + else: + b64 = str(payload.get("base64") or "") + raw = base64.b64decode(b64) if b64 else b"" + last_text = raw.decode("utf-8", errors="replace") + if needle in last_text: + return last_text + time.sleep(0.1) + raise cmuxError(f"Timed out waiting for {needle!r} in panel text: {last_text!r}") + + +def main() -> int: + with cmux(SOCKET_PATH) as c: + baseline_workspace = c.current_workspace() + created_workspace = "" + try: + token = f"tok_{int(time.time() * 1000)}" + payload = c._call( + "workspace.create", + { + "initial_env": {"CMUX_INITIAL_ENV_TOKEN": token}, + }, + ) or {} + created_workspace = str(payload.get("workspace_id") or "") + _must(bool(created_workspace), f"workspace.create returned no workspace_id: {payload}") + _must(c.current_workspace() == baseline_workspace, "workspace.create should not steal workspace focus") + + # Terminal surfaces in background workspaces may not be attached/render-ready yet. + # Select it before reading text so the initial command output is available. + c.select_workspace(created_workspace) + listed = c._call("surface.list", {"workspace_id": created_workspace}) or {} + rows = list(listed.get("surfaces") or []) + _must(bool(rows), "Expected at least one surface in the created workspace") + terminal_row = next((row for row in rows if str(row.get("type") or "") == "terminal"), None) + _must(terminal_row is not None, f"Expected a terminal surface in workspace.create result: {rows}") + + c.send("printf 'CMUX_ENV_CHECK=%s\\n' \"$CMUX_INITIAL_ENV_TOKEN\"\\n") + text = _wait_for_text(c, created_workspace, f"CMUX_ENV_CHECK={token}") + _must( + f"CMUX_ENV_CHECK={token}" in text, + f"initial_env token missing from terminal output: {text!r}", + ) + c.select_workspace(baseline_workspace) + finally: + if created_workspace: + try: + c.close_workspace(created_workspace) + except Exception: + pass + + print("PASS: workspace.create applies initial_env to initial terminal") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/bonsplit b/vendor/bonsplit index 02fa188c..efa23f4c 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 02fa188ccd244b1e6efc037e4ed631e966144795 +Subproject commit efa23f4c3c7d00688d8448dc7e4d08b4d847548d