Merge origin/main into feat-hidden-titlebar-minimalism-reset

This commit is contained in:
Lawrence Chen 2026-03-17 17:37:28 -07:00
commit 90e573b68f
No known key found for this signature in database
116 changed files with 36455 additions and 3357 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

@ -29,6 +29,7 @@
A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; };
A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; };
A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; };
A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; };
A5001420 /* MarkdownPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001418 /* MarkdownPanel.swift */; };
A5001421 /* MarkdownPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001419 /* MarkdownPanelView.swift */; };
A5001290 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A5001291 /* MarkdownUI */; };
@ -83,13 +84,16 @@
B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; };
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; };
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; };
FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; };
E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; };
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; };
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; };
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; };
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; };
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 */; };
@ -180,6 +184,7 @@
A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = "<group>"; };
A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = "<group>"; };
A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = "<group>"; };
A5007421 /* BrowserPopupWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPopupWindowController.swift; sourceTree = "<group>"; };
A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = "<group>"; };
A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = "<group>"; };
A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = "<group>"; };
@ -229,20 +234,23 @@
B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = "<group>"; };
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = "<group>"; };
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; };
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = "<group>"; };
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; };
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; };
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
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 */
@ -387,6 +395,7 @@
A5001412 /* BrowserPanel.swift */,
A5001413 /* TerminalPanelView.swift */,
A5001414 /* BrowserPanelView.swift */,
A5007421 /* BrowserPopupWindowController.swift */,
A5001418 /* MarkdownPanel.swift */,
A5001419 /* MarkdownPanelView.swift */,
A5001510 /* CmuxWebView.swift */,
@ -456,6 +465,7 @@
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */,
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */,
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */,
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */,
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */,
);
@ -470,7 +480,9 @@
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */,
FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */,
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */,
F6100001A1B2C3D4E5F60718 /* WorkspaceRemoteConnectionTests.swift */,
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */,
@ -659,6 +671,7 @@
A5001402 /* BrowserPanel.swift in Sources */,
A5001403 /* TerminalPanelView.swift in Sources */,
A5001404 /* BrowserPanelView.swift in Sources */,
A5007420 /* BrowserPopupWindowController.swift in Sources */,
A5001420 /* MarkdownPanel.swift in Sources */,
A5001421 /* MarkdownPanelView.swift in Sources */,
A5001500 /* CmuxWebView.swift in Sources */,
@ -696,6 +709,7 @@
B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */,
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */,
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */,
FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */,
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */,
E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */,
);
@ -710,7 +724,9 @@
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
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

@ -12,6 +12,21 @@
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Folder</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.folder</string>
</array>
</dict>
</array>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
@ -120,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>

File diff suppressed because it is too large Load diff

View file

@ -145,6 +145,40 @@ _cmux_pr_output_indicates_no_pull_request() {
|| "$output" == *"no pull request associated"* ]]
}
_cmux_github_repo_slug_for_path() {
local repo_path="$1"
local remote_url="" path_part=""
[[ -n "$repo_path" ]] || return 0
remote_url="$(git -C "$repo_path" remote get-url origin 2>/dev/null)"
[[ -n "$remote_url" ]] || return 0
case "$remote_url" in
git@github.com:*)
path_part="${remote_url#git@github.com:}"
;;
ssh://git@github.com/*)
path_part="${remote_url#ssh://git@github.com/}"
;;
https://github.com/*)
path_part="${remote_url#https://github.com/}"
;;
http://github.com/*)
path_part="${remote_url#http://github.com/}"
;;
git://github.com/*)
path_part="${remote_url#git://github.com/}"
;;
*)
return 0
;;
esac
path_part="${path_part%.git}"
[[ "$path_part" == */* ]] || return 0
printf '%s\n' "$path_part"
}
_cmux_report_pr_for_path() {
local repo_path="$1"
[[ -n "$repo_path" ]] || {
@ -159,18 +193,26 @@ _cmux_report_pr_for_path() {
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
local branch gh_output gh_error="" err_file="" gh_status number state url status_opt=""
local branch repo_slug="" gh_output="" gh_error="" err_file="" gh_status number state url status_opt=""
local explicit_branch_output="" explicit_branch_error="" explicit_branch_status=0
local implicit_probe_indicates_no_pr=0 explicit_probe_indicates_no_pr=0
local -a gh_repo_args=()
branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)"
if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
_cmux_clear_pr_for_panel
return 0
fi
repo_slug="$(_cmux_github_repo_slug_for_path "$repo_path")"
if [[ -n "$repo_slug" ]]; then
gh_repo_args=(--repo "$repo_slug")
fi
err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)"
[[ -n "$err_file" ]] || return 1
gh_output="$(
builtin cd "$repo_path" 2>/dev/null \
&& gh pr view \
"${gh_repo_args[@]}" \
--json number,state,url \
--jq '[.number, .state, .url] | @tsv' \
2>"$err_file"
@ -180,18 +222,54 @@ _cmux_report_pr_for_path() {
gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)"
/bin/rm -f -- "$err_file" >/dev/null 2>&1 || true
fi
if (( gh_status != 0 )); then
if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then
_cmux_clear_pr_for_panel
return 0
if (( gh_status == 0 )) && [[ -n "$gh_output" ]]; then
:
else
if (( gh_status == 0 )) && [[ -z "$gh_output" ]]; then
implicit_probe_indicates_no_pr=1
elif _cmux_pr_output_indicates_no_pull_request "$gh_error"; then
implicit_probe_indicates_no_pr=1
fi
# `gh pr view` without an explicit branch can fail to resolve the
# current worktree branch even when the branch has a PR. Fall back to
# the explicit branch name before concluding there is no PR.
err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)"
[[ -n "$err_file" ]] || return 1
explicit_branch_output="$(
builtin cd "$repo_path" 2>/dev/null \
&& gh pr view "$branch" \
"${gh_repo_args[@]}" \
--json number,state,url \
--jq '[.number, .state, .url] | @tsv' \
2>"$err_file"
)"
explicit_branch_status=$?
if [[ -f "$err_file" ]]; then
explicit_branch_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)"
/bin/rm -f -- "$err_file" >/dev/null 2>&1 || true
fi
if (( explicit_branch_status == 0 )) && [[ -n "$explicit_branch_output" ]]; then
gh_output="$explicit_branch_output"
gh_status=0
else
if (( explicit_branch_status == 0 )) && [[ -z "$explicit_branch_output" ]]; then
explicit_probe_indicates_no_pr=1
elif _cmux_pr_output_indicates_no_pull_request "$explicit_branch_error"; then
explicit_probe_indicates_no_pr=1
fi
if (( implicit_probe_indicates_no_pr )) && (( explicit_probe_indicates_no_pr )); then
_cmux_clear_pr_for_panel
return 0
fi
# Preserve the last-known PR badge when gh fails transiently, then retry
# on the next background poll instead of clearing visible state.
return 1
fi
# Preserve the last-known PR badge when gh fails transiently, then retry
# on the next background poll instead of clearing visible state.
return 1
fi
if [[ -z "$gh_output" ]]; then
_cmux_clear_pr_for_panel
return 0
fi
IFS=$'\t' read -r number state url <<< "$gh_output"
@ -376,11 +454,18 @@ _cmux_prompt_command() {
if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then
local head_signature
head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)"
if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
git_head_changed=1
# Also invalidate the PR poller so it refreshes with the new branch.
_CMUX_PR_FORCE=1
if [[ -n "$head_signature" ]]; then
if [[ -z "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
# The first observed HEAD value is just the session baseline.
# Treating it as a branch change clears restore-seeded PR badges
# before the first background probe can confirm the current PR.
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
elif [[ "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
git_head_changed=1
# Also invalidate the PR poller so it refreshes with the new branch.
_CMUX_PR_FORCE=1
fi
fi
fi

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).
@ -169,6 +263,40 @@ _cmux_pr_output_indicates_no_pull_request() {
|| "$output" == *"no pull request associated"* ]]
}
_cmux_github_repo_slug_for_path() {
local repo_path="$1"
local remote_url="" path_part=""
[[ -n "$repo_path" ]] || return 0
remote_url="$(git -C "$repo_path" remote get-url origin 2>/dev/null)"
[[ -n "$remote_url" ]] || return 0
case "$remote_url" in
git@github.com:*)
path_part="${remote_url#git@github.com:}"
;;
ssh://git@github.com/*)
path_part="${remote_url#ssh://git@github.com/}"
;;
https://github.com/*)
path_part="${remote_url#https://github.com/}"
;;
http://github.com/*)
path_part="${remote_url#http://github.com/}"
;;
git://github.com/*)
path_part="${remote_url#git://github.com/}"
;;
*)
return 0
;;
esac
path_part="${path_part%.git}"
[[ "$path_part" == */* ]] || return 0
print -r -- "$path_part"
}
_cmux_report_pr_for_path() {
local repo_path="$1"
[[ -n "$repo_path" ]] || {
@ -183,18 +311,27 @@ _cmux_report_pr_for_path() {
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
local branch gh_output gh_error="" err_file="" number state url status_opt="" gh_status
local branch repo_slug="" gh_output="" gh_error="" err_file="" number state url status_opt="" gh_status
local explicit_branch_output="" explicit_branch_error="" explicit_branch_status=0
local implicit_probe_indicates_no_pr=0 explicit_probe_indicates_no_pr=0
local -a gh_repo_args
gh_repo_args=()
branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)"
if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
_cmux_clear_pr_for_panel
return 0
fi
repo_slug="$(_cmux_github_repo_slug_for_path "$repo_path")"
if [[ -n "$repo_slug" ]]; then
gh_repo_args=(--repo "$repo_slug")
fi
err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)"
[[ -n "$err_file" ]] || return 1
gh_output="$(
builtin cd "$repo_path" 2>/dev/null \
&& gh pr view \
"${gh_repo_args[@]}" \
--json number,state,url \
--jq '[.number, .state, .url] | @tsv' \
2>"$err_file"
@ -204,18 +341,54 @@ _cmux_report_pr_for_path() {
gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)"
/bin/rm -f -- "$err_file" >/dev/null 2>&1 || true
fi
if (( gh_status != 0 )); then
if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then
_cmux_clear_pr_for_panel
return 0
if (( gh_status == 0 )) && [[ -n "$gh_output" ]]; then
:
else
if (( gh_status == 0 )) && [[ -z "$gh_output" ]]; then
implicit_probe_indicates_no_pr=1
elif _cmux_pr_output_indicates_no_pull_request "$gh_error"; then
implicit_probe_indicates_no_pr=1
fi
# `gh pr view` without an explicit branch can fail to resolve the
# current worktree branch even when the branch has a PR. Fall back to
# the explicit branch name before concluding there is no PR.
err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)"
[[ -n "$err_file" ]] || return 1
explicit_branch_output="$(
builtin cd "$repo_path" 2>/dev/null \
&& gh pr view "$branch" \
"${gh_repo_args[@]}" \
--json number,state,url \
--jq '[.number, .state, .url] | @tsv' \
2>"$err_file"
)"
explicit_branch_status=$?
if [[ -f "$err_file" ]]; then
explicit_branch_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)"
/bin/rm -f -- "$err_file" >/dev/null 2>&1 || true
fi
if (( explicit_branch_status == 0 )) && [[ -n "$explicit_branch_output" ]]; then
gh_output="$explicit_branch_output"
gh_status=0
else
if (( explicit_branch_status == 0 )) && [[ -z "$explicit_branch_output" ]]; then
explicit_probe_indicates_no_pr=1
elif _cmux_pr_output_indicates_no_pull_request "$explicit_branch_error"; then
explicit_probe_indicates_no_pr=1
fi
if (( implicit_probe_indicates_no_pr )) && (( explicit_probe_indicates_no_pr )); then
_cmux_clear_pr_for_panel
return 0
fi
# Keep the last-known PR badge on transient gh failures (auth hiccups,
# API lag after creation, or rate limiting) and retry on the next poll.
return 1
fi
# Keep the last-known PR badge on transient gh failures (auth hiccups,
# API lag after creation, or rate limiting) and retry on the next poll.
return 1
fi
if [[ -z "$gh_output" ]]; then
_cmux_clear_pr_for_panel
return 0
fi
local IFS=$'\t'
@ -399,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)"
@ -413,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
@ -453,14 +631,21 @@ _cmux_precmd() {
if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then
local head_signature
head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)"
if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
git_head_changed=1
# Treat HEAD file change like a git command — force-replace any
# running probe so the sidebar picks up the new branch immediately.
_CMUX_GIT_FORCE=1
_CMUX_PR_FORCE=1
should_git=1
if [[ -n "$head_signature" ]]; then
if [[ -z "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
# The first observed HEAD value establishes the baseline for this
# shell session. Don't treat it as a branch change or we'll clear
# restore-seeded PR badges before the first background probe runs.
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
elif [[ "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
git_head_changed=1
# Treat HEAD file change like a git command — force-replace any
# running probe so the sidebar picks up the new branch immediately.
_CMUX_GIT_FORCE=1
_CMUX_PR_FORCE=1
should_git=1
fi
fi
fi

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -110,6 +110,45 @@ enum BrowserDevToolsButtonDebugSettings {
}
}
enum BrowserToolbarAccessorySpacingDebugSettings {
static let key = "browserToolbarAccessorySpacing"
static let defaultSpacing = 2
static let supportedValues = [0, 2, 4, 6, 8]
static func resolved(_ rawValue: Int) -> Int {
supportedValues.contains(rawValue) ? rawValue : defaultSpacing
}
static func current(defaults: UserDefaults = .standard) -> Int {
resolved(defaults.object(forKey: key) as? Int ?? defaultSpacing)
}
}
enum BrowserProfilePopoverDebugSettings {
static let horizontalPaddingKey = "browserProfilePopoverHorizontalPadding"
static let verticalPaddingKey = "browserProfilePopoverVerticalPadding"
static let defaultHorizontalPadding = 12.0
static let defaultVerticalPadding = 10.0
static let horizontalPaddingRange = 8.0...20.0
static let verticalPaddingRange = 4.0...14.0
static func resolvedHorizontalPadding(_ rawValue: Double) -> Double {
horizontalPaddingRange.contains(rawValue) ? rawValue : defaultHorizontalPadding
}
static func resolvedVerticalPadding(_ rawValue: Double) -> Double {
verticalPaddingRange.contains(rawValue) ? rawValue : defaultVerticalPadding
}
static func currentHorizontalPadding(defaults: UserDefaults = .standard) -> Double {
resolvedHorizontalPadding((defaults.object(forKey: horizontalPaddingKey) as? NSNumber)?.doubleValue ?? defaultHorizontalPadding)
}
static func currentVerticalPadding(defaults: UserDefaults = .standard) -> Double {
resolvedVerticalPadding((defaults.object(forKey: verticalPaddingKey) as? NSNumber)?.doubleValue ?? defaultVerticalPadding)
}
}
struct OmnibarInlineCompletion: Equatable {
let typedText: String
let displayText: String
@ -203,9 +242,39 @@ func resolvedBrowserOmnibarPillBackgroundColor(
return themeBackgroundColor.blended(withFraction: darkenMix, of: .black) ?? themeBackgroundColor
}
private struct BrowserChromeStyle {
let backgroundColor: NSColor
let colorScheme: ColorScheme
let omnibarPillBackgroundColor: NSColor
static func resolve(
for colorScheme: ColorScheme,
themeBackgroundColor: NSColor
) -> BrowserChromeStyle {
let backgroundColor = resolvedBrowserChromeBackgroundColor(
for: colorScheme,
themeBackgroundColor: themeBackgroundColor
)
let chromeColorScheme = resolvedBrowserChromeColorScheme(
for: colorScheme,
themeBackgroundColor: backgroundColor
)
let omnibarPillBackgroundColor = resolvedBrowserOmnibarPillBackgroundColor(
for: chromeColorScheme,
themeBackgroundColor: backgroundColor
)
return BrowserChromeStyle(
backgroundColor: backgroundColor,
colorScheme: chromeColorScheme,
omnibarPillBackgroundColor: omnibarPillBackgroundColor
)
}
}
/// View for rendering a browser panel with address bar
struct BrowserPanelView: View {
@ObservedObject var panel: BrowserPanel
@ObservedObject private var browserProfileStore = BrowserProfileStore.shared
let paneId: PaneID
let isFocused: Bool
let isVisibleInUI: Bool
@ -219,11 +288,24 @@ struct BrowserPanelView: View {
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabledStorage = BrowserSearchSettings.defaultSearchSuggestionsEnabled
@AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
@AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
@AppStorage(BrowserToolbarAccessorySpacingDebugSettings.key) private var browserToolbarAccessorySpacingRaw = BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing
@AppStorage(BrowserProfilePopoverDebugSettings.horizontalPaddingKey)
private var browserProfilePopoverHorizontalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultHorizontalPadding
@AppStorage(BrowserProfilePopoverDebugSettings.verticalPaddingKey)
private var browserProfilePopoverVerticalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultVerticalPadding
@AppStorage(BrowserThemeSettings.modeKey) private var browserThemeModeRaw = BrowserThemeSettings.defaultMode.rawValue
@AppStorage(BrowserImportHintSettings.variantKey) private var browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue
@AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs
@AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed
@AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey)
private var toggleBrowserDeveloperToolsShortcutData = Data()
@State private var suggestionTask: Task<Void, Never>?
@State private var isLoadingRemoteSuggestions: Bool = false
@State private var latestRemoteSuggestionQuery: String = ""
@State private var latestRemoteSuggestions: [String] = []
@State private var emptyStateImportBrowsers: [InstalledBrowserCandidate] = []
@State private var emptyStateImportBrowserRefreshTask: Task<Void, Never>?
@State private var emptyStateImportBrowserRefreshGeneration: UInt64 = 0
@State private var inlineCompletion: OmnibarInlineCompletion?
@State private var omnibarSelectionRange: NSRange = NSRange(location: NSNotFound, length: 0)
@State private var omnibarHasMarkedText: Bool = false
@ -232,11 +314,17 @@ struct BrowserPanelView: View {
@State private var focusFlashAnimationGeneration: Int = 0
@State private var omnibarPillFrame: CGRect = .zero
@State private var addressBarHeight: CGFloat = 0
@State private var isBrowserImportHintPopoverPresented = false
@State private var lastHandledAddressBarFocusRequestId: UUID?
@State private var pendingAddressBarFocusRetryRequestId: UUID?
@State private var pendingAddressBarFocusRetryGeneration: UInt64 = 0
@State private var isBrowserProfileMenuPresented = false
@State private var isBrowserThemeMenuPresented = false
@State private var ghosttyBackgroundGeneration: Int = 0
@State private var browserChromeStyle = BrowserChromeStyle.resolve(
for: .light,
themeBackgroundColor: GhosttyBackgroundTheme.currentColor()
)
@State private var toggleBrowserDeveloperToolsShortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
// Keep this below half of the compact omnibar height so it reads as a squircle,
// not a capsule.
private let omnibarPillCornerRadius: CGFloat = 10
@ -281,25 +369,40 @@ struct BrowserPanelView: View {
BrowserThemeSettings.mode(for: browserThemeModeRaw)
}
private var browserImportHintVariant: BrowserImportHintVariant {
BrowserImportHintSettings.variant(for: browserImportHintVariantRaw)
}
private var browserImportHintPresentation: BrowserImportHintPresentation {
BrowserImportHintPresentation(
variant: browserImportHintVariant,
showOnBlankTabs: showBrowserImportHintOnBlankTabs,
isDismissed: isBrowserImportHintDismissed
)
}
private var browserToolbarAccessorySpacing: CGFloat {
CGFloat(BrowserToolbarAccessorySpacingDebugSettings.resolved(browserToolbarAccessorySpacingRaw))
}
private var browserProfilePopoverHorizontalPadding: CGFloat {
CGFloat(BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(browserProfilePopoverHorizontalPaddingRaw))
}
private var browserProfilePopoverVerticalPadding: CGFloat {
CGFloat(BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(browserProfilePopoverVerticalPaddingRaw))
}
private var browserChromeBackground: Color {
_ = ghosttyBackgroundGeneration
return Color(nsColor: GhosttyBackgroundTheme.currentColor())
Color(nsColor: browserChromeStyle.backgroundColor)
}
private var browserChromeBackgroundColor: NSColor {
_ = ghosttyBackgroundGeneration
return resolvedBrowserChromeBackgroundColor(
for: colorScheme,
themeBackgroundColor: GhosttyBackgroundTheme.currentColor()
)
browserChromeStyle.backgroundColor
}
private var browserChromeColorScheme: ColorScheme {
_ = ghosttyBackgroundGeneration
return resolvedBrowserChromeColorScheme(
for: colorScheme,
themeBackgroundColor: GhosttyBackgroundTheme.currentColor()
)
browserChromeStyle.colorScheme
}
private var browserContentAccessibilityIdentifier: String {
@ -307,10 +410,20 @@ struct BrowserPanelView: View {
}
private var omnibarPillBackgroundColor: NSColor {
resolvedBrowserOmnibarPillBackgroundColor(
for: browserChromeColorScheme,
themeBackgroundColor: browserChromeBackgroundColor
)
browserChromeStyle.omnibarPillBackgroundColor
}
private var developerToolsButtonHelp: String {
let base = String(localized: "browser.toggleDevTools", defaultValue: "Toggle Developer Tools")
return "\(base) (\(toggleBrowserDeveloperToolsShortcut.displayString))"
}
private var browserImportHintSummary: String {
InstalledBrowserDetector.summaryText(for: emptyStateImportBrowsers)
}
private var shouldShowToolbarImportHintChip: Bool {
shouldShowEmptyStateImportOverlay && browserImportHintPresentation.blankTabPlacement == .toolbarChip
}
private var owningWorkspace: Workspace? {
@ -418,12 +531,33 @@ struct BrowserPanelView: View {
UserDefaults.standard.register(defaults: [
BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue,
BrowserSearchSettings.searchSuggestionsEnabledKey: BrowserSearchSettings.defaultSearchSuggestionsEnabled,
BrowserToolbarAccessorySpacingDebugSettings.key: BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing,
BrowserProfilePopoverDebugSettings.horizontalPaddingKey: BrowserProfilePopoverDebugSettings.defaultHorizontalPadding,
BrowserProfilePopoverDebugSettings.verticalPaddingKey: BrowserProfilePopoverDebugSettings.defaultVerticalPadding,
BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue,
])
refreshBrowserChromeStyle()
refreshToggleBrowserDeveloperToolsShortcut()
let resolvedThemeMode = BrowserThemeSettings.mode(defaults: .standard)
if browserThemeModeRaw != resolvedThemeMode.rawValue {
browserThemeModeRaw = resolvedThemeMode.rawValue
}
let resolvedHintVariant = BrowserImportHintSettings.variant(for: browserImportHintVariantRaw)
if browserImportHintVariantRaw != resolvedHintVariant.rawValue {
browserImportHintVariantRaw = resolvedHintVariant.rawValue
}
let resolvedToolbarAccessorySpacing = BrowserToolbarAccessorySpacingDebugSettings.resolved(browserToolbarAccessorySpacingRaw)
if browserToolbarAccessorySpacingRaw != resolvedToolbarAccessorySpacing {
browserToolbarAccessorySpacingRaw = resolvedToolbarAccessorySpacing
}
let resolvedProfilePopoverHorizontalPadding = BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(browserProfilePopoverHorizontalPaddingRaw)
if browserProfilePopoverHorizontalPaddingRaw != resolvedProfilePopoverHorizontalPadding {
browserProfilePopoverHorizontalPaddingRaw = resolvedProfilePopoverHorizontalPadding
}
let resolvedProfilePopoverVerticalPadding = BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(browserProfilePopoverVerticalPaddingRaw)
if browserProfilePopoverVerticalPaddingRaw != resolvedProfilePopoverVerticalPadding {
browserProfilePopoverVerticalPaddingRaw = resolvedProfilePopoverVerticalPadding
}
panel.refreshAppearanceDrivenColors()
panel.setBrowserThemeMode(browserThemeMode)
applyPendingAddressBarFocusRequestIfNeeded()
@ -431,7 +565,8 @@ struct BrowserPanelView: View {
// If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar.
autoFocusOmnibarIfBlank()
syncWebViewResponderPolicyWithViewState(reason: "onAppear")
BrowserHistoryStore.shared.loadIfNeeded()
refreshEmptyStateImportBrowsers()
panel.historyStore.loadIfNeeded()
#if DEBUG
logBrowserFocusState(event: "view.onAppear")
#endif
@ -450,6 +585,9 @@ struct BrowserPanelView: View {
!isWebViewBlank() {
setAddressBarFocused(false, reason: "panel.currentURL.loaded")
}
if isWebViewBlank() {
refreshEmptyStateImportBrowsers()
}
}
.onChange(of: browserThemeModeRaw) { _ in
let normalizedMode = BrowserThemeSettings.mode(for: browserThemeModeRaw)
@ -459,11 +597,21 @@ struct BrowserPanelView: View {
panel.setBrowserThemeMode(normalizedMode)
}
.onChange(of: colorScheme) { _ in
refreshBrowserChromeStyle()
panel.refreshAppearanceDrivenColors()
}
.onChange(of: toggleBrowserDeveloperToolsShortcutData) { _ in
refreshToggleBrowserDeveloperToolsShortcut()
}
.onChange(of: panel.pendingAddressBarFocusRequestId) { _ in
applyPendingAddressBarFocusRequestIfNeeded()
}
.onChange(of: panel.profileID) { _ in
panel.historyStore.loadIfNeeded()
if addressBarFocused {
refreshSuggestions()
}
}
.onChange(of: isFocused) { focused in
#if DEBUG
logBrowserFocusState(
@ -536,7 +684,7 @@ struct BrowserPanelView: View {
applyOmnibarEffects(effects)
refreshInlineCompletion()
}
.onReceive(BrowserHistoryStore.shared.$entries) { _ in
.onReceive(panel.historyStore.$entries) { _ in
guard addressBarFocused else { return }
refreshSuggestions()
}
@ -552,7 +700,7 @@ struct BrowserPanelView: View {
}
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in
ghosttyBackgroundGeneration &+= 1
refreshBrowserChromeStyle()
}
}
@ -564,7 +712,11 @@ struct BrowserPanelView: View {
.accessibilityIdentifier("BrowserOmnibarPill")
.accessibilityLabel("Browser omnibar")
if !panel.isShowingNewTabPage {
HStack(spacing: browserToolbarAccessorySpacing) {
if shouldShowToolbarImportHintChip {
browserImportHintToolbarChip
}
browserProfileButton
browserThemeModeButton
developerToolsButton
}
@ -668,10 +820,38 @@ struct BrowserPanelView: View {
}
.buttonStyle(OmnibarAddressButtonStyle())
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.safeHelp(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.tooltip(String(localized: "browser.toggleDevTools", defaultValue: "Toggle Developer Tools")))
.safeHelp(developerToolsButtonHelp)
.accessibilityIdentifier("BrowserToggleDevToolsButton")
}
private var browserProfileButton: some View {
Button(action: {
isBrowserProfileMenuPresented.toggle()
}) {
Image(systemName: "person.crop.circle")
.symbolRenderingMode(.monochrome)
.cmuxFlatSymbolColorRendering()
.font(.system(size: devToolsButtonIconSize, weight: .medium))
.foregroundStyle(devToolsColorOption.color)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
}
.buttonStyle(OmnibarAddressButtonStyle())
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.popover(isPresented: $isBrowserProfileMenuPresented, arrowEdge: .bottom) {
browserProfilePopover
}
.safeHelp(
String(
format: String(
localized: "browser.profile.buttonHelp",
defaultValue: "Browser Profile: %@"
),
panel.profileDisplayName
)
)
.accessibilityIdentifier("BrowserProfileButton")
}
private var browserThemeModeButton: some View {
Button(action: {
isBrowserThemeMenuPresented.toggle()
@ -688,10 +868,108 @@ struct BrowserPanelView: View {
.popover(isPresented: $isBrowserThemeMenuPresented, arrowEdge: .bottom) {
browserThemeModePopover
}
.safeHelp("Browser Theme: \(browserThemeMode.displayName)")
.safeHelp(
String(
format: String(
localized: "browser.theme.buttonHelp",
defaultValue: "Browser Theme: %@"
),
browserThemeMode.displayName
)
)
.accessibilityIdentifier("BrowserThemeModeButton")
}
private var browserImportHintToolbarChip: some View {
Button(action: {
isBrowserImportHintPopoverPresented.toggle()
}) {
HStack(spacing: 4) {
Image(systemName: "square.and.arrow.down.on.square")
.font(.system(size: 10, weight: .medium))
Text(String(localized: "browser.import.hint.toolbar", defaultValue: "Import"))
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
}
.foregroundStyle(devToolsColorOption.color)
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
.buttonStyle(OmnibarAddressButtonStyle())
.popover(isPresented: $isBrowserImportHintPopoverPresented, arrowEdge: .bottom) {
browserImportHintPopover
}
.safeHelp(String(localized: "browser.import.hint.toolbar.help", defaultValue: "Import browser data"))
.accessibilityIdentifier("BrowserImportHintToolbarChip")
}
private var browserProfilePopover: some View {
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: "browser.profile.menu.title", defaultValue: "Profiles"))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
ForEach(browserProfileStore.profiles) { profile in
Button {
applyBrowserProfileSelection(profile.id)
} label: {
HStack(spacing: 8) {
Image(systemName: profile.id == panel.profileID ? "checkmark" : "circle")
.font(.system(size: 10, weight: .semibold))
.opacity(profile.id == panel.profileID ? 1.0 : 0.0)
.frame(width: 12, alignment: .center)
Text(profile.displayName)
.font(.system(size: 12))
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.frame(height: 24)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(profile.id == panel.profileID ? Color.primary.opacity(0.12) : Color.clear)
)
}
.buttonStyle(.plain)
}
}
Divider()
Button {
isBrowserProfileMenuPresented = false
presentCreateBrowserProfilePrompt()
} label: {
Text(String(localized: "browser.profile.new", defaultValue: "New Profile..."))
.font(.system(size: 12))
}
.buttonStyle(.plain)
Button {
presentImportDialogFromProfileMenu()
} label: {
Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…"))
.font(.system(size: 12))
}
.buttonStyle(.plain)
if browserProfileStore.canRenameProfile(id: panel.profileID) {
Button {
isBrowserProfileMenuPresented = false
presentRenameBrowserProfilePrompt()
} label: {
Text(String(localized: "browser.profile.rename", defaultValue: "Rename Current Profile..."))
.font(.system(size: 12))
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, browserProfilePopoverHorizontalPadding)
.padding(.vertical, browserProfilePopoverVerticalPadding)
.frame(minWidth: 208)
}
private var browserThemeModePopover: some View {
VStack(alignment: .leading, spacing: 2) {
ForEach(BrowserThemeMode.allCases) { mode in
@ -876,6 +1154,18 @@ struct BrowserPanelView: View {
setAddressBarFocused(false, reason: "placeholderContent.tapBlur")
}
}
.overlay(alignment: .topLeading) {
if shouldShowEmptyStateImportOverlay,
browserImportHintPresentation.blankTabPlacement == .inlineStrip {
emptyBrowserStateInlineStrip
}
}
.overlay {
if shouldShowEmptyStateImportOverlay,
browserImportHintPresentation.blankTabPlacement == .floatingCard {
emptyBrowserStateCardOverlay
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
@ -907,6 +1197,28 @@ struct BrowserPanelView: View {
}
}
private func refreshBrowserChromeStyle() {
browserChromeStyle = BrowserChromeStyle.resolve(
for: colorScheme,
themeBackgroundColor: GhosttyBackgroundTheme.currentColor()
)
}
private func refreshToggleBrowserDeveloperToolsShortcut() {
toggleBrowserDeveloperToolsShortcut = decodeShortcut(
from: toggleBrowserDeveloperToolsShortcutData,
fallback: KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
)
}
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
guard !data.isEmpty,
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
return fallback
}
return shortcut
}
private func syncWebViewResponderPolicyWithViewState(
reason: String,
isPanelFocusedOverride: Bool? = nil
@ -1119,6 +1431,155 @@ struct BrowserPanelView: View {
#endif
}
private var emptyBrowserStateCardOverlay: some View {
VStack {
Spacer(minLength: 22)
browserImportHintBody
.padding(12)
.frame(maxWidth: 360, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor).opacity(0.9))
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(
Color(nsColor: .separatorColor).opacity(0.45),
lineWidth: 1
)
)
.shadow(color: Color.black.opacity(0.08), radius: 8, y: 3)
Spacer()
}
.padding(.horizontal, 18)
}
private var emptyBrowserStateInlineStrip: some View {
VStack(alignment: .leading, spacing: 0) {
browserImportHintBody
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: 520, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor).opacity(0.84))
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(
Color(nsColor: .separatorColor).opacity(0.35),
lineWidth: 1
)
)
.shadow(color: Color.black.opacity(0.05), radius: 6, y: 2)
Spacer(minLength: 0)
}
.padding(.horizontal, 18)
.padding(.top, 14)
}
private var browserImportHintPopover: some View {
browserImportHintBody
.padding(12)
.frame(width: 300, alignment: .leading)
}
private var browserImportHintBody: some View {
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data"))
.font(.system(size: 12.5, weight: .semibold))
Text(browserImportHintSummary)
.font(.system(size: 11.5))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser."))
.font(.system(size: 10.5))
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
ViewThatFits(in: .horizontal) {
HStack(spacing: 10) {
browserImportHintPrimaryButton
browserImportHintSettingsButton
browserImportHintDismissButton
}
VStack(alignment: .leading, spacing: 8) {
browserImportHintPrimaryButton
HStack(spacing: 10) {
browserImportHintSettingsButton
browserImportHintDismissButton
}
}
}
}
.accessibilityElement(children: .contain)
}
private var browserImportHintPrimaryButton: some View {
Button(String(localized: "browser.import.hint.import", defaultValue: "Import…")) {
presentImportDialogFromHint()
}
.buttonStyle(.bordered)
.controlSize(.small)
.accessibilityIdentifier("BrowserImportHintImportButton")
}
private var browserImportHintSettingsButton: some View {
Button(String(localized: "browser.import.hint.settings", defaultValue: "Browser Settings")) {
openBrowserImportSettings()
}
.buttonStyle(.plain)
.controlSize(.small)
.accessibilityIdentifier("BrowserImportHintSettingsButton")
}
private var browserImportHintDismissButton: some View {
Button(String(localized: "browser.import.hint.dismiss", defaultValue: "Hide Hint")) {
dismissBrowserImportHint()
}
.buttonStyle(.plain)
.controlSize(.small)
.accessibilityIdentifier("BrowserImportHintDismissButton")
}
private var shouldShowEmptyStateImportOverlay: Bool {
!panel.shouldRenderWebView && isWebViewBlank()
}
private func presentImportDialogFromHint() {
isBrowserImportHintPopoverPresented = false
// Let the popover fully dismiss before entering the modal import flow.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
BrowserDataImportCoordinator.shared.presentImportDialog(
defaultDestinationProfileID: panel.profileID
)
}
}
private func presentImportDialogFromProfileMenu() {
isBrowserProfileMenuPresented = false
DispatchQueue.main.async {
BrowserDataImportCoordinator.shared.presentImportDialog(
defaultDestinationProfileID: panel.profileID
)
}
}
private func openBrowserImportSettings() {
isBrowserImportHintPopoverPresented = false
AppDelegate.presentPreferencesWindow(navigationTarget: .browserImport)
}
private func dismissBrowserImportHint() {
showBrowserImportHintOnBlankTabs = false
isBrowserImportHintDismissed = true
isBrowserImportHintPopoverPresented = false
}
/// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes.
private func isWebViewBlank() -> Bool {
guard let url = panel.webView.url else { return true }
@ -1170,6 +1631,31 @@ struct BrowserPanelView: View {
#endif
}
private func refreshEmptyStateImportBrowsers() {
emptyStateImportBrowserRefreshTask?.cancel()
emptyStateImportBrowserRefreshGeneration &+= 1
let generation = emptyStateImportBrowserRefreshGeneration
guard shouldShowEmptyStateImportOverlay else {
emptyStateImportBrowsers = []
emptyStateImportBrowserRefreshTask = nil
return
}
emptyStateImportBrowserRefreshTask = Task {
let browsers = await Task.detached(priority: .utility) {
InstalledBrowserDetector.detectInstalledBrowsers()
}.value
guard !Task.isCancelled else { return }
await MainActor.run {
guard emptyStateImportBrowserRefreshGeneration == generation,
shouldShowEmptyStateImportOverlay else { return }
emptyStateImportBrowsers = browsers
emptyStateImportBrowserRefreshTask = nil
}
}
}
private func openDevTools() {
#if DEBUG
dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))")
@ -1273,10 +1759,74 @@ struct BrowserPanelView: View {
let target = omnibarState.suggestions[idx]
guard case .history(let url, _) = target.kind else { return }
guard BrowserHistoryStore.shared.removeHistoryEntry(urlString: url) else { return }
guard panel.historyStore.removeHistoryEntry(urlString: url) else { return }
refreshSuggestions()
}
private func applyBrowserProfileSelection(_ profileID: UUID) {
isBrowserProfileMenuPresented = false
let didApply = panel.profileID == profileID || panel.switchToProfile(profileID)
guard didApply else { return }
owningWorkspace?.setPreferredBrowserProfileID(profileID)
}
private func presentCreateBrowserProfilePrompt() {
let alert = NSAlert()
alert.messageText = String(localized: "browser.profile.new.title", defaultValue: "New Browser Profile")
alert.informativeText = String(localized: "browser.profile.new.message", defaultValue: "Create a separate browser profile for cookies, history, and local storage.")
let input = NSTextField(string: "")
input.placeholderString = String(localized: "browser.profile.new.placeholder", defaultValue: "Profile name")
input.frame = NSRect(x: 0, y: 0, width: 260, height: 22)
alert.accessoryView = input
alert.addButton(withTitle: String(localized: "common.create", defaultValue: "Create"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
let alertWindow = alert.window
alertWindow.initialFirstResponder = input
DispatchQueue.main.async {
alertWindow.makeFirstResponder(input)
input.selectText(nil)
}
guard alert.runModal() == .alertFirstButtonReturn,
let profile = browserProfileStore.createProfile(named: input.stringValue) else {
return
}
applyBrowserProfileSelection(profile.id)
}
private func presentRenameBrowserProfilePrompt() {
guard let profile = browserProfileStore.profileDefinition(id: panel.profileID),
browserProfileStore.canRenameProfile(id: profile.id) else {
return
}
let alert = NSAlert()
alert.messageText = String(localized: "browser.profile.rename.title", defaultValue: "Rename Browser Profile")
alert.informativeText = String(localized: "browser.profile.rename.message", defaultValue: "Choose a new name for this browser profile.")
let input = NSTextField(string: profile.displayName)
input.placeholderString = String(localized: "browser.profile.new.placeholder", defaultValue: "Profile name")
input.frame = NSRect(x: 0, y: 0, width: 260, height: 22)
alert.accessoryView = input
alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
let alertWindow = alert.window
alertWindow.initialFirstResponder = input
DispatchQueue.main.async {
alertWindow.makeFirstResponder(input)
input.selectText(nil)
}
guard alert.runModal() == .alertFirstButtonReturn else { return }
_ = browserProfileStore.renameProfile(id: profile.id, to: input.stringValue)
}
private func refreshInlineCompletion() {
inlineCompletion = omnibarInlineCompletionForDisplay(
typedText: omnibarState.buffer,
@ -1312,9 +1862,9 @@ struct BrowserPanelView: View {
let query = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines)
let historyEntries: [BrowserHistoryStore.Entry] = {
if query.isEmpty {
return BrowserHistoryStore.shared.recentSuggestions(limit: 12)
return panel.historyStore.recentSuggestions(limit: 12)
}
return BrowserHistoryStore.shared.suggestions(for: query, limit: 12)
return panel.historyStore.suggestions(for: query, limit: 12)
}()
let openTabMatches = query.isEmpty ? [] : matchingOpenTabSuggestions(for: query, limit: 12)
let isSingleCharacterQuery = omnibarSingleCharacterQuery(for: query) != nil
@ -1378,7 +1928,7 @@ struct BrowserPanelView: View {
let merged = buildOmnibarSuggestions(
query: query,
engineName: searchEngine.displayName,
historyEntries: BrowserHistoryStore.shared.suggestions(for: query, limit: 12),
historyEntries: panel.historyStore.suggestions(for: query, limit: 12),
openTabMatches: matchingOpenTabSuggestions(for: query, limit: 12),
remoteQueries: remote,
resolvedURL: panel.resolveNavigableURL(from: query),

View file

@ -0,0 +1,619 @@
import AppKit
import Bonsplit
import ObjectiveC
import WebKit
func browserPopupContentRect(
requestedWidth: CGFloat?,
requestedHeight: CGFloat?,
requestedX: CGFloat?,
requestedTopY: CGFloat?,
visibleFrame: NSRect,
defaultWidth: CGFloat = 800,
defaultHeight: CGFloat = 600,
minWidth: CGFloat = 200,
minHeight: CGFloat = 150
) -> NSRect {
let clampedWidth = min(max(requestedWidth ?? defaultWidth, minWidth), visibleFrame.width)
let clampedHeight = min(max(requestedHeight ?? defaultHeight, minHeight), visibleFrame.height)
let x: CGFloat
let y: CGFloat
if let requestedX, let requestedTopY {
x = max(visibleFrame.minX, min(requestedX, visibleFrame.maxX - clampedWidth))
// Web content expresses popup Y as distance from the screen's top edge,
// while AppKit window origins are bottom-up.
let appKitY = visibleFrame.maxY - requestedTopY - clampedHeight
y = max(visibleFrame.minY, min(appKitY, visibleFrame.maxY - clampedHeight))
} else {
x = visibleFrame.midX - clampedWidth / 2
y = visibleFrame.midY - clampedHeight / 2
}
return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight)
}
/// Hosts a popup `CmuxWebView` in a standalone `NSPanel`, created when a page
/// calls `window.open()` (scripted new-window requests).
///
/// Lifecycle:
/// - The controller self-retains via `objc_setAssociatedObject` on its panel.
/// - Released in `windowWillClose(_:)` when the panel closes.
/// - The opener `BrowserPanel` also keeps a strong reference for deterministic
/// cleanup when the opener tab or workspace is closed.
/// NSPanel subclass that intercepts Cmd+W before the swizzled
/// `cmux_performKeyEquivalent` can dispatch it to the main menu's
/// "Close Tab" action (which would close the parent browser tab).
private class BrowserPopupPanel: NSPanel {
override func performKeyEquivalent(with event: NSEvent) -> Bool {
// Cmd+W: close this popup panel only
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if flags == .command,
event.charactersIgnoringModifiers == "w" {
#if DEBUG
dlog("popup.panel.cmdW close")
#endif
performClose(nil)
return true
}
return super.performKeyEquivalent(with: event)
}
}
@MainActor
final class BrowserPopupWindowController: NSObject, NSWindowDelegate {
static let maxNestingDepth = 3
let webView: CmuxWebView
private let panel: NSPanel
private let urlLabel: NSTextField
private weak var openerPanel: BrowserPanel?
private weak var parentPopupController: BrowserPopupWindowController?
private let nestingDepth: Int
private var titleObservation: NSKeyValueObservation?
private var urlObservation: NSKeyValueObservation?
private var childPopups: [BrowserPopupWindowController] = []
private let popupUIDelegate: PopupUIDelegate
private let popupNavigationDelegate: PopupNavigationDelegate
private let downloadDelegate: BrowserDownloadDelegate
private static var associatedObjectKey: UInt8 = 0
init(
configuration: WKWebViewConfiguration,
windowFeatures: WKWindowFeatures,
openerPanel: BrowserPanel?,
parentPopupController: BrowserPopupWindowController? = nil,
nestingDepth: Int = 0
) {
self.openerPanel = openerPanel
self.parentPopupController = parentPopupController
self.nestingDepth = nestingDepth
// Create popup web view with WebKit's supplied configuration (preserves
// internal browsing-context state for opener linkage / postMessage).
let webView = CmuxWebView(frame: .zero, configuration: configuration)
webView.allowsBackForwardNavigationGestures = true
if #available(macOS 13.3, *) {
webView.isInspectable = true
}
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
self.webView = webView
// --- Window sizing from WKWindowFeatures ---
let defaultWidth: CGFloat = 800
let defaultHeight: CGFloat = 600
let minWidth: CGFloat = 200
let minHeight: CGFloat = 150
let w = max(windowFeatures.width?.doubleValue ?? defaultWidth, minWidth)
let h = max(windowFeatures.height?.doubleValue ?? defaultHeight, minHeight)
// Screen-clamping: use opener's screen or main screen
let screen = openerPanel?.webView.window?.screen ?? NSScreen.main ?? NSScreen.screens.first
let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
let contentRect = browserPopupContentRect(
requestedWidth: w,
requestedHeight: h,
requestedX: windowFeatures.x.map { CGFloat($0.doubleValue) },
requestedTopY: windowFeatures.y.map { CGFloat($0.doubleValue) },
visibleFrame: visibleFrame,
defaultWidth: defaultWidth,
defaultHeight: defaultHeight,
minWidth: minWidth,
minHeight: minHeight
)
// Style mask: titled + closable + resizable by default.
// allowsResizing is a separate property from chrome-visibility flags
// (toolbarsVisibility, menuBarVisibility, statusBarVisibility).
var styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable]
if windowFeatures.allowsResizing?.boolValue != false {
styleMask.insert(.resizable)
}
let panel = BrowserPopupPanel(
contentRect: contentRect,
styleMask: styleMask,
backing: .buffered,
defer: false
)
panel.identifier = NSUserInterfaceItemIdentifier("cmux.browser-popup")
panel.level = NSWindow.Level.normal
panel.hidesOnDeactivate = false
panel.isReleasedWhenClosed = false
panel.minSize = NSSize(width: minWidth, height: minHeight)
panel.title = String(localized: "browser.popup.loadingTitle", defaultValue: "Loading\u{2026}")
self.panel = panel
let urlLabel = NSTextField(labelWithString: "")
self.urlLabel = urlLabel
// Build delegate objects before super.init so they can be assigned
let uiDel = PopupUIDelegate()
let navDel = PopupNavigationDelegate()
let dlDel = BrowserDownloadDelegate()
self.popupUIDelegate = uiDel
self.popupNavigationDelegate = navDel
self.downloadDelegate = dlDel
super.init()
// --- URL label for phishing protection ---
urlLabel.translatesAutoresizingMaskIntoConstraints = false
urlLabel.font = .systemFont(ofSize: 11)
urlLabel.textColor = .secondaryLabelColor
urlLabel.lineBreakMode = .byTruncatingMiddle
urlLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let containerView = NSView()
containerView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(urlLabel)
containerView.addSubview(webView)
webView.translatesAutoresizingMaskIntoConstraints = false
panel.contentView = containerView
NSLayoutConstraint.activate([
urlLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 4),
urlLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8),
urlLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8),
urlLabel.heightAnchor.constraint(equalToConstant: 16),
webView.topAnchor.constraint(equalTo: urlLabel.bottomAnchor, constant: 2),
webView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
webView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
webView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])
// --- Delegates ---
uiDel.controller = self
navDel.controller = self
navDel.downloadDelegate = dlDel
webView.uiDelegate = uiDel
webView.navigationDelegate = navDel
// Context menu "Open Link in New Tab" open in opener's workspace,
// not as a nested popup. Falls back to system browser if opener is gone.
webView.onContextMenuOpenLinkInNewTab = { [weak self] url in
if let opener = self?.openerPanel {
opener.openLinkInNewTab(url: url)
} else {
NSWorkspace.shared.open(url)
}
}
// --- KVO for title and URL ---
titleObservation = webView.observe(\.title, options: [.new]) { [weak self] _, change in
guard let newTitle = change.newValue ?? nil, !newTitle.isEmpty else { return }
Task { @MainActor [weak self] in
self?.panel.title = newTitle
}
}
urlObservation = webView.observe(\.url, options: [.new]) { [weak self] _, change in
let displayURL = change.newValue??.absoluteString ?? ""
Task { @MainActor [weak self] in
self?.urlLabel.stringValue = displayURL
}
}
// --- Self-retention via associated object on panel ---
objc_setAssociatedObject(panel, &Self.associatedObjectKey, self, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
panel.delegate = self
#if DEBUG
dlog("popup.init depth=\(nestingDepth) size=\(Int(contentRect.width))x\(Int(contentRect.height)) opener=\(openerPanel?.id.uuidString.prefix(5) ?? "nil")")
#endif
panel.makeKeyAndOrderFront(self)
}
// MARK: - Child popup tracking
func addChildPopup(_ child: BrowserPopupWindowController) {
childPopups.append(child)
}
func removeChildPopup(_ child: BrowserPopupWindowController) {
childPopups.removeAll { $0 === child }
}
// MARK: - Popup lifecycle
func closePopup() {
panel.close() // triggers windowWillClose
}
func closeAllChildPopups() {
let children = childPopups
childPopups.removeAll()
for child in children {
child.closeAllChildPopups()
child.closePopup()
}
}
// MARK: - NSWindowDelegate
func windowWillClose(_ notification: Notification) {
#if DEBUG
dlog("popup.close depth=\(nestingDepth)")
#endif
closeAllChildPopups()
// Invalidate observations
titleObservation?.invalidate()
titleObservation = nil
urlObservation?.invalidate()
urlObservation = nil
// Tear down web view
webView.stopLoading()
webView.navigationDelegate = nil
webView.uiDelegate = nil
// Unregister from parent (opener panel or parent popup)
openerPanel?.removePopupController(self)
parentPopupController?.removeChildPopup(self)
// Release self-retention
objc_setAssociatedObject(panel, &Self.associatedObjectKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
// MARK: - Nested popup creation
func createNestedPopup(
configuration: WKWebViewConfiguration,
windowFeatures: WKWindowFeatures
) -> WKWebView? {
let nextDepth = nestingDepth + 1
if nextDepth > Self.maxNestingDepth {
#if DEBUG
dlog("popup.nested.blocked depth=\(nextDepth) max=\(Self.maxNestingDepth)")
#endif
return nil
}
let child = BrowserPopupWindowController(
configuration: configuration,
windowFeatures: windowFeatures,
openerPanel: openerPanel,
parentPopupController: self,
nestingDepth: nextDepth
)
addChildPopup(child)
return child.webView
}
func openInOpenerTab(_ url: URL) {
if let openerPanel {
openerPanel.openLinkInNewTab(url: url)
} else {
NSWorkspace.shared.open(url)
}
}
// MARK: - Insecure HTTP prompt (parity with main browser)
/// Shows the same 3-button insecure HTTP alert as the main browser.
/// Reuses the global helpers from BrowserPanel.swift.
fileprivate func presentInsecureHTTPAlert(
for url: URL,
in webView: WKWebView,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else {
decisionHandler(.cancel)
return
}
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = String(localized: "browser.error.insecure.title", defaultValue: "Connection isn\u{2019}t secure")
alert.informativeText = String(localized: "browser.error.insecure.message", defaultValue: "\(host) uses plain HTTP, so traffic can be read or modified on the network.\n\nOpen this URL in your default browser, or proceed in cmux.")
alert.addButton(withTitle: String(localized: "browser.openInDefaultBrowser", defaultValue: "Open in Default Browser"))
alert.addButton(withTitle: String(localized: "browser.proceedInCmux", defaultValue: "Proceed in cmux"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
alert.showsSuppressionButton = true
alert.suppressionButton?.title = String(localized: "browser.alwaysAllowHost", defaultValue: "Always allow this host in cmux")
let handleResponse: (NSApplication.ModalResponse) -> Void = { [weak alert] response in
if browserShouldPersistInsecureHTTPAllowlistSelection(
response: response,
suppressionEnabled: alert?.suppressionButton?.state == .on
) {
BrowserInsecureHTTPSettings.addAllowedHost(host)
}
switch response {
case .alertFirstButtonReturn:
// Open in default browser, cancel popup navigation
NSWorkspace.shared.open(url)
decisionHandler(.cancel)
case .alertSecondButtonReturn:
// Proceed in popup
decisionHandler(.allow)
default:
decisionHandler(.cancel)
}
}
if let window = webView.window {
alert.beginSheetModal(for: window, completionHandler: handleResponse)
return
}
handleResponse(alert.runModal())
}
}
// MARK: - PopupUIDelegate
private class PopupUIDelegate: NSObject, WKUIDelegate {
weak var controller: BrowserPopupWindowController?
func webViewDidClose(_ webView: WKWebView) {
#if DEBUG
dlog("popup.webViewDidClose")
#endif
controller?.closePopup()
}
func webView(
_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures
) -> WKWebView? {
// External URL check
if let url = navigationAction.request.url,
browserShouldOpenURLExternally(url) {
NSWorkspace.shared.open(url)
return nil
}
let isScriptedPopup = browserNavigationShouldCreatePopup(
navigationType: navigationAction.navigationType,
modifierFlags: navigationAction.modifierFlags,
buttonNumber: navigationAction.buttonNumber,
hasRecentMiddleClickIntent: CmuxWebView.hasRecentMiddleClickIntent(for: webView)
)
if isScriptedPopup {
return controller?.createNestedPopup(
configuration: configuration,
windowFeatures: windowFeatures
)
}
if let url = navigationAction.request.url {
controller?.openInOpenerTab(url)
}
return nil
}
// MARK: - JS Dialogs (parity with main browser)
private func javaScriptDialogTitle(for webView: WKWebView) -> String {
if let absolute = webView.url?.absoluteString, !absolute.isEmpty {
return String(localized: "browser.dialog.pageSaysAt", defaultValue: "The page at \(absolute) says:")
}
return String(localized: "browser.dialog.pageSays", defaultValue: "This page says:")
}
private func presentDialog(
_ alert: NSAlert,
for webView: WKWebView,
completion: @escaping (NSApplication.ModalResponse) -> Void
) {
if let window = webView.window {
alert.beginSheetModal(for: window, completionHandler: completion)
return
}
completion(alert.runModal())
}
func webView(
_ webView: WKWebView,
runJavaScriptAlertPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping () -> Void
) {
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = javaScriptDialogTitle(for: webView)
alert.informativeText = message
alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK"))
presentDialog(alert, for: webView) { _ in completionHandler() }
}
func webView(
_ webView: WKWebView,
runJavaScriptConfirmPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping (Bool) -> Void
) {
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = javaScriptDialogTitle(for: webView)
alert.informativeText = message
alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
presentDialog(alert, for: webView) { response in
completionHandler(response == .alertFirstButtonReturn)
}
}
func webView(
_ webView: WKWebView,
runJavaScriptTextInputPanelWithPrompt prompt: String,
defaultText: String?,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping (String?) -> Void
) {
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = javaScriptDialogTitle(for: webView)
alert.informativeText = prompt
alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24))
field.stringValue = defaultText ?? ""
alert.accessoryView = field
presentDialog(alert, for: webView) { response in
if response == .alertFirstButtonReturn {
completionHandler(field.stringValue)
} else {
completionHandler(nil)
}
}
}
func webView(
_ webView: WKWebView,
runOpenPanelWith parameters: WKOpenPanelParameters,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping ([URL]?) -> Void
) {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
panel.canChooseDirectories = parameters.allowsDirectories
panel.canChooseFiles = true
panel.begin { result in
completionHandler(result == .OK ? panel.urls : nil)
}
}
func webView(
_ webView: WKWebView,
requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo,
type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void
) {
decisionHandler(.prompt)
}
}
// MARK: - PopupNavigationDelegate
private class PopupNavigationDelegate: NSObject, WKNavigationDelegate {
weak var controller: BrowserPopupWindowController?
var downloadDelegate: WKDownloadDelegate?
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
// Only guard main-frame navigations
guard navigationAction.targetFrame?.isMainFrame != false else {
decisionHandler(.allow)
return
}
guard let url = navigationAction.request.url else {
decisionHandler(.allow)
return
}
// External URL schemes hand off to macOS
if browserShouldOpenURLExternally(url) {
NSWorkspace.shared.open(url)
#if DEBUG
dlog("popup.nav.external url=\(url.absoluteString)")
#endif
decisionHandler(.cancel)
return
}
// Insecure HTTP show same prompt as main browser
if browserShouldBlockInsecureHTTPURL(url) {
#if DEBUG
dlog("popup.nav.insecureHTTP url=\(url.absoluteString)")
#endif
controller?.presentInsecureHTTPAlert(for: url, in: webView, decisionHandler: decisionHandler)
return
}
decisionHandler(.allow)
}
func webView(
_ webView: WKWebView,
decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
) {
if !navigationResponse.isForMainFrame {
decisionHandler(.allow)
return
}
if let scheme = navigationResponse.response.url?.scheme?.lowercased(),
scheme != "http", scheme != "https" {
decisionHandler(.allow)
return
}
if let response = navigationResponse.response as? HTTPURLResponse {
let contentDisposition = response.value(forHTTPHeaderField: "Content-Disposition") ?? ""
if contentDisposition.lowercased().hasPrefix("attachment") {
decisionHandler(.download)
return
}
}
if !navigationResponse.canShowMIMEType {
decisionHandler(.download)
return
}
decisionHandler(.allow)
}
func webView(
_ webView: WKWebView,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
// Parity with main browser: performDefaultHandling enables system keychain
// lookups, MDM client certs, and SSO extensions (e.g. Microsoft Entra ID).
completionHandler(.performDefaultHandling, nil)
}
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
#if DEBUG
dlog("popup.download.didBecome source=navigationAction")
#endif
download.delegate = downloadDelegate
}
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
#if DEBUG
dlog("popup.download.didBecome source=navigationResponse")
#endif
download.delegate = downloadDelegate
}
}

View file

@ -53,6 +53,9 @@ final class CmuxWebView: WKWebView {
private static var contextMenuFallbackKey: UInt8 = 0
var onContextMenuDownloadStateChanged: ((Bool) -> Void)?
/// Called when "Open Link in New Tab" context menu is selected.
/// Bypasses createWebViewWith so the link opens as a tab, not a popup.
var onContextMenuOpenLinkInNewTab: ((URL) -> Void)?
var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)?
var contextMenuDefaultBrowserOpener: ((URL) -> Bool)?
/// Guard against background panes stealing first responder (e.g. page autofocus).
@ -1212,12 +1215,15 @@ final class CmuxWebView: WKWebView {
openLinkInsertionIndex = index + 1
}
// Rename "Open Link in New Window" to "Open Link in New Tab".
// The UIDelegate's createWebViewWith already handles the action
// by opening the link as a new surface in the same pane.
// Retarget "Open Link in New Window" to open as a tab, not a popup.
// Without this, WebKit's default action calls createWebViewWith with
// navigationType .other, which our classifier would treat as a scripted
// popup request.
if item.identifier?.rawValue == "WKMenuItemIdentifierOpenLinkInNewWindow"
|| item.title.contains("Open Link in New Window") {
item.title = String(localized: "browser.contextMenu.openLinkInNewTab", defaultValue: "Open Link in New Tab")
item.target = self
item.action = #selector(contextMenuOpenLinkInNewTab(_:))
}
if isDownloadImageMenuItem(item) {
@ -1275,6 +1281,14 @@ final class CmuxWebView: WKWebView {
}
}
@objc private func contextMenuOpenLinkInNewTab(_ sender: Any?) {
let point = lastContextMenuPoint
resolveContextMenuLinkURL(at: point) { [weak self] url in
guard let self, let url else { return }
self.onContextMenuOpenLinkInNewTab?(url)
}
}
@objc private func contextMenuDownloadImage(_ sender: Any?) {
let traceID = Self.makeContextDownloadTraceID(prefix: "img")
let point = lastContextMenuPoint

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

@ -228,6 +228,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable {
struct SessionBrowserPanelSnapshot: Codable, Sendable {
var urlString: String?
var profileID: UUID?
var shouldRenderWebView: Bool
var pageZoom: Double
var developerToolsVisible: Bool

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"

File diff suppressed because it is too large Load diff

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() {
// Default to manual update checks. This also prevents Sparkle from prompting at startup.
let defaults = UserDefaults.standard
defaults.register(defaults: [
"SUEnableAutomaticChecks": false,
"SUSendProfileInfo": false,
"SUAutomaticallyUpdate": false,
])
UpdateSettings.apply(to: defaults)
let hostBundle = Bundle.main
self.userDriver = UpdateDriver(viewModel: .init(), hostBundle: hostBundle)
@ -59,27 +95,26 @@ class UpdateController {
guard !didStartUpdater else { return }
ensureSparkleInstallationCache()
#if DEBUG
// UI tests need to exercise Sparkle's permission request deterministically.
// Clearing these defaults causes Sparkle to re-request permission on next start.
// Keep the permission-related defaults resettable for UI tests even though the
// 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 {
// cmux never enables automatic update checks; we rely on the in-app update pill.
// Sparkle reads these from defaults, but set them explicitly before starting.
let defaults = UserDefaults.standard
defaults.set(false, forKey: "SUEnableAutomaticChecks")
defaults.set(false, forKey: "SUSendProfileInfo")
defaults.set(false, forKey: "SUAutomaticallyUpdate")
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,
@ -201,7 +236,7 @@ class UpdateController {
/// Validate the check for updates menu item.
func validateMenuItem(_ item: NSMenuItem) -> Bool {
if item.action == #selector(checkForUpdates) {
// Always allow user-initiated checks; we start Sparkle lazily on first use.
// Always allow user-initiated checks; Sparkle can safely surface current progress.
return true
}
return true

View file

@ -13,6 +13,10 @@ enum UpdateFeedResolver {
}
extension UpdateDriver: SPUUpdaterDelegate {
func updaterShouldPromptForPermissionToCheck(forUpdates _: SPUUpdater) -> Bool {
false
}
func feedURLString(for updater: SPUUpdater) -> String? {
#if DEBUG
let env = ProcessInfo.processInfo.environment
@ -29,12 +33,21 @@ 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,
/// which occurs when automatic download is enabled.
func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool {
viewModel.clearDetectedUpdate()
viewModel.state = .installing(.init(
isAutoUpdate: true,
retryTerminatingApplication: immediateInstallHandler,
@ -56,6 +69,7 @@ extension UpdateDriver: SPUUpdaterDelegate {
}
func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
viewModel.recordDetectedUpdate(item)
let version = item.displayVersionString
let fileURL = item.fileURL?.absoluteString ?? ""
if fileURL.isEmpty {
@ -66,6 +80,7 @@ extension UpdateDriver: SPUUpdaterDelegate {
}
func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) {
viewModel.clearDetectedUpdate()
let nsError = error as NSError
let reasonValue = (nsError.userInfo[SPUNoUpdateFoundReasonKey] as? NSNumber)?.intValue
let reason = reasonValue.map { SPUNoUpdateFoundReason(rawValue: OSStatus($0)) } ?? nil
@ -80,13 +95,18 @@ extension UpdateDriver: SPUUpdaterDelegate {
}
}
@MainActor
func updater(_ updater: SPUUpdater, userDidMake _: SPUUserUpdateChoice, forUpdate _: SUAppcastItem, state _: SPUUserUpdateState) {
viewModel.clearDetectedUpdate()
}
func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
AppDelegate.shared?.persistSessionForUpdateRelaunch()
TerminalController.shared.stop()
NSApp.invalidateRestorableState()
for window in NSApp.windows {
window.invalidateRestorableState()
Task { @MainActor in
AppDelegate.shared?.persistSessionForUpdateRelaunch()
TerminalController.shared.stop()
NSApp.invalidateRestorableState()
for window in NSApp.windows {
window.invalidateRestorableState()
}
}
}
}

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

@ -6,6 +6,14 @@ enum UpdateTestSupport {
static func applyIfNeeded(to viewModel: UpdateViewModel) {
let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_MODE"] == "1" else { return }
if let detectedVersion = env["CMUX_UI_TEST_DETECTED_UPDATE_VERSION"],
!detectedVersion.isEmpty {
DispatchQueue.main.async {
viewModel.detectedUpdateVersion = UpdateViewModel.normalizedDetectedUpdateVersion(from: detectedVersion)
}
}
guard let state = env["CMUX_UI_TEST_UPDATE_STATE"] else { return }
DispatchQueue.main.async {

View file

@ -6,6 +6,7 @@ import Sparkle
class UpdateViewModel: ObservableObject {
@Published var state: UpdateState = .idle
@Published var overrideState: UpdateState?
@Published var detectedUpdateVersion: String?
#if DEBUG
@Published var debugOverrideText: String?
#endif
@ -14,6 +15,14 @@ class UpdateViewModel: ObservableObject {
overrideState ?? state
}
func recordDetectedUpdate(_ item: SUAppcastItem) {
detectedUpdateVersion = Self.normalizedDetectedUpdateVersion(from: item.displayVersionString)
}
func clearDetectedUpdate() {
detectedUpdateVersion = nil
}
var text: String {
#if DEBUG
if let debugOverrideText { return debugOverrideText }
@ -334,6 +343,11 @@ class UpdateViewModel: ObservableObject {
return nil
}
}
static func normalizedDetectedUpdateVersion(from version: String) -> String? {
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
enum UpdateState: Equatable {

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

@ -111,6 +111,7 @@ struct cmuxApp: App {
@AppStorage(KeyboardShortcutSettings.Action.prevSidebarTab.defaultsKey) private var prevWorkspaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data()
@AppStorage(BrowserToolbarAccessorySpacingDebugSettings.key) private var browserToolbarAccessorySpacingRaw = BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing
@AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey)
private var toggleBrowserDeveloperToolsShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultsKey)
@ -122,6 +123,10 @@ struct cmuxApp: App {
@AppStorage(KeyboardShortcutSettings.Action.closeWorkspace.defaultsKey) private var closeWorkspaceShortcutData = Data()
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
private var browserToolbarAccessorySpacing: Int {
BrowserToolbarAccessorySpacingDebugSettings.resolved(browserToolbarAccessorySpacingRaw)
}
init() {
if SocketControlSettings.shouldBlockUntaggedDebugLaunch() {
Self.terminateForMissingLaunchTag()
@ -420,6 +425,19 @@ struct cmuxApp: App {
DebugWindowControlsWindowController.shared.show()
}
Button("Browser Import Hint Debug…") {
BrowserImportHintDebugWindowController.shared.show()
}
Button(
String(
localized: "debug.menu.browserProfilePopoverDebug",
defaultValue: "Browser Profile Popover Debug…"
)
) {
BrowserProfilePopoverDebugWindowController.shared.show()
}
Button("Settings/About Titlebar Debug…") {
SettingsAboutTitlebarDebugWindowController.shared.show()
}
@ -444,6 +462,29 @@ struct cmuxApp: App {
}
}
Menu(
String(
localized: "debug.menu.browserToolbarButtonSpacing",
defaultValue: "Browser Toolbar Button Spacing"
)
) {
ForEach(BrowserToolbarAccessorySpacingDebugSettings.supportedValues, id: \.self) { spacing in
Button {
browserToolbarAccessorySpacingRaw = spacing
} label: {
if browserToolbarAccessorySpacing == spacing {
Label {
Text(verbatim: "\(spacing)")
} icon: {
Image(systemName: "checkmark")
}
} else {
Text(verbatim: "\(spacing)")
}
}
}
}
Toggle("Always Show Shortcut Hints", isOn: $alwaysShowShortcutHints)
Toggle(
String(localized: "debug.devBuildBanner.show", defaultValue: "Show Dev Build Banner"),
@ -670,6 +711,13 @@ struct cmuxApp: App {
BrowserHistoryStore.shared.clearHistory()
}
Button(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…")) {
// Defer modal presentation until after AppKit finishes menu tracking.
DispatchQueue.main.async {
BrowserDataImportCoordinator.shared.presentImportDialog()
}
}
splitCommandButton(title: String(localized: "menu.view.nextWorkspace", defaultValue: "Next Workspace"), shortcut: nextWorkspaceMenuShortcut) {
activeTabManager.selectNextTab()
}
@ -1136,6 +1184,8 @@ struct cmuxApp: App {
}
private func openAllDebugWindows() {
BrowserImportHintDebugWindowController.shared.show()
BrowserProfilePopoverDebugWindowController.shared.show()
SettingsAboutTitlebarDebugWindowController.shared.show()
SidebarDebugWindowController.shared.show()
BackgroundDebugWindowController.shared.show()
@ -1147,8 +1197,10 @@ private let cmuxAuxiliaryWindowIdentifiers: Set<String> = [
"cmux.settings",
"cmux.about",
"cmux.licenses",
"cmux.browser-popup",
"cmux.settingsAboutTitlebarDebug",
"cmux.debugWindowControls",
"cmux.browserImportHintDebug",
"cmux.sidebarDebug",
"cmux.menubarDebug",
"cmux.backgroundDebug",
@ -1764,6 +1816,17 @@ private struct DebugWindowControlsView: View {
GroupBox("Open") {
VStack(alignment: .leading, spacing: 8) {
Button("Browser Import Hint Debug…") {
BrowserImportHintDebugWindowController.shared.show()
}
Button(
String(
localized: "debug.menu.browserProfilePopoverDebug",
defaultValue: "Browser Profile Popover Debug…"
)
) {
BrowserProfilePopoverDebugWindowController.shared.show()
}
Button("Settings/About Titlebar Debug…") {
SettingsAboutTitlebarDebugWindowController.shared.show()
}
@ -1777,6 +1840,8 @@ private struct DebugWindowControlsView: View {
MenuBarExtraDebugWindowController.shared.show()
}
Button("Open All Debug Windows") {
BrowserImportHintDebugWindowController.shared.show()
BrowserProfilePopoverDebugWindowController.shared.show()
SettingsAboutTitlebarDebugWindowController.shared.show()
SidebarDebugWindowController.shared.show()
BackgroundDebugWindowController.shared.show()
@ -1980,6 +2045,411 @@ private struct DebugWindowControlsView: View {
}
}
private final class BrowserImportHintDebugWindowController: NSWindowController, NSWindowDelegate {
static let shared = BrowserImportHintDebugWindowController()
private init() {
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 380, height: 420),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
window.title = "Browser Import Hint Debug"
window.titleVisibility = .visible
window.titlebarAppearsTransparent = false
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
window.identifier = NSUserInterfaceItemIdentifier("cmux.browserImportHintDebug")
window.center()
window.contentView = NSHostingView(rootView: BrowserImportHintDebugView())
AppDelegate.shared?.applyWindowDecorations(to: window)
super.init(window: window)
window.delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
window?.center()
window?.makeKeyAndOrderFront(nil)
}
}
private final class BrowserProfilePopoverDebugWindowController: NSWindowController, NSWindowDelegate {
static let shared = BrowserProfilePopoverDebugWindowController()
private init() {
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 340),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
window.title = String(
localized: "debug.windows.browserProfilePopover.title",
defaultValue: "Browser Profile Popover Debug"
)
window.titleVisibility = .visible
window.titlebarAppearsTransparent = false
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
window.identifier = NSUserInterfaceItemIdentifier("cmux.browserProfilePopoverDebug")
window.center()
window.contentView = NSHostingView(rootView: BrowserProfilePopoverDebugView())
AppDelegate.shared?.applyWindowDecorations(to: window)
super.init(window: window)
window.delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
window?.center()
window?.makeKeyAndOrderFront(nil)
}
}
private struct BrowserProfilePopoverDebugView: View {
@AppStorage(BrowserProfilePopoverDebugSettings.horizontalPaddingKey)
private var horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultHorizontalPadding
@AppStorage(BrowserProfilePopoverDebugSettings.verticalPaddingKey)
private var verticalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultVerticalPadding
private var horizontalPaddingBinding: Binding<Double> {
Binding(
get: { BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(horizontalPaddingRaw) },
set: { horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding($0) }
)
}
private var verticalPaddingBinding: Binding<Double> {
Binding(
get: { BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(verticalPaddingRaw) },
set: { verticalPaddingRaw = BrowserProfilePopoverDebugSettings.resolvedVerticalPadding($0) }
)
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text(
String(
localized: "debug.browserProfilePopover.heading",
defaultValue: "Browser Profile Popover"
)
)
.font(.headline)
Text(
String(
localized: "debug.browserProfilePopover.note",
defaultValue: "Tune the profile popover padding live while comparing it against the browser toolbar menu."
)
)
.font(.caption)
.foregroundStyle(.secondary)
GroupBox(
String(
localized: "debug.browserProfilePopover.group.padding",
defaultValue: "Padding"
)
) {
VStack(alignment: .leading, spacing: 8) {
sliderRow(
String(
localized: "debug.browserProfilePopover.label.horizontal",
defaultValue: "Horizontal"
),
value: horizontalPaddingBinding,
range: BrowserProfilePopoverDebugSettings.horizontalPaddingRange
)
sliderRow(
String(
localized: "debug.browserProfilePopover.label.vertical",
defaultValue: "Vertical"
),
value: verticalPaddingBinding,
range: BrowserProfilePopoverDebugSettings.verticalPaddingRange
)
}
.padding(.top, 2)
}
GroupBox(
String(
localized: "debug.browserProfilePopover.group.preview",
defaultValue: "Preview"
)
) {
profilePopoverPreview
.padding(.top, 2)
}
HStack(spacing: 12) {
Button(
String(
localized: "debug.browserProfilePopover.reset",
defaultValue: "Reset"
)
) {
horizontalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultHorizontalPadding
verticalPaddingRaw = BrowserProfilePopoverDebugSettings.defaultVerticalPadding
}
}
Text(
String(
localized: "debug.browserProfilePopover.liveNote",
defaultValue: "Changes apply live to the browser profile popover."
)
)
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var profilePopoverPreview: some View {
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: "browser.profile.menu.title", defaultValue: "Profiles"))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 8) {
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .semibold))
.frame(width: 12, alignment: .center)
Text(String(localized: "browser.profile.default", defaultValue: "Default"))
.font(.system(size: 12))
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.frame(height: 24)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color.primary.opacity(0.12))
)
}
Divider()
Text(String(localized: "browser.profile.new", defaultValue: "New Profile..."))
.font(.system(size: 12))
Text(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…"))
.font(.system(size: 12))
}
.padding(.horizontal, BrowserProfilePopoverDebugSettings.resolvedHorizontalPadding(horizontalPaddingRaw))
.padding(.vertical, BrowserProfilePopoverDebugSettings.resolvedVerticalPadding(verticalPaddingRaw))
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.primary.opacity(0.08))
)
)
}
private func sliderRow(_ label: String, value: Binding<Double>, range: ClosedRange<Double>) -> some View {
HStack(spacing: 8) {
Text(label)
Slider(value: value, in: range, step: 1)
Text(String(format: "%.0f", value.wrappedValue))
.font(.caption)
.monospacedDigit()
.frame(width: 32, alignment: .trailing)
}
}
}
private struct BrowserImportHintDebugView: View {
@AppStorage(BrowserImportHintSettings.variantKey)
private var variantRaw = BrowserImportHintSettings.defaultVariant.rawValue
@AppStorage(BrowserImportHintSettings.showOnBlankTabsKey)
private var showOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs
@AppStorage(BrowserImportHintSettings.dismissedKey)
private var isDismissed = BrowserImportHintSettings.defaultDismissed
private var selectedVariant: BrowserImportHintVariant {
BrowserImportHintSettings.variant(for: variantRaw)
}
private var variantSelection: Binding<String> {
Binding(
get: { selectedVariant.rawValue },
set: { variantRaw = BrowserImportHintSettings.variant(for: $0).rawValue }
)
}
private var showOnBlankTabsBinding: Binding<Bool> {
Binding(
get: { showOnBlankTabs },
set: { newValue in
showOnBlankTabs = newValue
if newValue {
isDismissed = false
}
}
)
}
private var presentation: BrowserImportHintPresentation {
BrowserImportHintPresentation(
variant: selectedVariant,
showOnBlankTabs: showOnBlankTabs,
isDismissed: isDismissed
)
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text("Browser Import Hint")
.font(.headline)
Text("Try lighter blank-tab import surfaces and dismissal states without touching the permanent Browser settings home.")
.font(.caption)
.foregroundStyle(.secondary)
GroupBox("Variant") {
VStack(alignment: .leading, spacing: 10) {
Picker("Blank Tab Style", selection: variantSelection) {
ForEach(BrowserImportHintVariant.allCases) { variant in
Text(title(for: variant)).tag(variant.rawValue)
}
}
.pickerStyle(.menu)
Text(description(for: selectedVariant))
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.top, 2)
}
GroupBox("State") {
VStack(alignment: .leading, spacing: 10) {
Toggle("Show on blank browser tabs", isOn: showOnBlankTabsBinding)
Toggle("Pretend the user dismissed it", isOn: $isDismissed)
Text("Current blank-tab placement: \(placementTitle(presentation.blankTabPlacement))")
.font(.caption)
.foregroundStyle(.secondary)
Text("Settings status: \(settingsStatusTitle(presentation.settingsStatus))")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.top, 2)
}
GroupBox("Quick Actions") {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Button("Open Browser Settings") {
AppDelegate.presentPreferencesWindow(navigationTarget: .browser)
}
Button("Open Import Dialog") {
DispatchQueue.main.async {
BrowserDataImportCoordinator.shared.presentImportDialog()
}
}
}
Button("Reset Hint Debug State") {
BrowserImportHintSettings.reset()
}
}
.padding(.top, 2)
}
GroupBox("Ideas") {
VStack(alignment: .leading, spacing: 6) {
Text("Inline strip: default candidate, visible but quieter than the old floating card.")
Text("Floating card: strongest nudge, useful when we want more explanation.")
Text("Toolbar chip: most subtle, best when the hint should stay out of the content area.")
Text("Settings only: no in-browser nudge, Browser settings becomes the only permanent home.")
}
.font(.caption)
.foregroundStyle(.secondary)
.padding(.top, 2)
}
Spacer(minLength: 0)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func title(for variant: BrowserImportHintVariant) -> String {
switch variant {
case .inlineStrip:
return "Inline Strip"
case .floatingCard:
return "Floating Card"
case .toolbarChip:
return "Toolbar Chip"
case .settingsOnly:
return "Settings Only"
}
}
private func description(for variant: BrowserImportHintVariant) -> String {
switch variant {
case .inlineStrip:
return "Shows a thin hint bar at the top of blank browser tabs."
case .floatingCard:
return "Shows the fuller callout card inside blank browser tabs."
case .toolbarChip:
return "Moves the hint into a small toolbar chip beside the browser controls."
case .settingsOnly:
return "Hides the blank-tab hint and leaves Browser settings as the only home."
}
}
private func placementTitle(_ placement: BrowserImportHintBlankTabPlacement) -> String {
switch placement {
case .hidden:
return "Hidden"
case .inlineStrip:
return "Inline Strip"
case .floatingCard:
return "Floating Card"
case .toolbarChip:
return "Toolbar Chip"
}
}
private func settingsStatusTitle(_ status: BrowserImportHintSettingsStatus) -> String {
switch status {
case .visible:
return "Visible"
case .hidden:
return "Hidden"
case .settingsOnly:
return "Settings Only"
}
}
}
private final class AboutWindowController: NSWindowController, NSWindowDelegate {
static let shared = AboutWindowController()
@ -2199,6 +2669,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
}
enum SettingsNavigationTarget: String {
case browser
case browserImport
case keyboardShortcuts
}
@ -3269,6 +3741,9 @@ struct SettingsView: View {
@AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
@AppStorage(BrowserThemeSettings.modeKey) private var browserThemeMode = BrowserThemeSettings.defaultMode.rawValue
@AppStorage(BrowserImportHintSettings.variantKey) private var browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue
@AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs
@AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed
@AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
@AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey)
private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue()
@ -3306,6 +3781,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
@ -3314,6 +3790,7 @@ struct SettingsView: View {
@AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String?
@AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String?
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity
@ObservedObject private var notificationStore = TerminalNotificationStore.shared
@State private var shortcutResetToken = UUID()
@State private var topBlurOpacity: Double = 0
@ -3323,6 +3800,7 @@ struct SettingsView: View {
@State private var showOpenAccessConfirmation = false
@State private var pendingOpenAccessMode: SocketControlMode?
@State private var browserHistoryEntryCount: Int = 0
@State private var detectedImportBrowsers: [InstalledBrowserCandidate] = []
@State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
@State private var socketPasswordDraft = ""
@State private var socketPasswordStatusMessage: String?
@ -3386,6 +3864,30 @@ struct SettingsView: View {
)
}
private var browserImportHintVariant: BrowserImportHintVariant {
BrowserImportHintSettings.variant(for: browserImportHintVariantRaw)
}
private var browserImportHintPresentation: BrowserImportHintPresentation {
BrowserImportHintPresentation(
variant: browserImportHintVariant,
showOnBlankTabs: showBrowserImportHintOnBlankTabs,
isDismissed: isBrowserImportHintDismissed
)
}
private var browserImportHintVisibilityBinding: Binding<Bool> {
Binding(
get: { showBrowserImportHintOnBlankTabs },
set: { newValue in
showBrowserImportHintOnBlankTabs = newValue
if newValue {
isBrowserImportHintDismissed = false
}
}
)
}
private var socketModeSelection: Binding<String> {
Binding(
get: { socketControlMode },
@ -3456,6 +3958,21 @@ struct SettingsView: View {
}
}
private var browserImportSubtitle: String {
InstalledBrowserDetector.summaryText(for: detectedImportBrowsers)
}
private var browserImportHintSettingsNote: String {
switch browserImportHintPresentation.settingsStatus {
case .visible:
return String(localized: "settings.browser.import.hint.note.visible", defaultValue: "Blank browser tabs can show this import suggestion. Hide or re-enable it here.")
case .hidden:
return String(localized: "settings.browser.import.hint.note.hidden", defaultValue: "The blank-tab import hint is hidden. Turn it back on here any time.")
case .settingsOnly:
return String(localized: "settings.browser.import.hint.note.settingsOnly", defaultValue: "Blank tabs are currently using Settings only mode from the debug window.")
}
}
private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool {
browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist
}
@ -4078,6 +4595,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.")
@ -4392,6 +4920,8 @@ struct SettingsView: View {
}
SettingsSectionHeader(title: String(localized: "settings.section.browser", defaultValue: "Browser"))
.id(SettingsNavigationTarget.browser)
.accessibilityIdentifier("SettingsBrowserSection")
SettingsCard {
SettingsPickerRow(
String(localized: "settings.browser.searchEngine", defaultValue: "Default Search Engine"),
@ -4566,6 +5096,76 @@ struct SettingsView: View {
SettingsCardDivider()
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: "settings.browser.import", defaultValue: "Import From Browser"))
.font(.system(size: 13, weight: .semibold))
VStack(alignment: .leading, spacing: 6) {
Text(String(localized: "browser.import.hint.title", defaultValue: "Import browser data"))
.font(.system(size: 12.5, weight: .semibold))
Text(browserImportSubtitle)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(String(localized: "browser.import.hint.settingsFootnote", defaultValue: "You can always find this in Settings > Browser."))
.font(.system(size: 10.5))
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(nsColor: .controlBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color(nsColor: .separatorColor).opacity(0.4), lineWidth: 1)
)
}
HStack(spacing: 8) {
Button(String(localized: "settings.browser.import.choose", defaultValue: "Choose…")) {
DispatchQueue.main.async {
BrowserDataImportCoordinator.shared.presentImportDialog()
refreshDetectedImportBrowsers()
}
}
.buttonStyle(.bordered)
.controlSize(.small)
.accessibilityIdentifier("SettingsBrowserImportChooseButton")
Button(String(localized: "settings.browser.import.refresh", defaultValue: "Refresh")) {
refreshDetectedImportBrowsers()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
.accessibilityIdentifier("SettingsBrowserImportActions")
Toggle(
String(localized: "settings.browser.import.hint.show", defaultValue: "Show import hint on blank browser tabs"),
isOn: browserImportHintVisibilityBinding
)
.controlSize(.small)
.accessibilityIdentifier("SettingsBrowserImportHintToggle")
Text(browserImportHintSettingsNote)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.id(SettingsNavigationTarget.browserImport)
.accessibilityIdentifier("SettingsBrowserImportSection")
.padding(.horizontal, 14)
.padding(.vertical, 10)
SettingsCardDivider()
SettingsCardRow(String(localized: "settings.browser.history", defaultValue: "Browsing History"), subtitle: browserHistorySubtitle) {
Button(String(localized: "settings.browser.history.clearButton", defaultValue: "Clear History…")) {
showClearBrowserHistoryConfirmation = true
@ -4706,8 +5306,10 @@ struct SettingsView: View {
BrowserHistoryStore.shared.loadIfNeeded()
notificationStore.refreshAuthorizationStatus()
browserThemeMode = BrowserThemeSettings.mode(defaults: .standard).rawValue
browserImportHintVariantRaw = BrowserImportHintSettings.variant(for: browserImportHintVariantRaw).rawValue
browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count
browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist
refreshDetectedImportBrowsers()
reloadWorkspaceTabColorSettings()
refreshNotificationCustomSoundStatus()
}
@ -4818,6 +5420,9 @@ struct SettingsView: View {
browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
browserThemeMode = BrowserThemeSettings.defaultMode.rawValue
browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue
showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs
isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed
openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser
browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist
@ -4856,6 +5461,7 @@ struct SettingsView: View {
sidebarShowPullRequest = true
openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold
sidebarShowSSH = true
sidebarShowPorts = true
sidebarShowLog = true
sidebarShowProgress = true
@ -4869,6 +5475,7 @@ struct SettingsView: View {
socketPasswordDraft = ""
socketPasswordStatusMessage = nil
socketPasswordStatusIsError = false
refreshDetectedImportBrowsers()
KeyboardShortcutSettings.resetAll()
WorkspaceTabColorSettings.reset()
reloadWorkspaceTabColorSettings()
@ -4914,6 +5521,10 @@ struct SettingsView: View {
private func saveBrowserInsecureHTTPAllowlist() {
browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft
}
private func refreshDetectedImportBrowsers() {
detectedImportBrowsers = InstalledBrowserDetector.detectInstalledBrowsers()
}
}
private struct SettingsTopOffsetPreferenceKey: PreferenceKey {

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")
@ -2768,6 +2911,24 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertEqual(activateApplicationCallCount, 1)
}
func testPresentPreferencesWindowForwardsBrowserImportNavigationTarget() {
var receivedNavigationTarget: SettingsNavigationTarget?
var activateApplicationCallCount = 0
AppDelegate.presentPreferencesWindow(
navigationTarget: .browserImport,
showFallbackSettingsWindow: { navigationTarget in
receivedNavigationTarget = navigationTarget
},
activateApplication: {
activateApplicationCallCount += 1
}
)
XCTAssertEqual(receivedNavigationTarget, .browserImport)
XCTAssertEqual(activateApplicationCallCount, 1)
}
private func makeKeyDownEvent(
key: String,
modifiers: NSEvent.ModifierFlags,
@ -2890,6 +3051,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

@ -0,0 +1,355 @@
import XCTest
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
final class BrowserImportMappingTests: XCTestCase {
@MainActor
func testDefaultExecutionPlanUsesSeparateModeForMultipleSourceProfiles() {
let defaultProfile = BrowserProfileDefinition(
id: UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!,
displayName: "Default",
createdAt: .distantPast,
isBuiltInDefault: true
)
let sourceProfiles = [
makeSourceProfile(displayName: "You", path: "/tmp/browser-import-you", isDefault: true),
makeSourceProfile(displayName: "austin", path: "/tmp/browser-import-austin", isDefault: false),
]
let plan = BrowserImportPlanResolver.defaultPlan(
selectedSourceProfiles: sourceProfiles,
destinationProfiles: [defaultProfile],
preferredSingleDestinationProfileID: defaultProfile.id
)
XCTAssertEqual(plan.mode, .separateProfiles)
XCTAssertEqual(plan.entries.count, 2)
XCTAssertEqual(plan.entries.map { $0.sourceProfiles.map(\.displayName) }, [["You"], ["austin"]])
}
@MainActor
func testDefaultExecutionPlanUsesSingleDestinationForSingleSourceProfile() {
let defaultProfileID = UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!
let sourceProfile = makeSourceProfile(
displayName: "You",
path: "/tmp/browser-import-single",
isDefault: true
)
let plan = BrowserImportPlanResolver.defaultPlan(
selectedSourceProfiles: [sourceProfile],
destinationProfiles: [],
preferredSingleDestinationProfileID: defaultProfileID
)
XCTAssertEqual(plan.mode, .singleDestination)
XCTAssertEqual(plan.entries.count, 1)
XCTAssertEqual(plan.entries[0].sourceProfiles.map(\.displayName), ["You"])
}
@MainActor
func testSeparatePlanReusesExistingSameNamedDestinationProfiles() {
let workID = UUID()
let destinationProfiles = [
BrowserProfileDefinition(
id: workID,
displayName: "You",
createdAt: .distantPast,
isBuiltInDefault: false
)
]
let sourceProfiles = [
makeSourceProfile(displayName: " you ", path: "/tmp/browser-import-match", isDefault: true)
]
let plan = BrowserImportPlanResolver.separateProfilesPlan(
selectedSourceProfiles: sourceProfiles,
destinationProfiles: destinationProfiles
)
XCTAssertEqual(plan.entries.count, 1)
XCTAssertEqual(plan.entries[0].destination, .existing(workID))
}
@MainActor
func testSeparatePlanUsesStableCreateNamesWhenTwoSourceProfilesShareDisplayName() {
let sourceProfiles = [
makeSourceProfile(displayName: "Work", path: "/tmp/browser-import-work-1", isDefault: true),
makeSourceProfile(displayName: "Work", path: "/tmp/browser-import-work-2", isDefault: false),
]
let plan = BrowserImportPlanResolver.separateProfilesPlan(
selectedSourceProfiles: sourceProfiles,
destinationProfiles: []
)
XCTAssertEqual(plan.entries.count, 2)
XCTAssertEqual(plan.entries[0].destination, .createNamed("Work"))
XCTAssertEqual(plan.entries[1].destination, .createNamed("Work (2)"))
}
func testStep3PresentationShowsPerProfileRowsWhenPlanUsesSeparateMode() {
let presentation = BrowserImportStep3Presentation(
plan: BrowserImportExecutionPlan(
mode: .separateProfiles,
entries: [
BrowserImportExecutionEntry(
sourceProfiles: [
makeSourceProfile(
displayName: "You",
path: "/tmp/browser-import-presentation-separate",
isDefault: true
)
],
destination: .createNamed("You")
)
]
)
)
XCTAssertTrue(presentation.showsSeparateRows)
XCTAssertFalse(presentation.showsSingleDestinationPicker)
}
func testStep3PresentationShowsSingleDestinationPickerWhenPlanUsesMergeMode() {
let presentation = BrowserImportStep3Presentation(
plan: BrowserImportExecutionPlan(
mode: .mergeIntoOne,
entries: []
)
)
XCTAssertFalse(presentation.showsSeparateRows)
XCTAssertTrue(presentation.showsSingleDestinationPicker)
}
func testSourceProfilesPresentationShrinksListForSmallProfileCounts() {
let presentation = BrowserImportSourceProfilesPresentation(profileCount: 2)
XCTAssertEqual(presentation.scrollHeight, 76)
XCTAssertTrue(presentation.showsHelpText)
}
func testSourceProfilesPresentationCapsListHeightAndHidesHelpForSingleProfile() {
let singleProfilePresentation = BrowserImportSourceProfilesPresentation(profileCount: 1)
let manyProfilesPresentation = BrowserImportSourceProfilesPresentation(profileCount: 9)
XCTAssertEqual(singleProfilePresentation.scrollHeight, 76)
XCTAssertFalse(singleProfilePresentation.showsHelpText)
XCTAssertEqual(manyProfilesPresentation.scrollHeight, 144)
XCTAssertTrue(manyProfilesPresentation.showsHelpText)
}
func testBrowserImportHintSettingsDefaultToToolbarChip() throws {
let suiteName = "BrowserImportHintDefaults-\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let presentation = BrowserImportHintSettings.presentation(defaults: defaults)
XCTAssertEqual(presentation.blankTabPlacement, .toolbarChip)
XCTAssertEqual(presentation.settingsStatus, .visible)
}
func testBrowserImportHintPresentationHidesBlankTabHintWhenDismissed() {
let presentation = BrowserImportHintPresentation(
variant: .floatingCard,
showOnBlankTabs: true,
isDismissed: true
)
XCTAssertEqual(presentation.blankTabPlacement, .hidden)
XCTAssertEqual(presentation.settingsStatus, .hidden)
}
func testBrowserImportHintPresentationUsesToolbarChipWhenEnabled() {
let presentation = BrowserImportHintPresentation(
variant: .toolbarChip,
showOnBlankTabs: true,
isDismissed: false
)
XCTAssertEqual(presentation.blankTabPlacement, .toolbarChip)
XCTAssertEqual(presentation.settingsStatus, .visible)
}
func testBrowserImportHintPresentationSettingsOnlyVariantStaysInSettings() {
let presentation = BrowserImportHintPresentation(
variant: .settingsOnly,
showOnBlankTabs: true,
isDismissed: false
)
XCTAssertEqual(presentation.blankTabPlacement, .hidden)
XCTAssertEqual(presentation.settingsStatus, .settingsOnly)
}
@MainActor
func testRealizePlanCreatesMissingDestinationProfilesOnlyWhenRequested() throws {
let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = BrowserProfileStore(defaults: defaults)
let plan = BrowserImportExecutionPlan(
mode: .separateProfiles,
entries: [
BrowserImportExecutionEntry(
sourceProfiles: [
makeSourceProfile(
displayName: "You",
path: "/tmp/browser-import-realize-create",
isDefault: true
)
],
destination: .createNamed("You")
)
]
)
let realized = try BrowserImportPlanResolver.realize(plan: plan, profileStore: store)
XCTAssertEqual(realized.createdProfiles.map(\.displayName), ["You"])
XCTAssertEqual(store.profiles.map(\.displayName), ["Default", "You"])
}
@MainActor
func testRealizePlanReusesExistingProfileInsteadOfCreatingDuplicate() throws {
let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = BrowserProfileStore(defaults: defaults)
let existing = try XCTUnwrap(store.createProfile(named: "You"))
let plan = BrowserImportExecutionPlan(
mode: .separateProfiles,
entries: [
BrowserImportExecutionEntry(
sourceProfiles: [
makeSourceProfile(
displayName: "You",
path: "/tmp/browser-import-realize-existing",
isDefault: true
)
],
destination: .existing(existing.id)
)
]
)
let realized = try BrowserImportPlanResolver.realize(plan: plan, profileStore: store)
XCTAssertTrue(realized.createdProfiles.isEmpty)
XCTAssertEqual(realized.entries[0].destinationProfileID, existing.id)
}
func testAggregateOutcomeIncludesOneMappingLinePerDestination() {
let outcome = BrowserImportOutcome(
browserName: "Helium",
scope: .cookiesAndHistory,
domainFilters: [],
createdDestinationProfileNames: ["You", "austin"],
entries: [
BrowserImportOutcomeEntry(
sourceProfileNames: ["You"],
destinationProfileName: "You",
importedCookies: 10,
skippedCookies: 0,
importedHistoryEntries: 20,
warnings: []
),
BrowserImportOutcomeEntry(
sourceProfileNames: ["austin"],
destinationProfileName: "austin",
importedCookies: 5,
skippedCookies: 1,
importedHistoryEntries: 9,
warnings: []
),
],
warnings: []
)
let lines = BrowserImportOutcomeFormatter.lines(for: outcome)
XCTAssertTrue(lines.contains("You -> You"))
XCTAssertTrue(lines.contains("austin -> austin"))
XCTAssertTrue(lines.contains("Created cmux profiles: You, austin"))
}
@MainActor
func testImportWizardCanBeConstructedForSettingsChoosePath() {
let destinationProfiles = [
BrowserProfileDefinition(
id: UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!,
displayName: "Default",
createdAt: .distantPast,
isBuiltInDefault: true
)
]
let browser = makeInstalledBrowserCandidate(
descriptorID: "google-chrome",
displayName: "Chrome",
profiles: [
makeSourceProfile(displayName: "Default", path: "/tmp/browser-import-chrome-default", isDefault: true),
makeSourceProfile(displayName: "Profile 1", path: "/tmp/browser-import-chrome-profile-1", isDefault: false),
]
)
let window = BrowserDataImportCoordinator.shared.debugMakeImportWizardWindow(
browsers: [browser],
destinationProfiles: destinationProfiles,
defaultDestinationProfileID: destinationProfiles[0].id
)
defer {
window.orderOut(nil)
window.close()
}
XCTAssertEqual(window.title, "Import Browser Data")
XCTAssertNotNil(window.contentView)
}
private func makeSourceProfile(displayName: String, path: String, isDefault: Bool) -> InstalledBrowserProfile {
InstalledBrowserProfile(
displayName: displayName,
rootURL: URL(fileURLWithPath: path, isDirectory: true),
isDefault: isDefault
)
}
private func makeInstalledBrowserCandidate(
descriptorID: String,
displayName: String,
profiles: [InstalledBrowserProfile]
) -> InstalledBrowserCandidate {
let descriptor = try! XCTUnwrap(InstalledBrowserDetector.allBrowserDescriptors.first(where: { $0.id == descriptorID }))
return InstalledBrowserCandidate(
descriptor: BrowserImportBrowserDescriptor(
id: descriptor.id,
displayName: displayName,
family: descriptor.family,
tier: descriptor.tier,
bundleIdentifiers: descriptor.bundleIdentifiers,
appNames: descriptor.appNames,
dataRootRelativePaths: descriptor.dataRootRelativePaths,
dataArtifactRelativePaths: descriptor.dataArtifactRelativePaths,
supportsDataOnlyDetection: descriptor.supportsDataOnlyDetection
),
resolvedFamily: descriptor.family,
homeDirectoryURL: URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true),
appURL: nil,
dataRootURL: URL(fileURLWithPath: "/tmp/browser-import-\(descriptorID)", isDirectory: true),
profiles: profiles,
detectionSignals: ["test"],
detectionScore: 1
)
}
}

File diff suppressed because it is too large Load diff

View file

@ -434,6 +434,59 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
)
}
func testPendingEmptyStateIsPreservedWhenRefiningAResolvedNoMatchQuery() {
XCTAssertTrue(
ContentView.commandPaletteShouldPreserveEmptyStateWhileSearchPending(
isSearchPending: true,
visibleResultsScopeMatches: true,
resolvedSearchScopeMatches: true,
resolvedSearchFingerprintMatches: true,
resolvedResultsAreEmpty: true,
currentMatchingQuery: "zzzzzzzzz",
resolvedMatchingQuery: "zzzzzzzz"
)
)
}
func testPendingEmptyStateIsNotPreservedWhenQueryDoesNotRefineResolvedNoMatch() {
XCTAssertFalse(
ContentView.commandPaletteShouldPreserveEmptyStateWhileSearchPending(
isSearchPending: true,
visibleResultsScopeMatches: true,
resolvedSearchScopeMatches: true,
resolvedSearchFingerprintMatches: true,
resolvedResultsAreEmpty: true,
currentMatchingQuery: "zzzza",
resolvedMatchingQuery: "zzzzb"
)
)
}
func testPendingEmptyStateIsNotPreservedWhenResolvedResultsMayBeStale() {
XCTAssertFalse(
ContentView.commandPaletteShouldPreserveEmptyStateWhileSearchPending(
isSearchPending: true,
visibleResultsScopeMatches: true,
resolvedSearchScopeMatches: true,
resolvedSearchFingerprintMatches: false,
resolvedResultsAreEmpty: true,
currentMatchingQuery: "zzzzzzzzz",
resolvedMatchingQuery: "zzzzzzzz"
)
)
XCTAssertFalse(
ContentView.commandPaletteShouldPreserveEmptyStateWhileSearchPending(
isSearchPending: true,
visibleResultsScopeMatches: true,
resolvedSearchScopeMatches: true,
resolvedSearchFingerprintMatches: true,
resolvedResultsAreEmpty: false,
currentMatchingQuery: "zzzzzzzzz",
resolvedMatchingQuery: "zzzzzzzz"
)
)
}
func testVisibleResultsResetWhenQueryChangesCommandPaletteScope() {
XCTAssertTrue(
ContentView.commandPaletteShouldResetVisibleResultsForQueryTransition(

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()
@ -1043,6 +1543,7 @@ final class RecentlyClosedBrowserStackTests: XCTestCase {
ClosedBrowserPanelRestoreSnapshot(
workspaceId: UUID(),
url: URL(string: "https://example.com/\(index)"),
profileID: nil,
originalPaneId: UUID(),
originalTabIndex: index,
fallbackSplitOrientation: .horizontal,
@ -1781,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)")
@ -1802,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,
@ -1820,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()
@ -1844,3 +2381,241 @@ final class ZshShellIntegrationHandoffTests: XCTestCase {
return output.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
final class BrowserInstallDetectorTests: XCTestCase {
func testDetectInstalledBrowsersUsesBundleIdAndProfileData() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
try createFile(
at: home
.appendingPathComponent("Library/Application Support/Google/Chrome/Default/History"),
contents: Data()
)
try createFile(
at: home
.appendingPathComponent("Library/Application Support/Firefox/Profiles/dev.default-release/cookies.sqlite"),
contents: Data()
)
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { bundleIdentifier in
if bundleIdentifier == "com.google.Chrome" {
return URL(fileURLWithPath: "/Applications/Google Chrome.app", isDirectory: true)
}
return nil
},
applicationSearchDirectories: []
)
guard let chrome = detected.first(where: { $0.descriptor.id == "google-chrome" }) else {
XCTFail("Expected Chrome to be detected")
return
}
guard let firefox = detected.first(where: { $0.descriptor.id == "firefox" }) else {
XCTFail("Expected Firefox to be detected from profile data")
return
}
XCTAssertNotNil(chrome.appURL)
XCTAssertEqual(firefox.profileURLs.count, 1)
XCTAssertNil(firefox.appURL)
}
func testDetectInstalledBrowsersReturnsEmptyWhenNoSignalsExist() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { _ in nil },
applicationSearchDirectories: []
)
XCTAssertTrue(detected.isEmpty)
}
func testUngoogledChromiumRequiresAppSignal() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
try createFile(
at: home
.appendingPathComponent("Library/Application Support/Chromium/Default/History"),
contents: Data()
)
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { _ in nil },
applicationSearchDirectories: []
)
XCTAssertTrue(detected.contains(where: { $0.descriptor.id == "chromium" }))
XCTAssertFalse(detected.contains(where: { $0.descriptor.id == "ungoogled-chromium" }))
}
func testDetectInstalledBrowsersDiscoversHeliumProfilesFromChromiumLayout() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
let heliumRoot = home.appendingPathComponent("Library/Application Support/net.imput.helium", isDirectory: true)
try createFile(
at: heliumRoot.appendingPathComponent("Default/History"),
contents: Data()
)
try createFile(
at: heliumRoot.appendingPathComponent("Profile 1/Cookies"),
contents: Data()
)
try createFile(
at: heliumRoot.appendingPathComponent("Local State"),
contents: Data(
"""
{
"profile": {
"info_cache": {
"Default": {
"name": "Personal"
},
"Profile 1": {
"name": "Work"
}
}
}
}
""".utf8
)
)
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { _ in nil },
applicationSearchDirectories: []
)
guard let helium = detected.first(where: { $0.descriptor.id == "helium" }) else {
XCTFail("Expected Helium to be detected")
return
}
XCTAssertEqual(helium.family, .chromium)
XCTAssertEqual(helium.profiles.map(\.displayName), ["Personal", "Work"])
XCTAssertEqual(
helium.profiles.map(\.rootURL.lastPathComponent),
["Default", "Profile 1"]
)
}
func testDetectInstalledBrowsersDiscoversSafariProfiles() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
try createFile(
at: home.appendingPathComponent("Library/Safari/History.db"),
contents: Data()
)
try createFile(
at: home.appendingPathComponent(
"Library/Safari/Profiles/Work/History.db"
),
contents: Data()
)
try createFile(
at: home.appendingPathComponent(
"Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel/History.db"
),
contents: Data()
)
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { _ in nil },
applicationSearchDirectories: []
)
guard let safari = detected.first(where: { $0.descriptor.id == "safari" }) else {
XCTFail("Expected Safari to be detected")
return
}
XCTAssertEqual(Set(safari.profiles.map(\.displayName)), Set(["Default", "Work", "Travel"]))
XCTAssertEqual(
safari.profiles
.map { $0.rootURL.standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false) }
.sorted(),
[
home.appendingPathComponent("Library/Safari", isDirectory: true)
.standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false),
home.appendingPathComponent("Library/Safari/Profiles/Work", isDirectory: true)
.standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false),
home.appendingPathComponent(
"Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel",
isDirectory: true
).standardizedFileURL.resolvingSymlinksInPath().path(percentEncoded: false),
].sorted()
)
}
private func makeTemporaryHome() -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent("cmux-browser-detect-\(UUID().uuidString)")
}
private func createFile(at url: URL, contents: Data) throws {
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
guard FileManager.default.createFile(atPath: url.path, contents: contents) else {
throw CocoaError(
.fileWriteUnknown,
userInfo: [NSFilePathErrorKey: url.path]
)
}
}
}
final class BrowserImportScopeTests: XCTestCase {
func testFromSelectionCookiesOnly() {
let scope = BrowserImportScope.fromSelection(
includeCookies: true,
includeHistory: false,
includeAdditionalData: false
)
XCTAssertEqual(scope, .cookiesOnly)
}
func testFromSelectionHistoryOnly() {
let scope = BrowserImportScope.fromSelection(
includeCookies: false,
includeHistory: true,
includeAdditionalData: false
)
XCTAssertEqual(scope, .historyOnly)
}
func testFromSelectionCookiesAndHistory() {
let scope = BrowserImportScope.fromSelection(
includeCookies: true,
includeHistory: true,
includeAdditionalData: false
)
XCTAssertEqual(scope, .cookiesAndHistory)
}
func testFromSelectionEverything() {
let scope = BrowserImportScope.fromSelection(
includeCookies: false,
includeHistory: false,
includeAdditionalData: true
)
XCTAssertEqual(scope, .everything)
}
func testFromSelectionRejectsEmptySelection() {
let scope = BrowserImportScope.fromSelection(
includeCookies: false,
includeHistory: false,
includeAdditionalData: false
)
XCTAssertNil(scope)
}
}

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)
@ -150,8 +184,10 @@ final class SessionPersistenceTests: XCTestCase {
}
func testSessionBrowserPanelSnapshotHistoryRoundTrip() throws {
let profileID = try XCTUnwrap(UUID(uuidString: "8F03A658-5A84-428B-AD03-5A6D04692F64"))
let source = SessionBrowserPanelSnapshot(
urlString: "https://example.com/current",
profileID: profileID,
shouldRenderWebView: true,
pageZoom: 1.2,
developerToolsVisible: true,
@ -167,6 +203,7 @@ final class SessionPersistenceTests: XCTestCase {
let data = try JSONEncoder().encode(source)
let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: data)
XCTAssertEqual(decoded.urlString, source.urlString)
XCTAssertEqual(decoded.profileID, source.profileID)
XCTAssertEqual(decoded.backHistoryURLStrings, source.backHistoryURLStrings)
XCTAssertEqual(decoded.forwardHistoryURLStrings, source.forwardHistoryURLStrings)
}
@ -183,6 +220,7 @@ final class SessionPersistenceTests: XCTestCase {
let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: json)
XCTAssertEqual(decoded.urlString, "https://example.com/current")
XCTAssertNil(decoded.profileID)
XCTAssertNil(decoded.backHistoryURLStrings)
XCTAssertNil(decoded.forwardHistoryURLStrings)
}
@ -836,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))
@ -881,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

@ -0,0 +1,236 @@
import XCTest
import Foundation
private func browserImportPollUntil(
timeout: TimeInterval,
pollInterval: TimeInterval = 0.05,
condition: () -> Bool
) -> Bool {
let start = ProcessInfo.processInfo.systemUptime
while true {
if condition() {
return true
}
if (ProcessInfo.processInfo.systemUptime - start) >= timeout {
return false
}
RunLoop.current.run(until: Date().addingTimeInterval(pollInterval))
}
}
final class BrowserImportProfilesUITests: XCTestCase {
private var capturePath = ""
override func setUp() {
super.setUp()
continueAfterFailure = false
capturePath = "/tmp/cmux-ui-test-browser-import-\(UUID().uuidString).json"
try? FileManager.default.removeItem(atPath: capturePath)
}
func testMultipleSourceProfilesDefaultToSeparateDestinations() throws {
let app = launchApp()
app.buttons["Next"].click()
app.buttons["Next"].click()
XCTAssertTrue(
app.radioButtons["Separate profiles"].waitForExistence(timeout: 5.0),
"Expected Step 3 to show the separate-profiles default"
)
XCTAssertTrue(app.radioButtons["Merge into one"].exists)
XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-you"].exists)
XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-austin"].exists)
app.buttons["Start Import"].click()
let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0))
XCTAssertEqual(capture["mode"] as? String, "separateProfiles")
XCTAssertEqual(capture["scope"] as? String, "cookiesAndHistory")
let entries = try XCTUnwrap(capture["entries"] as? [[String: Any]])
XCTAssertEqual(entries.count, 2)
XCTAssertEqual(entries[0]["sourceProfiles"] as? [String], ["You"])
XCTAssertEqual(entries[0]["destinationKind"] as? String, "create")
XCTAssertEqual(entries[0]["destinationName"] as? String, "You")
XCTAssertEqual(entries[1]["sourceProfiles"] as? [String], ["austin"])
XCTAssertEqual(entries[1]["destinationKind"] as? String, "create")
XCTAssertEqual(entries[1]["destinationName"] as? String, "austin")
}
func testMergeModeCapturesSingleMergedDestination() throws {
let app = launchApp()
app.buttons["Next"].click()
app.buttons["Next"].click()
let mergeRadio = app.radioButtons["Merge into one"]
XCTAssertTrue(mergeRadio.waitForExistence(timeout: 5.0))
mergeRadio.click()
XCTAssertTrue(
app.popUpButtons["BrowserImportDestinationPopup-merge"].waitForExistence(timeout: 5.0),
"Expected merge mode to show the single destination popup"
)
app.buttons["Start Import"].click()
let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0))
XCTAssertEqual(capture["mode"] as? String, "mergeIntoOne")
let entries = try XCTUnwrap(capture["entries"] as? [[String: Any]])
XCTAssertEqual(entries.count, 1)
XCTAssertEqual(entries[0]["sourceProfiles"] as? [String], ["You", "austin"])
XCTAssertEqual(entries[0]["destinationKind"] as? String, "existing")
XCTAssertEqual(entries[0]["destinationName"] as? String, "Default")
}
func testAdditionalDataSelectionCapturesEverythingScope() throws {
let app = launchApp()
app.buttons["Next"].click()
app.buttons["Next"].click()
let cookiesCheckbox = app.checkBoxes["BrowserImportCookiesCheckbox"]
XCTAssertTrue(cookiesCheckbox.waitForExistence(timeout: 5.0))
cookiesCheckbox.click()
let historyCheckbox = app.checkBoxes["BrowserImportHistoryCheckbox"]
XCTAssertTrue(historyCheckbox.waitForExistence(timeout: 5.0))
historyCheckbox.click()
let additionalDataCheckbox = app.checkBoxes["BrowserImportAdditionalDataCheckbox"]
XCTAssertTrue(
additionalDataCheckbox.waitForExistence(timeout: 5.0),
"Expected Step 3 to expose the additional data checkbox"
)
additionalDataCheckbox.click()
app.buttons["Start Import"].click()
let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0))
XCTAssertEqual(capture["scope"] as? String, "everything")
}
func testBlankBrowserImportHintCanOpenBrowserSettings() {
let app = launchAppForBlankImportHint()
let settingsButton = app.buttons["BrowserImportHintSettingsButton"]
XCTAssertTrue(settingsButton.waitForExistence(timeout: 5.0))
settingsButton.click()
let importSection = app.otherElements["SettingsBrowserImportSection"]
XCTAssertTrue(
importSection.waitForExistence(timeout: 5.0),
"Expected Browser Settings to scroll to the import section"
)
let chooseButton = app.buttons["SettingsBrowserImportChooseButton"]
XCTAssertTrue(
chooseButton.waitForExistence(timeout: 5.0),
"Expected Browser Settings to expose the import actions"
)
XCTAssertTrue(
browserImportPollUntil(timeout: 5.0) {
importSection.isHittable && chooseButton.isHittable
},
"Expected Browser Settings to scroll directly to the import controls"
)
}
func testBlankBrowserImportHintCanBeDismissed() {
let app = launchAppForBlankImportHint()
let dismissButton = app.buttons["BrowserImportHintDismissButton"]
XCTAssertTrue(dismissButton.waitForExistence(timeout: 5.0))
dismissButton.click()
XCTAssertTrue(
browserImportPollUntil(timeout: 2.0) { !dismissButton.exists },
"Expected the blank-tab import hint to disappear after dismissal"
)
}
private func launchApp() -> XCUIApplication {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_FIXTURE"] = #"{"browserName":"Helium","profiles":["You","austin"]}"#
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_DESTINATIONS"] = #"["Default"]"#
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_MODE"] = "capture-only"
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_CAPTURE_PATH"] = capturePath
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_VARIANT"] = "inlineStrip"
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] = "1"
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_DISMISSED"] = "0"
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] = "1"
launchAndActivate(app)
openImportWizardFromBlankImportHint(app)
return app
}
private func launchAppForBlankImportHint() -> XCUIApplication {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_VARIANT"] = "inlineStrip"
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_SHOW"] = "1"
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_DISMISSED"] = "0"
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_HINT_OPEN_BLANK_BROWSER"] = "1"
launchAndActivate(app)
waitForBlankImportHint(app)
return app
}
private func waitForImportWizard(_ app: XCUIApplication) {
let wizardOpened = browserImportPollUntil(timeout: 5.0) {
app.buttons["Next"].exists || app.windows["Import Browser Data"].exists
}
XCTAssertTrue(wizardOpened, "Expected the import wizard to open")
}
private func waitForBlankImportHint(_ app: XCUIApplication) {
let hintOpened = browserImportPollUntil(timeout: 5.0) {
app.buttons["BrowserImportHintImportButton"].exists
}
XCTAssertTrue(hintOpened, "Expected the blank browser import hint to appear")
}
private func openImportWizardFromBlankImportHint(_ app: XCUIApplication) {
waitForBlankImportHint(app)
let importButton = app.buttons["BrowserImportHintImportButton"]
XCTAssertTrue(importButton.waitForExistence(timeout: 5.0))
importButton.click()
waitForImportWizard(app)
}
private func waitForCapturedSelection(timeout: TimeInterval) -> [String: Any]? {
let url = URL(fileURLWithPath: capturePath)
let foundCapture = browserImportPollUntil(timeout: timeout) {
if let data = try? Data(contentsOf: url),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
return !object.isEmpty
}
return false
}
if foundCapture,
let data = try? Data(contentsOf: url),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
return object
}
return nil
}
private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) {
app.launch()
let activated = browserImportPollUntil(timeout: activateTimeout) {
guard app.state != .runningForeground else {
return true
}
app.activate()
return app.state == .runningForeground
}
if !activated {
app.activate()
}
}
}

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

@ -299,6 +299,7 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase {
private var socketPath = ""
private let hiddenSurfaceToken = "cmux-command-palette-hidden-surface"
private let visibleSurfaceToken = "cmux-command-palette-visible-surface"
private let noMatchWorkspaceQuery = "cmux-command-palette-no-match"
override func setUp() {
super.setUp()
@ -542,6 +543,69 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase {
)
}
func testSwitcherEmptyStateDoesNotBlinkWhileRefiningNoMatchQuery() throws {
let app = XCUIApplication()
app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"]
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
launchAndActivate(app)
XCTAssertTrue(
sidebarHelpPollUntil(timeout: 8.0) {
app.windows.count >= 1
},
"Expected the main window to be visible"
)
XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)")
let mainWindowId = try XCTUnwrap(
socketCommand("current_window")?.trimmingCharacters(in: .whitespacesAndNewlines)
)
try seedWorkspaceSwitcherCorpus(workspaceCount: 96)
let searchField = app.textFields["CommandPaletteSearchField"]
app.typeKey("p", modifierFlags: [.command])
XCTAssertTrue(searchField.waitForExistence(timeout: 5.0), "Expected command palette search field")
searchField.click()
try debugTypeText(String(repeating: "z", count: 8))
let emptyLabel = app.staticTexts["No workspaces match your search."].firstMatch
XCTAssertTrue(
sidebarHelpPollUntil(timeout: 5.0) {
guard emptyLabel.exists else { return false }
guard let snapshot = commandPaletteSnapshot(windowId: mainWindowId) else { return false }
return (snapshot["query"] as? String) == String(repeating: "z", count: 8)
&& self.commandPaletteResultRows(from: snapshot).isEmpty
},
"Expected the switcher to reach a visible no-results state before refining the query"
)
try debugTypeText("z")
let emptyLabelDisappearedWhileRefining = sidebarHelpPollUntil(timeout: 0.5, pollInterval: 0.01) {
!emptyLabel.exists
}
XCTAssertFalse(
emptyLabelDisappearedWhileRefining,
"Expected refining an already-empty switcher query to keep the empty-state label visible"
)
let refinedSnapshot = try XCTUnwrap(
waitForCommandPaletteSnapshot(
windowId: mainWindowId,
query: String(repeating: "z", count: 9),
timeout: 5.0
) { snapshot in
self.commandPaletteResultRows(from: snapshot).isEmpty
}
)
XCTAssertTrue(
commandPaletteResultRows(from: refinedSnapshot).isEmpty,
"Expected the refined no-match query to stay empty. snapshot=\(refinedSnapshot)"
)
}
private func launchAndActivate(_ app: XCUIApplication) {
app.launch()
XCTAssertTrue(
@ -684,6 +748,46 @@ final class CommandPaletteAllSurfacesUITests: XCTestCase {
return UUID(uuidString: value) != nil ? value : nil
}
private func debugTypeText(_ text: String) throws {
let response = try XCTUnwrap(
socketJSON(
method: "debug.type",
params: ["text": text]
),
"Expected a response from debug.type"
)
XCTAssertEqual(response["ok"] as? Bool, true, "Expected debug.type to succeed. response=\(response)")
}
private func seedWorkspaceSwitcherCorpus(workspaceCount: Int) throws {
guard workspaceCount > 1 else { return }
for index in 1..<workspaceCount {
let workspaceId = try XCTUnwrap(
okUUID(from: socketCommand("new_workspace")),
"Expected new_workspace to return a workspace ID"
)
let title = "\(noMatchWorkspaceQuery)-\(index)-" + String(repeating: "workspace-", count: 8)
let response = try XCTUnwrap(
socketJSON(
method: "workspace.rename",
params: [
"workspace_id": workspaceId,
"title": title,
]
),
"Expected a response from workspace.rename"
)
XCTAssertEqual(
response["ok"] as? Bool,
true,
"Expected workspace.rename to succeed. response=\(response)"
)
}
XCTAssertEqual(socketCommand("select_workspace 0"), "OK")
}
private func socketCommand(_ command: String) -> String? {
ControlSocketClient(path: socketPath, responseTimeout: 2.0).sendLine(command)
}

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

View file

@ -124,6 +124,23 @@ final class UpdatePillUITests: XCTestCase {
assertVisibleSize(noUpdatePill)
}
func testBackgroundDetectedUpdateKeepsOnlyBottomUpdatePill() {
let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences")
systemSettings.terminate()
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launchEnvironment["CMUX_UI_TEST_DETECTED_UPDATE_VERSION"] = "9.9.9"
app.launchEnvironment["CMUX_UI_TEST_UPDATE_STATE"] = "available"
app.launchEnvironment["CMUX_UI_TEST_UPDATE_VERSION"] = "9.9.9"
launchAndActivate(app)
let pill = pillButton(app: app, expectedLabel: "Update Available: 9.9.9")
XCTAssertTrue(pill.waitForExistence(timeout: 6.0))
assertVisibleSize(pill)
XCTAssertFalse(app.otherElements["SidebarUpdateBanner"].exists)
XCTAssertFalse(app.buttons["SidebarUpdateBannerAction"].exists)
}
func testNoSparklePermissionDialogIsShown() {
let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences")
systemSettings.terminate()

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

@ -1 +1 @@
Subproject commit dcfaa081e5b3e0ad62c5c1a5a4d58f4562f6be71
Subproject commit a5f372ecfa5ee3903af6e1faba0eda096b4f5746

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

@ -10,6 +10,9 @@ Validates that shell integration:
4) recovers when a gh probe wedges longer than the async timeout
5) keeps polling in bash after prompt-render helper commands run
6) tears down the timed-out gh probe instead of leaking it in the background
7) falls back to explicit branch lookup when implicit gh branch resolution fails
8) does not clear an existing PR badge on the first prompt while establishing
the HEAD baseline
"""
from __future__ import annotations
@ -77,6 +80,11 @@ def _git_stub() -> str:
exit 0
fi
if [ "$1" = "remote" ] && [ "$2" = "get-url" ] && [ "$3" = "origin" ]; then
printf 'https://github.com/manaflow-ai/cmux.git\\n'
exit 0
fi
if [ "$1" = "status" ] && [ "$2" = "--porcelain" ] && [ "$3" = "-uno" ]; then
exit 0
fi
@ -111,6 +119,17 @@ def _gh_stub() -> str:
exit 9
fi
requested_branch=""
if [ $# -ge 3 ]; then
case "$3" in
--*)
;;
*)
requested_branch="$3"
;;
esac
fi
branch=""
if [ -f "$head_file" ]; then
head_line="$(cat "$head_file")"
@ -125,6 +144,9 @@ def _gh_stub() -> str:
prompt_helper_idle)
printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n'
;;
initial_prompt_preserves_pr_badge)
printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n'
;;
transient_same_context)
if [ "$count" -eq 1 ]; then
printf 'rate limit exceeded\\n' >&2
@ -154,6 +176,18 @@ def _gh_stub() -> str:
fi
printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n'
;;
explicit_branch_fallback)
if [ -z "$requested_branch" ]; then
printf 'no pull requests found for branch "%s"\\n' "$branch" >&2
exit 1
fi
if [ "$requested_branch" = "$branch" ]; then
printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n'
exit 0
fi
printf 'unexpected branch lookup: %s\\n' "$requested_branch" >&2
exit 8
;;
*)
printf 'unknown scenario: %s\\n' "$scenario" >&2
exit 2
@ -198,6 +232,20 @@ def _shell_command(kind: str, scenario: str) -> str:
'sleep 4\n'
'_cmux_cleanup\n'
),
"explicit_branch_fallback": (
'cd "$CMUX_TEST_REPO"\n'
'_CMUX_PR_POLL_INTERVAL=10\n'
'_cmux_prompt_entry\n'
'sleep 2\n'
'_cmux_cleanup\n'
),
"initial_prompt_preserves_pr_badge": (
'cd "$CMUX_TEST_REPO"\n'
'_CMUX_PR_POLL_INTERVAL=10\n'
'_cmux_prompt_entry\n'
'sleep 2\n'
'_cmux_cleanup\n'
),
}[scenario]
if kind == "zsh":
@ -344,6 +392,27 @@ def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path, sc
return (1, f"{shell}/{scenario}: timed-out gh probe still running as pid {gh_pid}")
return (0, f"{shell}/{scenario}: ok")
if scenario == "explicit_branch_fallback":
if _report_line(1138) not in send_lines:
return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines))
if not any(line.startswith("pr view feature/issue-1138 ") for line in gh_args_lines):
return (
1,
f"{shell}/{scenario}: expected explicit branch fallback\n" + "\n".join(gh_args_lines),
)
return (0, f"{shell}/{scenario}: ok")
if scenario == "initial_prompt_preserves_pr_badge":
if _report_line(1138) not in send_lines:
return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines))
if any(line.startswith("clear_pr ") for line in send_lines):
return (
1,
f"{shell}/{scenario}: initial prompt should not clear an existing PR badge\n"
+ "\n".join(send_lines),
)
return (0, f"{shell}/{scenario}: ok")
return (1, f"{shell}/{scenario}: unhandled scenario")
@ -358,6 +427,8 @@ def main() -> int:
"transient_same_context",
"branch_switch_clear",
"timeout_recovery",
"explicit_branch_fallback",
"initial_prompt_preserves_pr_badge",
]
base = Path("/tmp") / f"cmux_issue_1138_pr_poll_{os.getpid()}"

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

Some files were not shown because too many files have changed in this diff Show more