Merge remote-tracking branch 'origin/main' into task-browser-import-followups

# Conflicts:
#	Sources/Workspace.swift
This commit is contained in:
Lawrence Chen 2026-03-17 16:49:16 -07:00
commit f5d610e3ea
No known key found for this signature in database
98 changed files with 25290 additions and 2982 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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: |

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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 */,

View file

@ -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>

View file

@ -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": {

View file

@ -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

View file

@ -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? {

View file

@ -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

View file

@ -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 {

View file

@ -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")

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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()
}
}
}
}

View file

@ -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,

View file

@ -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,

View file

@ -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))
}
}

View file

@ -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

View file

@ -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
View file

@ -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)

View file

@ -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,

View file

@ -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"]

View file

@ -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()

View file

@ -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
)
)
}
}

View 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 })
}
}

View 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)))"]
)
}
}

View 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"
)
}
}

View file

@ -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? {

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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) {

View file

@ -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(

View file

@ -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) {

View file

@ -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) {

View file

@ -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]? {

View file

@ -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? {

View file

@ -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)

View file

@ -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
View 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.

View 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]), &params); 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")
}

View 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")
}
}

File diff suppressed because it is too large Load diff

View 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 &notifyingBuffer{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
View file

@ -0,0 +1,3 @@
module github.com/manaflow-ai/cmux/daemon/remote
go 1.22

214
docs/remote-daemon-spec.md Normal file
View 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).

View file

@ -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

View 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}"

View file

@ -3,6 +3,7 @@
# Format: <ghostty_sha> <sha256>
7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207
a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d
c47010b80cd9ae6d1ab744c120f011a465521ea3 d6904870a3c920b2787b1c4b950cfdef232606bb9876964f5e8497081d5cb5df
0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de
312c7b23a7c8dc0704431940d76ba5dc32a46afb ae73cb18a9d6efec42126a1d99e0e9d12022403d7dc301dfa21ed9f7c89c9e30
404a3f175ba6baafabc46cac807194883e040980 bcbd2954f4746fe5bcb4bfca6efeddd3ea355fda2836371f4c7150271c58acbd

View file

@ -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",

View file

@ -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);

View file

@ -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
View 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
View 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
View 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
View 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())

View file

@ -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"

View file

@ -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()

View file

@ -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:

View 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

View 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())

View 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"

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View file

@ -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],

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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

@ -1 +1 @@
Subproject commit 02fa188ccd244b1e6efc037e4ed631e966144795
Subproject commit efa23f4c3c7d00688d8448dc7e4d08b4d847548d