Merge remote-tracking branch 'origin/main' into task-browser-import-followups
# Conflicts: # Sources/Workspace.swift
This commit is contained in:
commit
f5d610e3ea
98 changed files with 25290 additions and 2982 deletions
24
.github/workflows/build-ghosttykit.yml
vendored
24
.github/workflows/build-ghosttykit.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
78
.github/workflows/ci-macos-compat.yml
vendored
78
.github/workflows/ci-macos-compat.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
172
.github/workflows/ci.yml
vendored
172
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
73
.github/workflows/nightly.yml
vendored
73
.github/workflows/nightly.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
58
.github/workflows/release.yml
vendored
58
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
17
.github/workflows/test-depot.yml
vendored
17
.github/workflows/test-depot.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
29
.github/workflows/test-e2e.yml
vendored
29
.github/workflows/test-e2e.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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 <tag>`): `/tmp/cmux-debug-<tag>.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
|
||||
|
|
|
|||
2155
CLI/cmux.swift
2155
CLI/cmux.swift
File diff suppressed because it is too large
Load diff
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: —"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
15
TODO.md
15
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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
75
cmuxTests/TabManagerSessionSnapshotTests.swift
Normal file
75
cmuxTests/TabManagerSessionSnapshotTests.swift
Normal file
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
258
cmuxTests/TerminalControllerSocketSecurityTests.swift
Normal file
258
cmuxTests/TerminalControllerSocketSecurityTests.swift
Normal file
|
|
@ -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)))"]
|
||||
)
|
||||
}
|
||||
}
|
||||
204
cmuxTests/WorkspaceRemoteConnectionTests.swift
Normal file
204
cmuxTests/WorkspaceRemoteConnectionTests.swift
Normal file
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]? {
|
||||
|
|
|
|||
|
|
@ -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? {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
83
daemon/remote/README.md
Normal file
83
daemon/remote/README.md
Normal file
|
|
@ -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.
|
||||
758
daemon/remote/cmd/cmuxd-remote/cli.go
Normal file
758
daemon/remote/cmd/cmuxd-remote/cli.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
923
daemon/remote/cmd/cmuxd-remote/cli_test.go
Normal file
923
daemon/remote/cmd/cmuxd-remote/cli_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
1105
daemon/remote/cmd/cmuxd-remote/main.go
Normal file
1105
daemon/remote/cmd/cmuxd-remote/main.go
Normal file
File diff suppressed because it is too large
Load diff
755
daemon/remote/cmd/cmuxd-remote/main_test.go
Normal file
755
daemon/remote/cmd/cmuxd-remote/main_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
3
daemon/remote/go.mod
Normal file
3
daemon/remote/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module github.com/manaflow-ai/cmux/daemon/remote
|
||||
|
||||
go 1.22
|
||||
214
docs/remote-daemon-spec.md
Normal file
214
docs/remote-daemon-spec.md
Normal file
|
|
@ -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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
154
scripts/build_remote_daemon_release_assets.sh
Executable file
154
scripts/build_remote_daemon_release_assets.sh
Executable file
|
|
@ -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}"
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
# Format: <ghostty_sha> <sha256>
|
||||
7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207
|
||||
a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d
|
||||
c47010b80cd9ae6d1ab744c120f011a465521ea3 d6904870a3c920b2787b1c4b950cfdef232606bb9876964f5e8497081d5cb5df
|
||||
0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de
|
||||
312c7b23a7c8dc0704431940d76ba5dc32a46afb ae73cb18a9d6efec42126a1d99e0e9d12022403d7dc301dfa21ed9f7c89c9e30
|
||||
404a3f175ba6baafabc46cac807194883e040980 bcbd2954f4746fe5bcb4bfca6efeddd3ea355fda2836371f4c7150271c58acbd
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
20
tests/fixtures/ssh-remote/Dockerfile
vendored
Normal file
20
tests/fixtures/ssh-remote/Dockerfile
vendored
Normal file
|
|
@ -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"]
|
||||
38
tests/fixtures/ssh-remote/run.sh
vendored
Normal file
38
tests/fixtures/ssh-remote/run.sh
vendored
Normal file
|
|
@ -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
|
||||
31
tests/fixtures/ssh-remote/sshd_config
vendored
Normal file
31
tests/fixtures/ssh-remote/sshd_config
vendored
Normal file
|
|
@ -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
|
||||
138
tests/fixtures/ssh-remote/ws_echo.py
vendored
Normal file
138
tests/fixtures/ssh-remote/ws_echo.py
vendored
Normal file
|
|
@ -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())
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
65
tests/test_remote_daemon_release_assets.sh
Executable file
65
tests/test_remote_daemon_release_assets.sh
Executable file
|
|
@ -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
|
||||
79
tests/test_sidebar_copy_ssh_error_context_menu.py
Normal file
79
tests/test_sidebar_copy_ssh_error_context_menu.py
Normal file
|
|
@ -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())
|
||||
124
tests_v2/pane_resize_test_support.py
Normal file
124
tests_v2/pane_resize_test_support.py
Normal file
|
|
@ -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"
|
||||
166
tests_v2/test_cli_browser_console_errors_text.py
Normal file
166
tests_v2/test_cli_browser_console_errors_text.py
Normal file
|
|
@ -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())
|
||||
101
tests_v2/test_cli_global_flags_and_v1_error_contract.py
Normal file
101
tests_v2/test_cli_global_flags_and_v1_error_contract.py
Normal file
|
|
@ -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())
|
||||
124
tests_v2/test_cli_sidebar_metadata_commands.py
Normal file
124
tests_v2/test_cli_sidebar_metadata_commands.py
Normal file
|
|
@ -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())
|
||||
96
tests_v2/test_pane_break_swap_preserve_focus.py
Normal file
96
tests_v2/test_pane_break_swap_preserve_focus.py
Normal file
|
|
@ -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())
|
||||
202
tests_v2/test_pane_resize_preserves_ls_scrollback.py
Normal file
202
tests_v2/test_pane_resize_preserves_ls_scrollback.py
Normal file
|
|
@ -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())
|
||||
157
tests_v2/test_pane_resize_preserves_visible_content.py
Normal file
157
tests_v2/test_pane_resize_preserves_visible_content.py
Normal file
|
|
@ -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())
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
315
tests_v2/test_ssh_remote_browser_favicon_uses_proxy.py
Normal file
315
tests_v2/test_ssh_remote_browser_favicon_uses_proxy.py
Normal file
|
|
@ -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())
|
||||
297
tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py
Normal file
297
tests_v2/test_ssh_remote_browser_move_rebinds_proxy.py
Normal file
|
|
@ -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())
|
||||
622
tests_v2/test_ssh_remote_cli_metadata.py
Normal file
622
tests_v2/test_ssh_remote_cli_metadata.py
Normal file
|
|
@ -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())
|
||||
392
tests_v2/test_ssh_remote_cli_relay.py
Normal file
392
tests_v2/test_ssh_remote_cli_relay.py
Normal file
|
|
@ -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())
|
||||
190
tests_v2/test_ssh_remote_daemon_resize_stdio.py
Normal file
190
tests_v2/test_ssh_remote_daemon_resize_stdio.py
Normal file
|
|
@ -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())
|
||||
258
tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py
Normal file
258
tests_v2/test_ssh_remote_docker_bootstrap_nonlogin_shell.py
Normal file
|
|
@ -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())
|
||||
742
tests_v2/test_ssh_remote_docker_forwarding.py
Normal file
742
tests_v2/test_ssh_remote_docker_forwarding.py
Normal file
|
|
@ -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())
|
||||
612
tests_v2/test_ssh_remote_docker_reconnect.py
Normal file
612
tests_v2/test_ssh_remote_docker_reconnect.py
Normal file
|
|
@ -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())
|
||||
264
tests_v2/test_ssh_remote_interactive_cmux_command_regression.py
Normal file
264
tests_v2/test_ssh_remote_interactive_cmux_command_regression.py
Normal file
|
|
@ -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())
|
||||
259
tests_v2/test_ssh_remote_last_surface_clears_remote_state.py
Normal file
259
tests_v2/test_ssh_remote_last_surface_clears_remote_state.py
Normal file
|
|
@ -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())
|
||||
246
tests_v2/test_ssh_remote_proxy_bind_conflict.py
Normal file
246
tests_v2/test_ssh_remote_proxy_bind_conflict.py
Normal file
|
|
@ -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())
|
||||
357
tests_v2/test_ssh_remote_resize_scrollback_regression.py
Normal file
357
tests_v2/test_ssh_remote_resize_scrollback_regression.py
Normal file
|
|
@ -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())
|
||||
179
tests_v2/test_ssh_remote_second_session_mux_regression.py
Normal file
179
tests_v2/test_ssh_remote_second_session_mux_regression.py
Normal file
|
|
@ -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())
|
||||
577
tests_v2/test_ssh_remote_shell_integration.py
Executable file
577
tests_v2/test_ssh_remote_shell_integration.py
Executable file
|
|
@ -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())
|
||||
281
tests_v2/test_ssh_remote_shortcuts_stay_remote.py
Normal file
281
tests_v2/test_ssh_remote_shortcuts_stay_remote.py
Normal file
|
|
@ -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())
|
||||
103
tests_v2/test_surface_list_custom_titles.py
Normal file
103
tests_v2/test_surface_list_custom_titles.py
Normal file
|
|
@ -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())
|
||||
154
tests_v2/test_v1_panel_creation_preserves_focus.py
Normal file
154
tests_v2/test_v1_panel_creation_preserves_focus.py
Normal file
|
|
@ -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())
|
||||
83
tests_v2/test_workspace_create_background_starts_terminal.py
Normal file
83
tests_v2/test_workspace_create_background_starts_terminal.py
Normal file
|
|
@ -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())
|
||||
86
tests_v2/test_workspace_create_initial_env.py
Normal file
86
tests_v2/test_workspace_create_initial_env.py
Normal file
|
|
@ -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())
|
||||
2
vendor/bonsplit
vendored
2
vendor/bonsplit
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 02fa188ccd244b1e6efc037e4ed631e966144795
|
||||
Subproject commit efa23f4c3c7d00688d8448dc7e4d08b4d847548d
|
||||
Loading…
Add table
Add a link
Reference in a new issue