Revert "Merge pull request #239 from manaflow-ai/issue-151-ssh-remote-port-proxying"

This reverts commit 78e4bd32ba, reversing
changes made to cf75da8f8a.
This commit is contained in:
Lawrence Chen 2026-03-12 14:45:58 -07:00
parent 78e4bd32ba
commit f7cbbad434
60 changed files with 1250 additions and 17140 deletions

View file

@ -28,27 +28,6 @@ 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
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:

View file

@ -20,8 +20,6 @@ concurrency:
permissions:
contents: write
attestations: write
id-token: write
env:
CREATE_DMG_VERSION: 8.0.0
@ -144,11 +142,6 @@ 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
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
@ -247,7 +240,6 @@ jobs:
NIGHTLY_BUILD="${NIGHTLY_DATE}000000"
fi
echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV"
echo "NIGHTLY_REMOTE_DAEMON_VERSION=${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" >> "$GITHUB_ENV"
ARM_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg"
UNIVERSAL_DMG_IMMUTABLE="cmux-nightly-universal-macos-${NIGHTLY_BUILD}.dmg"
@ -292,24 +284,6 @@ jobs:
echo "Nightly universal immutable DMG: ${UNIVERSAL_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.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)"
for APP_PLIST in \
"build-arm/Build/Products/Release/cmux NIGHTLY.app/Contents/Info.plist" \
"build-universal/Build/Products/Release/cmux NIGHTLY.app/Contents/Info.plist"
do
plutil -remove CMUXRemoteDaemonManifestJSON "$APP_PLIST" >/dev/null 2>&1 || true
plutil -insert CMUXRemoteDaemonManifestJSON -string "$MANIFEST_JSON" "$APP_PLIST"
done
- name: Import signing cert
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true'
env:
@ -453,18 +427,6 @@ jobs:
./scripts/sparkle_generate_appcast.sh "$NIGHTLY_DMG_IMMUTABLE" nightly appcast.xml
./scripts/sparkle_generate_appcast.sh "$NIGHTLY_UNIVERSAL_DMG_IMMUTABLE" nightly appcast-universal.xml
- name: Attest remote daemon nightly assets
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.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
@ -475,12 +437,6 @@ jobs:
cmux-nightly-universal-macos*.dmg
appcast.xml
appcast-universal.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
if-no-files-found: error
- name: Move nightly tag to built commit
@ -516,12 +472,6 @@ jobs:
cmux-nightly-universal-macos.dmg
appcast.xml
appcast-universal.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
overwrite_files: true
- name: Cleanup keychain

View file

@ -8,8 +8,6 @@ on:
permissions:
contents: write
attestations: write
id-token: write
env:
CREATE_DMG_VERSION: 8.0.0
@ -116,12 +114,6 @@ 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:
@ -142,21 +134,6 @@ 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: |
@ -291,18 +268,6 @@ 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
@ -310,12 +275,6 @@ 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

@ -103,8 +103,6 @@ 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

@ -25452,57 +25452,6 @@
}
}
},
"contextMenu.copyError": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Copy Error"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "エラーをコピー"
}
}
}
},
"contextMenu.copyErrors": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Copy Errors"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "エラーをコピー"
}
}
}
},
"contextMenu.copySshError": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Copy SSH Error"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "SSHエラーをコピー"
}
}
}
},
"contextMenu.moveDown": {
"extractionState": "manual",
"localizations": {
@ -42843,40 +42792,6 @@
}
}
},
"settings.app.showSSH": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Show SSH in Sidebar"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "サイドバーにSSHを表示"
}
}
}
},
"settings.app.showSSH.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Display the SSH target for remote workspaces in its own row."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "リモートワークスペースのSSHターゲットを専用の行に表示します。"
}
}
}
},
"settings.app.showPorts.subtitle": {
"extractionState": "manual",
"localizations": {
@ -61421,227 +61336,6 @@
}
}
},
"sidebar.remote.badge": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "SSH"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "SSH"
}
}
}
},
"remote.status.connected": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Connected"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "接続済み"
}
}
}
},
"remote.status.connecting": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Connecting"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "接続中"
}
}
}
},
"remote.status.disconnected": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Disconnected"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "切断済み"
}
}
}
},
"remote.status.error": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Error"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "エラー"
}
}
}
},
"sidebar.remote.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "SSH • %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "SSH • %@"
}
}
}
},
"sidebar.remote.subtitleFallback": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "SSH workspace"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "SSH ワークスペース"
}
}
}
},
"sidebar.remote.help.connected": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "SSH connected to %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "SSH は %@ に接続済み"
}
}
}
},
"sidebar.remote.help.connecting": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "SSH connecting to %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "SSH は %@ に接続中"
}
}
}
},
"sidebar.remote.help.error": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "SSH error for %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%@ の SSH エラー"
}
}
}
},
"sidebar.remote.help.errorWithDetail": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "SSH error for %@: %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%@ の SSH エラー: %@"
}
}
}
},
"sidebar.remote.help.disconnected": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "SSH disconnected from %@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "SSH は %@ から切断済み"
}
}
}
},
"sidebar.remote.help.targetFallback": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "remote host"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "リモートホスト"
}
}
}
},
"sidebar.workspace.moveDownAction": {
"extractionState": "manual",
"localizations": {

View file

@ -57,102 +57,6 @@ typeset -g _CMUX_PORTS_LAST_RUN=0
typeset -g _CMUX_CMD_START=0
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() {
(( _CMUX_GHOSTTY_SEMANTIC_PATCHED )) && return 0
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).
@ -481,9 +385,6 @@ _cmux_precmd() {
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
# 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)"
@ -498,8 +399,6 @@ _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

View file

@ -74,47 +74,6 @@ func cmuxAccentColor() -> Color {
Color(nsColor: cmuxAccentNSColor())
}
struct SidebarRemoteErrorCopyEntry: Equatable {
let workspaceTitle: String
let target: String
let detail: String
}
enum SidebarRemoteErrorCopySupport {
static func menuLabel(for entries: [SidebarRemoteErrorCopyEntry]) -> String? {
guard !entries.isEmpty else { return nil }
if entries.count == 1 {
return String(localized: "contextMenu.copyError", defaultValue: "Copy Error")
}
return String(localized: "contextMenu.copyErrors", defaultValue: "Copy Errors")
}
static func clipboardText(for entries: [SidebarRemoteErrorCopyEntry]) -> String? {
guard !entries.isEmpty else { return nil }
if entries.count == 1, let entry = entries.first {
return "SSH error (\(entry.target)): \(entry.detail)"
}
return entries.enumerated().map { index, entry in
"\(index + 1). \(entry.workspaceTitle) (\(entry.target)): \(entry.detail)"
}.joined(separator: "\n")
}
static func parsedTargetAndDetail(from value: String, fallbackTarget: String? = nil) -> (target: String, detail: String)? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("SSH error") else { return nil }
if let match = trimmed.firstMatch(of: /^SSH error \((.+?)\):\s*(.+)$/) {
return (String(match.1), String(match.2))
}
if let match = trimmed.firstMatch(of: /^SSH error:\s*(.+)$/) {
guard let fallbackTarget, !fallbackTarget.isEmpty else { return nil }
return (fallbackTarget, String(match.1))
}
return nil
}
}
func sidebarSelectedWorkspaceBackgroundNSColor(for colorScheme: ColorScheme) -> NSColor {
cmuxAccentNSColor(for: colorScheme)
}
@ -1970,7 +1929,6 @@ struct ContentView: View {
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
)
.frame(width: sidebarWidth)
.frame(maxHeight: .infinity, alignment: .topLeading)
}
/// Space at top of content area for the titlebar. This must be at least the actual titlebar
@ -7338,7 +7296,6 @@ struct VerticalTabsSidebar: View {
#endif
draggedTabId = nil
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func debugShortSidebarTabId(_ id: UUID?) -> String {
@ -9532,7 +9489,6 @@ private struct TabItemView: View, Equatable {
@AppStorage("sidebarShowPullRequest") private var sidebarShowPullRequest = true
@AppStorage(BrowserLinkOpenSettings.openSidebarPullRequestLinksInCmuxBrowserKey)
private var openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
@AppStorage("sidebarShowSSH") private var sidebarShowSSH = true
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
@ -9635,84 +9591,12 @@ private struct TabItemView: View, Equatable {
)
}
private var remoteWorkspaceSidebarText: String? {
guard tab.hasActiveRemoteTerminalSessions else { return nil }
let trimmedTarget = tab.remoteDisplayTarget?.trimmingCharacters(in: .whitespacesAndNewlines)
if let trimmedTarget, !trimmedTarget.isEmpty {
return trimmedTarget
}
return String(localized: "sidebar.remote.subtitleFallback", defaultValue: "SSH workspace")
}
private var copyableSidebarSSHError: String? {
let trimmedDetail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines)
if tab.remoteConnectionState == .error, let trimmedDetail, !trimmedDetail.isEmpty {
let target = tab.remoteDisplayTarget ?? "unknown"
return "SSH error (\(target)): \(trimmedDetail)"
}
if let statusValue = tab.statusEntries["remote.error"]?.value
.trimmingCharacters(in: .whitespacesAndNewlines),
!statusValue.isEmpty {
return statusValue
}
return nil
}
private var remoteConnectionStatusText: String {
switch tab.remoteConnectionState {
case .connected:
return String(localized: "remote.status.connected", defaultValue: "Connected")
case .connecting:
return String(localized: "remote.status.connecting", defaultValue: "Connecting")
case .error:
return String(localized: "remote.status.error", defaultValue: "Error")
case .disconnected:
return String(localized: "remote.status.disconnected", defaultValue: "Disconnected")
}
}
@ViewBuilder
private var remoteWorkspaceSection: some View {
if sidebarShowSSH, let remoteWorkspaceSidebarText {
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "sidebar.remote.badge", defaultValue: "SSH"))
.font(.system(size: 9, weight: .semibold))
.foregroundColor(activeSecondaryColor(0.62))
.textCase(.uppercase)
HStack(spacing: 6) {
Text(remoteWorkspaceSidebarText)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(activeSecondaryColor(0.8))
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
Text(remoteConnectionStatusText)
.font(.system(size: 9, weight: .medium))
.foregroundColor(activeSecondaryColor(0.58))
.lineLimit(1)
}
}
.padding(.top, latestNotificationText == nil ? 1 : 2)
.safeHelp(remoteStateHelpText)
}
}
private func copyTextToPasteboard(_ text: String) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
}
var body: some View {
let closeWorkspaceTooltip = String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close Workspace")
let accessibilityHintText = String(localized: "sidebar.workspace.accessibilityHint", defaultValue: "Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions.")
let moveUpActionText = String(localized: "sidebar.workspace.moveUpAction", defaultValue: "Move Up")
let moveDownActionText = String(localized: "sidebar.workspace.moveDownAction", defaultValue: "Move Down")
let latestNotificationSubtitle = latestNotificationText
let effectiveSubtitle = latestNotificationSubtitle
let orderedPanelIds: [UUID]? = (sidebarShowBranchDirectory || sidebarShowPullRequest)
? tab.sidebarOrderedPanelIds()
: nil
@ -9816,7 +9700,7 @@ private struct TabItemView: View, Equatable {
.frame(width: workspaceHintSlotWidth, height: 16, alignment: .trailing)
}
if let subtitle = effectiveSubtitle {
if let subtitle = latestNotificationSubtitle {
Text(subtitle)
.font(.system(size: 10))
.foregroundColor(activeSecondaryColor(0.8))
@ -9825,8 +9709,6 @@ private struct TabItemView: View, Equatable {
.multilineTextAlignment(.leading)
}
remoteWorkspaceSection
if sidebarShowMetadata {
let metadataEntries = tab.sidebarStatusEntriesInDisplayOrder()
let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder()
@ -10086,16 +9968,6 @@ private struct TabItemView: View, Equatable {
let isMulti = targetIds.count > 1
let tabColorPalette = WorkspaceTabColorSettings.palette()
let shouldPin = !tab.isPinned
let targetWorkspaces = targetIds.compactMap { id in tabManager.tabs.first(where: { $0.id == id }) }
let remoteTargetWorkspaces = targetWorkspaces.filter { $0.isRemoteWorkspace }
let reconnectLabel = contextMenuLabel(
multi: String(localized: "contextMenu.reconnectWorkspaces", defaultValue: "Reconnect Workspaces"),
single: String(localized: "contextMenu.reconnectWorkspace", defaultValue: "Reconnect Workspace"),
isMulti: isMulti)
let disconnectLabel = contextMenuLabel(
multi: String(localized: "contextMenu.disconnectWorkspaces", defaultValue: "Disconnect Workspaces"),
single: String(localized: "contextMenu.disconnectWorkspace", defaultValue: "Disconnect Workspace"),
isMulti: isMulti)
let pinLabel = shouldPin
? contextMenuLabel(
multi: String(localized: "contextMenu.pinWorkspaces", defaultValue: "Pin Workspaces"),
@ -10145,24 +10017,6 @@ private struct TabItemView: View, Equatable {
}
}
if !remoteTargetWorkspaces.isEmpty {
Divider()
Button(reconnectLabel) {
for workspace in remoteTargetWorkspaces {
workspace.reconnectRemoteConnection()
}
}
.disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .connecting })
Button(disconnectLabel) {
for workspace in remoteTargetWorkspaces {
workspace.disconnectRemoteConnection(clearConfiguration: false)
}
}
.disabled(remoteTargetWorkspaces.allSatisfy { $0.remoteConnectionState == .disconnected })
}
Menu(String(localized: "contextMenu.workspaceColor", defaultValue: "Workspace Color")) {
if tab.customColor != nil {
Button {
@ -10195,12 +10049,6 @@ private struct TabItemView: View, Equatable {
}
}
if let copyableSidebarSSHError {
Button(String(localized: "contextMenu.copySshError", defaultValue: "Copy SSH Error")) {
copyTextToPasteboard(copyableSidebarSSHError)
}
}
Divider()
Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) {
@ -10476,62 +10324,6 @@ private struct TabItemView: View, Equatable {
}
}
private var remoteStateHelpText: String {
let target = tab.remoteDisplayTarget ?? String(
localized: "sidebar.remote.help.targetFallback",
defaultValue: "remote host"
)
let detail = tab.remoteConnectionDetail?.trimmingCharacters(in: .whitespacesAndNewlines)
switch tab.remoteConnectionState {
case .connected:
return String(
format: String(
localized: "sidebar.remote.help.connected",
defaultValue: "SSH connected to %@"
),
locale: .current,
target
)
case .connecting:
return String(
format: String(
localized: "sidebar.remote.help.connecting",
defaultValue: "SSH connecting to %@"
),
locale: .current,
target
)
case .error:
if let detail, !detail.isEmpty {
return String(
format: String(
localized: "sidebar.remote.help.errorWithDetail",
defaultValue: "SSH error for %@: %@"
),
locale: .current,
target,
detail
)
}
return String(
format: String(
localized: "sidebar.remote.help.error",
defaultValue: "SSH error for %@"
),
locale: .current,
target
)
case .disconnected:
return String(
format: String(
localized: "sidebar.remote.help.disconnected",
defaultValue: "SSH disconnected from %@"
),
locale: .current,
target
)
}
}
private func moveWorkspaces(_ workspaceIds: [UUID], toWindow windowId: UUID) {
guard let app = AppDelegate.shared else { return }
let orderedWorkspaceIds = tabManager.tabs.compactMap { workspaceIds.contains($0.id) ? $0.id : nil }
@ -10733,18 +10525,6 @@ private struct TabItemView: View, Equatable {
}
}
private func shortenPath(_ path: String, home: String) -> String {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return path }
if trimmed == home {
return "~"
}
if trimmed.hasPrefix(home + "/") {
return "~" + trimmed.dropFirst(home.count)
}
return trimmed
}
private struct PullRequestStatusIcon: View {
let status: SidebarPullRequestStatus
let color: Color

View file

@ -2333,8 +2333,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
private let surfaceContext: ghostty_surface_context_e
private let configTemplate: ghostty_surface_config_s?
private let workingDirectory: String?
private let initialCommand: String?
private let initialEnvironmentOverrides: [String: String]
private let additionalEnvironment: [String: String]
let hostedView: GhosttySurfaceScrollView
private let surfaceView: GhosttyNSView
private var lastPixelWidth: UInt32 = 0
@ -2402,8 +2401,6 @@ final class TerminalSurface: Identifiable, ObservableObject {
context: ghostty_surface_context_e,
configTemplate: ghostty_surface_config_s?,
workingDirectory: String? = nil,
initialCommand: String? = nil,
initialEnvironmentOverrides: [String: String] = [:],
additionalEnvironment: [String: String] = [:]
) {
self.id = UUID()
@ -2411,12 +2408,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
self.surfaceContext = context
self.configTemplate = configTemplate
self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines)
self.initialCommand = (trimmedCommand?.isEmpty == false) ? trimmedCommand : nil
self.initialEnvironmentOverrides = Self.mergedNormalizedEnvironment(
base: additionalEnvironment,
overrides: initialEnvironmentOverrides
)
self.additionalEnvironment = additionalEnvironment
// Match Ghostty's own SurfaceView: ensure a non-zero initial frame so the backing layer
// has non-zero bounds and the renderer can initialize without presenting a blank/stretched
// intermediate frame on the first real resize.
@ -2434,25 +2426,6 @@ final class TerminalSurface: Identifiable, ObservableObject {
surfaceView.tabId = newTabId
}
private static func mergedNormalizedEnvironment(
base: [String: String],
overrides: [String: String]
) -> [String: String] {
var merged: [String: String] = [:]
merged.reserveCapacity(base.count + overrides.count)
for (rawKey, value) in base {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
merged[key] = value
}
for (rawKey, value) in overrides {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
merged[key] = value
}
return merged
}
func isAttached(to view: GhosttyNSView) -> Bool {
attachedView === view && surface != nil
}
@ -2811,10 +2784,6 @@ final class TerminalSurface: Identifiable, ObservableObject {
env["CMUX_PANEL_ID"] = id.uuidString
env["CMUX_TAB_ID"] = tabId.uuidString
env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath()
if let bundledCLIPath = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux").path,
!bundledCLIPath.isEmpty {
env["CMUX_BUNDLED_CLI_PATH"] = bundledCLIPath
}
if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty {
env["CMUX_BUNDLE_ID"] = bundleId
}
@ -2882,8 +2851,8 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
}
if !initialEnvironmentOverrides.isEmpty {
for (key, value) in initialEnvironmentOverrides {
if !additionalEnvironment.isEmpty {
for (key, value) in additionalEnvironment where !key.isEmpty && !value.isEmpty {
env[key] = value
}
}
@ -2911,31 +2880,15 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
}
let createWithCommandAndWorkingDirectory = { [self] in
if let initialCommand, !initialCommand.isEmpty {
initialCommand.withCString { cCommand in
surfaceConfig.command = cCommand
if let workingDirectory, !workingDirectory.isEmpty {
workingDirectory.withCString { cWorkingDir in
surfaceConfig.working_directory = cWorkingDir
createSurface()
}
} else {
createSurface()
}
}
} else if let workingDirectory, !workingDirectory.isEmpty {
workingDirectory.withCString { cWorkingDir in
surfaceConfig.working_directory = cWorkingDir
createSurface()
}
} else {
if let workingDirectory, !workingDirectory.isEmpty {
workingDirectory.withCString { cWorkingDir in
surfaceConfig.working_directory = cWorkingDir
createSurface()
}
} else {
createSurface()
}
createWithCommandAndWorkingDirectory()
if surface == nil {
surfaceCallbackContext?.release()
surfaceCallbackContext = nil
@ -3085,7 +3038,6 @@ final class TerminalSurface: Identifiable, ObservableObject {
dlog("forceRefresh: \(id) reason=\(reason) \(viewState)")
#endif
guard let view = attachedView,
let surface,
view.window != nil,
view.bounds.width > 0,
view.bounds.height > 0 else {
@ -5685,7 +5637,6 @@ final class GhosttySurfaceScrollView: NSView {
private var activeDropZone: DropZone?
private var pendingDropZone: DropZone?
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
private var pendingAutomaticFirstResponderApply = false
// Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection.
/// Tracks whether keyboard focus should go to the search field or the terminal
@ -6293,7 +6244,7 @@ final class GhosttySurfaceScrollView: NSView {
#if DEBUG
dlog("find.window.didBecomeKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) focusTarget=\(self.searchFocusTarget) firstResponder=\(String(describing: self.window?.firstResponder))")
#endif
self.scheduleAutomaticFirstResponderApply(reason: "didBecomeKey")
self.applyFirstResponderIfNeeded()
})
windowObservers.append(NotificationCenter.default.addObserver(
forName: NSWindow.didResignKeyNotification,
@ -6316,9 +6267,7 @@ final class GhosttySurfaceScrollView: NSView {
#endif
}
})
if window.isKeyWindow {
scheduleAutomaticFirstResponderApply(reason: "viewDidMoveToWindow")
}
if window.isKeyWindow { applyFirstResponderIfNeeded() }
}
func attachSurface(_ terminalSurface: TerminalSurface) {
@ -6735,7 +6684,7 @@ final class GhosttySurfaceScrollView: NSView {
window.makeFirstResponder(nil)
}
} else {
scheduleAutomaticFirstResponderApply(reason: "setVisibleInUI")
applyFirstResponderIfNeeded()
}
}
@ -6762,7 +6711,7 @@ final class GhosttySurfaceScrollView: NSView {
}
#endif
if active {
scheduleAutomaticFirstResponderApply(reason: "setActive")
applyFirstResponderIfNeeded()
} else {
resignOwnedFirstResponderIfNeeded(reason: "setActive(false)")
}
@ -7124,20 +7073,6 @@ final class GhosttySurfaceScrollView: NSView {
return fr === surfaceView || fr.isDescendant(of: surfaceView)
}
private func scheduleAutomaticFirstResponderApply(reason: String) {
guard !pendingAutomaticFirstResponderApply else { return }
pendingAutomaticFirstResponderApply = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.pendingAutomaticFirstResponderApply = false
#if DEBUG
let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
dlog("find.applyFirstResponder.defer surface=\(surfaceShort) reason=\(reason)")
#endif
self.applyFirstResponderIfNeeded()
}
}
private func reassertTerminalSurfaceFocus(reason: String) {
guard let terminalSurface = surfaceView.terminalSurface else { return }
#if DEBUG
@ -7713,15 +7648,35 @@ final class GhosttySurfaceScrollView: NSView {
/// regions such as scrollbar space) when telling libghostty the terminal size.
@discardableResult
private func synchronizeCoreSurface() -> Bool {
// Reserving extra overlay-scroller gutter here causes AppKit and libghostty to fight
// over terminal columns during split churn. The width can flap by one scrollbar gutter,
// which redraws the shell prompt multiple times on Cmd+D. Favor stable columns.
let width = max(0, scrollView.contentSize.width)
let width = max(0, scrollView.contentSize.width - overlayScrollbarInsetWidth())
let height = surfaceView.frame.height
guard width > 0, height > 0 else { return false }
return surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height))
}
/// Reserve overlay scrollbar gutter so wrapped text never sits underneath a visible scroller.
private func overlayScrollbarInsetWidth() -> CGFloat {
guard scrollView.hasVerticalScroller, scrollView.scrollerStyle == .overlay else { return 0 }
// If AppKit already reserved non-content width in `contentSize`, avoid double-subtraction.
let alreadyReserved = max(0, scrollView.bounds.width - scrollView.contentSize.width)
if alreadyReserved > 0.5 { return 0 }
let fallback = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .overlay)
guard let verticalScroller = scrollView.verticalScroller else { return fallback }
let measuredWidth = verticalScroller.frame.width
if measuredWidth > 0 {
return max(measuredWidth, fallback)
}
let controlSizeWidth = NSScroller.scrollerWidth(
for: verticalScroller.controlSize,
scrollerStyle: .overlay
)
return max(controlSizeWidth, fallback)
}
private func updateNotificationRingPath() {
updateOverlayRingPath(
layer: notificationRingLayer,
@ -8235,12 +8190,6 @@ struct GhosttyTerminalView: NSViewRepresentable {
}
let portalExpectedSurfaceId = terminalSurface.id
let portalExpectedGeneration = terminalSurface.portalBindingGeneration()
func portalBindingStillLive() -> Bool {
terminalSurface.canAcceptPortalBinding(
expectedSurfaceId: portalExpectedSurfaceId,
expectedGeneration: portalExpectedGeneration
)
}
let forwardedDropZone = isVisibleInUI ? paneDropZone : nil
#if DEBUG
if coordinator.lastPaneDropZone != paneDropZone {
@ -8279,7 +8228,6 @@ struct GhosttyTerminalView: NSViewRepresentable {
reason: "didMoveToWindow"
) else { return }
guard host.window != nil else { return }
guard portalBindingStillLive() else { return }
TerminalWindowPortalRegistry.bind(
hostedView: hostedView,
to: host,
@ -8303,7 +8251,6 @@ struct GhosttyTerminalView: NSViewRepresentable {
bounds: host.bounds,
reason: "geometryChanged"
) else { return }
guard portalBindingStillLive() else { return }
let hostId = ObjectIdentifier(host)
if host.window != nil,
(coordinator.lastBoundHostId != hostId ||
@ -8333,7 +8280,6 @@ struct GhosttyTerminalView: NSViewRepresentable {
}
if host.window != nil, hostOwnsPortalNow {
let portalBindingLive = portalBindingStillLive()
let hostId = ObjectIdentifier(host)
let geometryRevision = host.geometryRevision
let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)
@ -8344,7 +8290,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
previousDesiredIsVisibleInUI != isVisibleInUI ||
previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing ||
previousDesiredPortalZPriority != portalZPriority
if portalBindingLive && shouldBindNow {
if shouldBindNow {
#if DEBUG
if portalEntryMissing {
dlog(
@ -8364,11 +8310,11 @@ struct GhosttyTerminalView: NSViewRepresentable {
)
coordinator.lastBoundHostId = hostId
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
} else if portalBindingLive && coordinator.lastSynchronizedHostGeometryRevision != geometryRevision {
} else if coordinator.lastSynchronizedHostGeometryRevision != geometryRevision {
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
}
} else if hostOwnsPortalNow, portalBindingStillLive() {
} else if hostOwnsPortalNow {
// Bind is deferred until host moves into a window. Update the
// existing portal entry's visibleInUI now so that any portal sync
// that runs before the deferred bind completes won't hide the view.
@ -8398,7 +8344,7 @@ struct GhosttyTerminalView: NSViewRepresentable {
isBoundToCurrentHost: isBoundToCurrentHost
)
if portalBindingStillLive() && shouldApplyImmediateHostedState {
if shouldApplyImmediateHostedState {
hostedView.setVisibleInUI(isVisibleInUI)
hostedView.setActive(isActive)
} else {

View file

@ -3,19 +3,6 @@ import Combine
import WebKit
import AppKit
import Bonsplit
import Network
struct BrowserProxyEndpoint: Equatable {
let host: String
let port: Int
}
struct BrowserRemoteWorkspaceStatus: Equatable {
let target: String
let connectionState: WorkspaceRemoteConnectionState
let heartbeatCount: Int
let lastHeartbeatAt: Date?
}
enum GhosttyBackgroundTheme {
static func clampedOpacity(_ opacity: Double) -> CGFloat {
@ -1268,14 +1255,6 @@ final class BrowserPortalAnchorView: NSView {
@MainActor
final class BrowserPanel: Panel, ObservableObject {
private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me"
private static let remoteLoopbackHosts: Set<String> = [
"localhost",
"127.0.0.1",
"::1",
"0.0.0.0",
]
/// Shared process pool for cookie sharing across all browser panels
private static let sharedProcessPool = WKProcessPool()
@ -1794,8 +1773,6 @@ final class BrowserPanel: Panel, ObservableObject {
private var developerToolsRestoreRetryAttempt: Int = 0
private let developerToolsRestoreRetryDelay: TimeInterval = 0.05
private let developerToolsRestoreRetryMaxAttempts: Int = 40
private var remoteProxyEndpoint: BrowserProxyEndpoint?
@Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus?
private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35
private var developerToolsDetachedOpenGraceDeadline: Date?
private var developerToolsTransitionTargetVisible: Bool?
@ -2038,24 +2015,15 @@ final class BrowserPanel: Panel, ObservableObject {
return instanceID == webViewInstanceID
}
init(
workspaceId: UUID,
initialURL: URL? = nil,
bypassInsecureHTTPHostOnce: String? = nil,
proxyEndpoint: BrowserProxyEndpoint? = nil,
isRemoteWorkspace: Bool = false
) {
init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) {
self.id = UUID()
self.workspaceId = workspaceId
self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "")
self.remoteProxyEndpoint = proxyEndpoint
self.browserThemeMode = BrowserThemeSettings.mode()
let webView = Self.makeWebView()
self.webView = webView
self.insecureHTTPAlertFactory = { NSAlert() }
let _ = isRemoteWorkspace
applyRemoteProxyConfigurationIfAvailable()
// Set up navigation delegate
let navDelegate = BrowserNavigationDelegate()
@ -2135,40 +2103,6 @@ final class BrowserPanel: Panel, ObservableObject {
}
}
func setRemoteProxyEndpoint(_ endpoint: BrowserProxyEndpoint?) {
guard remoteProxyEndpoint != endpoint else { return }
remoteProxyEndpoint = endpoint
applyRemoteProxyConfigurationIfAvailable()
}
func setRemoteWorkspaceStatus(_ status: BrowserRemoteWorkspaceStatus?) {
guard remoteWorkspaceStatus != status else { return }
remoteWorkspaceStatus = status
}
private func applyRemoteProxyConfigurationIfAvailable() {
guard #available(macOS 14.0, *) else { return }
let store = webView.configuration.websiteDataStore
guard let endpoint = remoteProxyEndpoint else {
store.proxyConfigurations = []
return
}
let host = endpoint.host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty,
endpoint.port > 0 && endpoint.port <= 65535,
let nwPort = NWEndpoint.Port(rawValue: UInt16(endpoint.port)) else {
store.proxyConfigurations = []
return
}
let nwEndpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: nwPort)
let socks = ProxyConfiguration(socksv5Proxy: nwEndpoint)
let connect = ProxyConfiguration(httpCONNECTProxy: nwEndpoint)
store.proxyConfigurations = [socks, connect]
}
private func beginDownloadActivity() {
let apply = {
self.activeDownloadCount += 1
@ -2665,7 +2599,6 @@ final class BrowserPanel: Panel, ObservableObject {
if !preserveRestoredSessionHistory {
abandonRestoredSessionHistoryIfNeeded()
}
let effectiveRequest = remoteProxyPreparedNavigationRequest(from: request)
// Some installs can end up with a legacy Chrome UA override; keep this pinned.
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
shouldRenderWebView = true
@ -2673,35 +2606,7 @@ final class BrowserPanel: Panel, ObservableObject {
BrowserHistoryStore.shared.recordTypedNavigation(url: url)
}
navigationDelegate?.lastAttemptedURL = url
browserLoadRequest(effectiveRequest, in: webView)
}
private func remoteProxyPreparedNavigationRequest(from request: URLRequest) -> URLRequest {
guard remoteProxyEndpoint != nil else { return request }
guard let url = request.url else { return request }
guard let rewrittenURL = Self.remoteProxyLoopbackAliasURL(for: url) else { return request }
var rewrittenRequest = request
rewrittenRequest.url = rewrittenURL
#if DEBUG
dlog(
"browser.remoteProxy.rewrite " +
"panel=\(id.uuidString.prefix(5)) " +
"from=\(url.absoluteString) " +
"to=\(rewrittenURL.absoluteString)"
)
#endif
return rewrittenRequest
}
private static func remoteProxyLoopbackAliasURL(for url: URL) -> URL? {
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return nil }
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return nil }
guard remoteLoopbackHosts.contains(host) else { return nil }
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
components?.host = remoteLoopbackProxyAliasHost
return components?.url
browserLoadRequest(request, in: webView)
}
/// Navigate with smart URL/search detection
@ -3576,16 +3481,6 @@ extension BrowserPanel {
applyPageZoom(1.0)
}
func currentPageZoomFactor() -> CGFloat {
webView.pageZoom
}
@discardableResult
func setPageZoomFactor(_ pageZoom: CGFloat) -> Bool {
let clamped = max(minPageZoom, min(maxPageZoom, pageZoom))
return applyPageZoom(clamped)
}
/// Take a snapshot of the web view
func takeSnapshot(completion: @escaping (NSImage?) -> Void) {
let config = WKSnapshotConfiguration()
@ -4400,13 +4295,6 @@ extension BrowserPanel {
return "webFrame=\(Self.debugRectDescription(webFrame)) webBounds=\(Self.debugRectDescription(webView.bounds)) webWin=\(webView.window?.windowNumber ?? -1) super=\(Self.debugObjectToken(container)) superType=\(containerType) superBounds=\(Self.debugRectDescription(containerBounds)) inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) inspectorInsets=\(String(format: "%.1f", inspectorInsets)) inspectorOverflow=\(String(format: "%.1f", inspectorOverflow)) inspectorSubviews=\(inspectorSubviews)"
}
func hideBrowserPortalView(source: String) {
BrowserWindowPortalRegistry.hide(
webView: webView,
source: source
)
}
}
#endif

View file

@ -84,45 +84,20 @@ final class TerminalPanel: Panel, ObservableObject {
context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: ghostty_surface_config_s? = nil,
workingDirectory: String? = nil,
portOrdinal: Int = 0,
initialCommand: String? = nil,
initialEnvironmentOverrides: [String: String] = [:],
additionalEnvironment: [String: String] = [:]
additionalEnvironment: [String: String] = [:],
portOrdinal: Int = 0
) {
let surface = TerminalSurface(
tabId: workspaceId,
context: context,
configTemplate: configTemplate,
workingDirectory: workingDirectory,
initialCommand: initialCommand,
initialEnvironmentOverrides: Self.mergedNormalizedEnvironment(
base: additionalEnvironment,
overrides: initialEnvironmentOverrides
)
additionalEnvironment: additionalEnvironment
)
surface.portOrdinal = portOrdinal
self.init(workspaceId: workspaceId, surface: surface)
}
private static func mergedNormalizedEnvironment(
base: [String: String],
overrides: [String: String]
) -> [String: String] {
var merged: [String: String] = [:]
merged.reserveCapacity(base.count + overrides.count)
for (rawKey, value) in base {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
merged[key] = value
}
for (rawKey, value) in overrides {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
merged[key] = value
}
return merged
}
func updateTitle(_ newTitle: String) {
let trimmed = newTitle.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty && title != trimmed {

View file

@ -406,18 +406,6 @@ struct SocketControlSettings {
) -> String {
let fallback = defaultSocketPath(bundleIdentifier: bundleIdentifier, isDebugBuild: isDebugBuild)
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
}
@ -434,9 +422,6 @@ struct SocketControlSettings {
}
static func defaultSocketPath(bundleIdentifier: String?, isDebugBuild: Bool) -> String {
if let taggedDebugPath = taggedDebugSocketPath(bundleIdentifier: bundleIdentifier, environment: [:]) {
return taggedDebugPath
}
if bundleIdentifier == "com.cmuxterm.app.nightly" {
return "/tmp/cmux-nightly.sock"
}
@ -469,37 +454,6 @@ struct SocketControlSettings {
|| bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.")
}
static func taggedDebugSocketPath(
bundleIdentifier: String?,
environment: [String: String]
) -> String? {
let bundleId = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if bundleId.hasPrefix("\(baseDebugBundleIdentifier).") {
let suffix = String(bundleId.dropFirst(baseDebugBundleIdentifier.count + 1))
let slug = suffix
.replacingOccurrences(of: ".", with: "-")
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
if !slug.isEmpty {
return "/tmp/cmux-debug-\(slug).sock"
}
}
let tag = launchTag(environment: environment)?
.lowercased()
.replacingOccurrences(of: ".", with: "-")
.replacingOccurrences(of: "_", with: "-")
.components(separatedBy: CharacterSet.alphanumerics.inverted)
.filter { !$0.isEmpty }
.joined(separator: "-")
guard bundleId == baseDebugBundleIdentifier,
let tag,
!tag.isEmpty else {
return nil
}
return "/tmp/cmux-debug-\(tag).sock"
}
static func isStagingBundleIdentifier(_ bundleIdentifier: String?) -> Bool {
guard let bundleIdentifier else { return false }
return bundleIdentifier == "com.cmuxterm.app.staging"

View file

@ -30,20 +30,11 @@ enum NewWorkspacePlacement: String, CaseIterable, Identifiable {
var description: String {
switch self {
case .top:
return String(
localized: "workspace.placement.top.description",
defaultValue: "Insert new workspaces at the top of the list."
)
return String(localized: "workspace.placement.top.description", defaultValue: "Insert new workspaces at the top of the list.")
case .afterCurrent:
return String(
localized: "workspace.placement.afterCurrent.description",
defaultValue: "Insert new workspaces directly after the active workspace."
)
return String(localized: "workspace.placement.afterCurrent.description", defaultValue: "Insert new workspaces directly after the active workspace.")
case .end:
return String(
localized: "workspace.placement.end.description",
defaultValue: "Append new workspaces to the bottom of the list."
)
return String(localized: "workspace.placement.end.description", defaultValue: "Append new workspaces to the bottom of the list.")
}
}
}
@ -81,9 +72,9 @@ enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable {
var displayName: String {
switch self {
case .leftRail:
return "Left Rail"
return String(localized: "sidebar.indicator.leftRail", defaultValue: "Left Rail")
case .solidFill:
return "Solid Fill"
return String(localized: "sidebar.indicator.solidFill", defaultValue: "Solid Fill")
}
}
}
@ -741,25 +732,36 @@ class TabManager: ObservableObject {
}
var isFindVisible: Bool {
selectedTerminalPanel?.searchState != nil || focusedBrowserPanel?.searchState != nil
if selectedTerminalPanel?.searchState != nil { return true }
if focusedBrowserPanel?.searchState != nil { return true }
return false
}
var canUseSelectionForFind: Bool {
selectedTerminalPanel?.hasSelection() == true
if focusedBrowserPanel != nil { return false }
return selectedTerminalPanel?.hasSelection() == true
}
func startSearch() {
if let panel = selectedTerminalPanel {
if panel.searchState == nil {
panel.searchState = TerminalSurface.SearchState()
}
NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString)
NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
_ = panel.performBindingAction("start_search")
if let browser = focusedBrowserPanel {
browser.startFind()
return
}
focusedBrowserPanel?.startFind()
guard let panel = selectedTerminalPanel else {
#if DEBUG
dlog("find.startSearch SKIPPED no selectedTerminalPanel")
#endif
return
}
let wasNil = panel.searchState == nil
if wasNil {
panel.searchState = TerminalSurface.SearchState()
}
#if DEBUG
dlog("find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5)) created=\(wasNil ? "yes" : "no(reuse)") firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))")
#endif
NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
_ = panel.performBindingAction("start_search")
}
func searchSelection() {
@ -767,27 +769,27 @@ class TabManager: ObservableObject {
if panel.searchState == nil {
panel.searchState = TerminalSurface.SearchState()
}
NSLog("Find: searchSelection workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString)
#if DEBUG
dlog("find.searchSelection workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5))")
#endif
NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
_ = panel.performBindingAction("search_selection")
}
func findNext() {
if let panel = selectedTerminalPanel {
_ = panel.performBindingAction("search:next")
if let browser = focusedBrowserPanel, browser.searchState != nil {
browser.findNext()
return
}
focusedBrowserPanel?.findNext()
_ = selectedTerminalPanel?.performBindingAction("search:next")
}
func findPrevious() {
if let panel = selectedTerminalPanel {
_ = panel.performBindingAction("search:previous")
if let browser = focusedBrowserPanel, browser.searchState != nil {
browser.findPrevious()
return
}
focusedBrowserPanel?.findPrevious()
_ = selectedTerminalPanel?.performBindingAction("search:previous")
}
@discardableResult
@ -797,26 +799,27 @@ class TabManager: ObservableObject {
}
func hideFind() {
if let panel = selectedTerminalPanel {
panel.searchState = nil
if let browser = focusedBrowserPanel, browser.searchState != nil {
browser.hideFind()
return
}
focusedBrowserPanel?.hideFind()
#if DEBUG
dlog("find.hideFind panel=\(selectedTerminalPanel?.id.uuidString.prefix(5) ?? "nil")")
#endif
selectedTerminalPanel?.searchState = nil
}
@discardableResult
func addWorkspace(
workingDirectory overrideWorkingDirectory: String? = nil,
initialTerminalCommand: String? = nil,
initialTerminalEnvironment: [String: String] = [:],
select: Bool = true,
eagerLoadTerminal: Bool = false,
placementOverride: NewWorkspacePlacement? = nil,
autoWelcomeIfNeeded: Bool = true
) -> Workspace {
sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1])
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory)
let workingDirectory = explicitWorkingDirectory ?? preferredWorkingDirectoryForNewTab()
let inheritedConfig = inheritedTerminalConfigForNewWorkspace()
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
@ -824,9 +827,7 @@ class TabManager: ObservableObject {
title: "Terminal \(tabs.count + 1)",
workingDirectory: workingDirectory,
portOrdinal: ordinal,
configTemplate: inheritedConfig,
initialTerminalCommand: initialTerminalCommand,
initialTerminalEnvironment: initialTerminalEnvironment
configTemplate: inheritedConfig
)
wireClosedBrowserTracking(for: newWorkspace)
let insertIndex = newTabInsertIndex(placementOverride: placementOverride)
@ -835,8 +836,17 @@ class TabManager: ObservableObject {
} else {
tabs.append(newWorkspace)
}
if let explicitWorkingDirectory,
let terminalPanel = newWorkspace.focusedTerminalPanel {
scheduleInitialWorkspaceGitMetadataRefresh(
workspaceId: newWorkspace.id,
panelId: terminalPanel.id,
directory: explicitWorkingDirectory
)
}
if eagerLoadTerminal {
newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded()
requestBackgroundWorkspaceLoad(for: newWorkspace.id)
newWorkspace.requestBackgroundTerminalSurfaceStartIfNeeded()
}
if select {
selectedTabId = newWorkspace.id
@ -1152,6 +1162,16 @@ class TabManager: ObservableObject {
tabs.insert(tab, at: insertIndex)
}
func moveTabToTopForNotification(_ tabId: UUID) {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
let pinnedCount = tabs.filter { $0.isPinned }.count
guard index != pinnedCount else { return }
let tab = tabs[index]
guard !tab.isPinned else { return }
tabs.remove(at: index)
tabs.insert(tab, at: pinnedCount)
}
func moveTabsToTop(_ tabIds: Set<UUID>) {
guard !tabIds.isEmpty else { return }
let selectedTabs = tabs.filter { tabIds.contains($0.id) }
@ -1164,16 +1184,6 @@ class TabManager: ObservableObject {
tabs = selectedPinned + remainingPinned + selectedUnpinned + remainingUnpinned
}
func moveTabToTopForNotification(_ tabId: UUID) {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
let pinnedCount = tabs.filter { $0.isPinned }.count
guard index != pinnedCount else { return }
let tab = tabs[index]
guard !tab.isPinned else { return }
tabs.remove(at: index)
tabs.insert(tab, at: pinnedCount)
}
@discardableResult
func reorderWorkspace(tabId: UUID, toIndex targetIndex: Int) -> Bool {
guard let currentIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false }
@ -1259,23 +1269,22 @@ class TabManager: ObservableObject {
func closeWorkspace(_ workspace: Workspace) {
guard tabs.count > 1 else { return }
guard let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return }
sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
clearInitialWorkspaceGitProbe(workspaceId: workspace.id)
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
workspace.teardownAllPanels()
workspace.teardownRemoteConnection()
unwireClosedBrowserTracking(for: workspace)
workspace.teardownAllPanels()
if let index = tabs.firstIndex(where: { $0.id == workspace.id }) {
tabs.remove(at: index)
tabs.remove(at: index)
if selectedTabId == workspace.id {
// Keep the "focused index" stable when possible:
// - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up).
// - Otherwise (we closed the last workspace), focus the new last workspace (i-1).
let newIndex = min(index, max(0, tabs.count - 1))
selectedTabId = tabs[newIndex].id
}
if selectedTabId == workspace.id {
// Keep the "focused index" stable when possible:
// - If we closed workspace i and there is still a workspace at index i, focus it (the one that moved up).
// - Otherwise (we closed the last workspace), focus the new last workspace (i-1).
let newIndex = min(index, max(0, tabs.count - 1))
selectedTabId = tabs[newIndex].id
}
}
@ -1284,6 +1293,7 @@ class TabManager: ObservableObject {
@discardableResult
func detachWorkspace(tabId: UUID) -> Workspace? {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil }
clearInitialWorkspaceGitProbe(workspaceId: tabId)
let removed = tabs.remove(at: index)
unwireClosedBrowserTracking(for: removed)
@ -1345,9 +1355,13 @@ class TabManager: ObservableObject {
let count = plan.panelIds.count
let titleLines = plan.titles.map { "\($0)" }.joined(separator: "\n")
let message = "This is about to close \(count) tab\(count == 1 ? "" : "s") in this pane:\n\(titleLines)"
let message = if count == 1 {
String(localized: "dialog.closeOtherTabs.message.one", defaultValue: "This will close 1 tab in this pane:\n\(titleLines)")
} else {
String(localized: "dialog.closeOtherTabs.message.other", defaultValue: "This will close \(count) tabs in this pane:\n\(titleLines)")
}
guard confirmClose(
title: "Close other tabs?",
title: String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"),
message: message,
acceptCmdD: false
) else { return }
@ -1387,8 +1401,8 @@ class TabManager: ObservableObject {
alert.messageText = title
alert.informativeText = message
alert.alertStyle = .warning
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: "Cancel")
alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
// macOS convention: Cmd+D = confirm destructive close (e.g. "Don't Save").
// We only opt into this for the "close last workspace => close window" path to avoid
@ -1449,15 +1463,15 @@ class TabManager: ObservableObject {
if let collapsed, !collapsed.isEmpty {
return collapsed
}
return "Untitled Tab"
return String(localized: "tab.untitled", defaultValue: "Untitled Tab")
}
private func closeWorkspaceIfRunningProcess(_ workspace: Workspace) {
let willCloseWindow = tabs.count <= 1
if workspaceNeedsConfirmClose(workspace),
!confirmClose(
title: "Close workspace?",
message: "This will close the workspace and all of its panels.",
title: String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"),
message: String(localized: "dialog.closeWorkspace.message", defaultValue: "This will close the workspace and all of its panels."),
acceptCmdD: willCloseWindow
) {
return
@ -1498,8 +1512,8 @@ class TabManager: ObservableObject {
let needsConfirm = workspaceNeedsConfirmClose(tab)
if needsConfirm {
let message = willCloseWindow
? "This will close the last tab and close the window."
: "This will close the last tab and close its workspace."
? String(localized: "dialog.closeLastTabWindow.message", defaultValue: "This will close the last tab and close the window.")
: String(localized: "dialog.closeLastTabWorkspace.message", defaultValue: "This will close the last tab and close its workspace.")
#if DEBUG
dlog(
"surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " +
@ -1507,7 +1521,7 @@ class TabManager: ObservableObject {
)
#endif
guard confirmClose(
title: "Close tab?",
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: message,
acceptCmdD: willCloseWindow
) else {
@ -1539,8 +1553,8 @@ class TabManager: ObservableObject {
)
#endif
guard confirmClose(
title: "Close tab?",
message: "This will close the current tab.",
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."),
acceptCmdD: false
) else {
#if DEBUG
@ -1578,8 +1592,8 @@ class TabManager: ObservableObject {
if let terminalPanel = tab.terminalPanel(for: surfaceId),
terminalPanel.needsConfirmClose() {
guard confirmClose(
title: "Close tab?",
message: "This will close the current tab.",
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."),
acceptCmdD: false
) else { return }
}
@ -1846,28 +1860,32 @@ class TabManager: ObservableObject {
guard !shouldSuppressFlash else { return }
guard AppFocusState.isAppActive() else { return }
guard let panelId = focusedPanelId(for: tabId) else { return }
markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId)
_ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true)
}
private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) {
guard selectedTabId == tabId else { return }
guard !suppressFocusFlash else { return }
guard AppFocusState.isAppActive() else { return }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: panelId) else { return }
if let tab = tabs.first(where: { $0.id == tabId }) {
tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false)
}
notificationStore.markRead(forTabId: tabId, surfaceId: panelId)
_ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true)
}
@discardableResult
func dismissNotificationOnDirectInteraction(tabId: UUID, surfaceId: UUID?) -> Bool {
dismissNotificationIfActive(tabId: tabId, surfaceId: surfaceId, triggerFlash: true)
}
@discardableResult
private func dismissNotificationIfActive(
tabId: UUID,
surfaceId: UUID?,
triggerFlash: Bool
) -> Bool {
guard selectedTabId == tabId else { return false }
guard AppFocusState.isAppActive() else { return false }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return false }
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return false }
if let panelId = surfaceId,
if triggerFlash,
let panelId = surfaceId,
let tab = tabs.first(where: { $0.id == tabId }) {
tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false)
}
@ -2166,9 +2184,24 @@ class TabManager: ObservableObject {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }),
let focusedPanelId = tab.focusedPanelId else { return }
#if DEBUG
let directionLabel = direction.debugLabel
dlog(
"split.create.request kind=terminal dir=\(directionLabel) " +
"tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
tab.clearSplitZoom()
sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)])
_ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
let createdPanelId = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
#if DEBUG
dlog(
"split.create.result kind=terminal dir=\(directionLabel) " +
"created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
}
/// Create a new browser split from the currently focused panel.
@ -2177,14 +2210,30 @@ class TabManager: ObservableObject {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }),
let focusedPanelId = tab.focusedPanelId else { return nil }
#if DEBUG
let directionLabel = direction.debugLabel
dlog(
"split.create.request kind=browser dir=\(directionLabel) " +
"tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
tab.clearSplitZoom()
return newBrowserSplit(
let createdPanelId = newBrowserSplit(
tabId: selectedTabId,
fromPanelId: focusedPanelId,
orientation: direction.orientation,
insertFirst: direction.insertFirst,
url: url
)
#if DEBUG
dlog(
"split.create.result kind=browser dir=\(directionLabel) " +
"created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
return createdPanelId
}
/// Refresh Bonsplit right-side action button tooltips for all workspaces.
@ -2285,12 +2334,21 @@ class TabManager: ObservableObject {
/// Returns the new panel's ID (which is also the surface ID for terminals)
func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
return tab.newTerminalSplit(
let createdPanel = tab.newTerminalSplit(
from: surfaceId,
orientation: direction.orientation,
insertFirst: direction.insertFirst,
focus: focus
)?.id
#if DEBUG
let directionLabel = direction.debugLabel
dlog(
"split.newSurface result dir=\(directionLabel) " +
"tab=\(tabId.uuidString.prefix(5)) source=\(surfaceId.uuidString.prefix(5)) " +
"created=\(createdPanel?.uuidString.prefix(5) ?? "nil") focus=\(focus ? 1 : 0)"
)
#endif
return createdPanel
}
/// Move focus in the specified direction
@ -2891,7 +2949,7 @@ class TabManager: ObservableObject {
continue
}
terminal.hostedView.reconcileGeometryNow()
terminal.surface.forceRefresh()
terminal.surface.forceRefresh(reason: "tabManager.reconcileVisibleTerminalGeometry")
}
}
@ -3869,6 +3927,15 @@ enum SplitDirection {
var insertFirst: Bool {
self == .left || self == .up
}
var debugLabel: String {
switch self {
case .left: return "left"
case .right: return "right"
case .up: return "up"
case .down: return "down"
}
}
}
/// Resize direction for backwards compatibility

File diff suppressed because it is too large Load diff

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: CharacterSet.whitespacesAndNewlines)
let title = tab.title.trimmingCharacters(in: .whitespacesAndNewlines)
text = title.isEmpty ? "Cmd: —" : "Cmd: \(title)"
} else {
text = "Cmd: —"

File diff suppressed because it is too large Load diff

View file

@ -3086,7 +3086,6 @@ 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
@ -3698,17 +3697,6 @@ 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.")
@ -4394,7 +4382,6 @@ struct SettingsView: View {
sidebarShowPullRequest = true
openSidebarPullRequestLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenSidebarPullRequestLinksInCmuxBrowser
showShortcutHintsOnCommandHold = ShortcutHintDebugSettings.defaultShowHintsOnCommandHold
sidebarShowSSH = true
sidebarShowPorts = true
sidebarShowLog = true
sidebarShowProgress = true

15
TODO.md
View file

@ -1,18 +1,5 @@
# 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).
@ -54,7 +41,7 @@
- [ ] OpenCode integration
## Browser
- [ ] Per-WKWebView proxy observability/inspection once remote proxy path is shipped (URL, method, headers, body, status, timing)
- [ ] Per-WKWebView local proxy for full network request/response inspection (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

@ -4782,74 +4782,6 @@ final class UpdateChannelSettingsTests: XCTestCase {
}
}
final class SidebarRemoteErrorCopySupportTests: XCTestCase {
func testMenuLabelIsNilWhenThereAreNoErrors() {
XCTAssertNil(SidebarRemoteErrorCopySupport.menuLabel(for: []))
XCTAssertNil(SidebarRemoteErrorCopySupport.clipboardText(for: []))
}
func testSingleErrorUsesCopyErrorLabelAndSingleLinePayload() {
let entries = [
SidebarRemoteErrorCopyEntry(
workspaceTitle: "alpha",
target: "devbox:22",
detail: "failed to start reverse relay"
)
]
XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Error")
XCTAssertEqual(
SidebarRemoteErrorCopySupport.clipboardText(for: entries),
"SSH error (devbox:22): failed to start reverse relay"
)
}
func testMultipleErrorsUseCopyErrorsLabelAndEnumeratedPayload() {
let entries = [
SidebarRemoteErrorCopyEntry(
workspaceTitle: "alpha",
target: "devbox-a:22",
detail: "connection timed out"
),
SidebarRemoteErrorCopyEntry(
workspaceTitle: "beta",
target: "devbox-b:22",
detail: "permission denied"
),
]
XCTAssertEqual(SidebarRemoteErrorCopySupport.menuLabel(for: entries), "Copy Errors")
XCTAssertEqual(
SidebarRemoteErrorCopySupport.clipboardText(for: entries),
"""
1. alpha (devbox-a:22): connection timed out
2. beta (devbox-b:22): permission denied
"""
)
}
func testParsedTargetAndDetailParsesCanonicalStatusValue() {
let parsed = SidebarRemoteErrorCopySupport.parsedTargetAndDetail(
from: "SSH error (devbox:22): failed to bootstrap daemon"
)
XCTAssertEqual(parsed?.target, "devbox:22")
XCTAssertEqual(parsed?.detail, "failed to bootstrap daemon")
}
func testParsedTargetAndDetailUsesFallbackTargetWhenStatusOmitsTarget() {
let parsed = SidebarRemoteErrorCopySupport.parsedTargetAndDetail(
from: "SSH error: connection refused",
fallbackTarget: "fallback-host"
)
XCTAssertEqual(parsed?.target, "fallback-host")
XCTAssertEqual(parsed?.detail, "connection refused")
}
func testParsedTargetAndDetailIgnoresNonSSHStatusValues() {
XCTAssertNil(SidebarRemoteErrorCopySupport.parsedTargetAndDetail(from: "All good"))
}
}
final class WorkspaceReorderTests: XCTestCase {
@MainActor
func testReorderWorkspaceMovesWorkspaceToRequestedIndex() {

View file

@ -659,104 +659,6 @@ 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 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()

View file

@ -1,49 +0,0 @@
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)
}
}

View file

@ -1,214 +0,0 @@
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 {
FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-socket-security-\(name)-\(UUID().uuidString).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)
#else
throw XCTSkip("Socket command policy snapshot helper is debug-only.")
#endif
}
private func waitForSocket(at path: String, timeout: TimeInterval = 2.0) throws {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if FileManager.default.fileExists(atPath: path) {
return
}
usleep(20_000)
}
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

@ -1,82 +0,0 @@
# 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.read`
7. `session.open`
8. `session.close`
9. `session.attach`
10. `session.resize`
11. `session.detach`
12. `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`.
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 retries for up to 15 seconds on connection refused, re-reading `~/.cmux/socket_addr` on each attempt to pick up updated relay ports.
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

@ -1,721 +0,0 @@
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
}
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: "panel.list", flagKeys: []string{"workspace"}},
{name: "focus-panel", proto: protoV2, v2Method: "panel.focus", flagKeys: []string{"panel", "workspace"}},
{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"}},
{name: "new-surface", proto: protoV2, v2Method: "surface.create", flagKeys: []string{"workspace", "pane"}},
{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 retry loops to pick up updated relay ports.
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 := parseFlags(args, spec.flagKeys)
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)
if !spec.noParams {
parsed := parseFlags(args, spec.flagKeys)
// 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)
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]
}
// Fall back to env vars for common IDs
if _, ok := params["workspace_id"]; !ok {
if envWs := os.Getenv("CMUX_WORKSPACE_ID"); envWs != "" {
params["workspace_id"] = envWs
}
}
if _, ok := params["surface_id"]; !ok {
if envSf := os.Getenv("CMUX_SURFACE_ID"); envSf != "" {
params["surface_id"] = envSf
}
}
}
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
switch sub {
case "open", "open-split", "new":
method = "browser.open"
flagKeys = []string{"url", "workspace", "surface"}
case "navigate":
method = "browser.navigate"
flagKeys = []string{"url", "surface"}
case "back":
method = "browser.back"
flagKeys = []string{"surface"}
case "forward":
method = "browser.forward"
flagKeys = []string{"surface"}
case "reload":
method = "browser.reload"
flagKeys = []string{"surface"}
case "get-url":
method = "browser.get_url"
flagKeys = []string{"surface"}
default:
fmt.Fprintf(os.Stderr, "cmux browser: unknown subcommand %q\n", sub)
return 2
}
params := make(map[string]any)
parsed := parseFlags(subArgs, flagKeys)
for _, key := range flagKeys {
if val, ok := parsed.flags[key]; ok {
paramKey := flagToParamKey(key)
params[paramKey] = val
}
}
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 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 {
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 !strings.HasPrefix(args[i], "--") {
result.positional = append(result.positional, args[i])
continue
}
key := strings.TrimPrefix(args[i], "--")
if !allowed[key] {
continue
}
if i+1 < len(args) {
result.flags[key] = args[i+1]
i++
}
}
return result
}
// 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, it retries briefly to allow the SSH reverse forward to establish.
// refreshAddr, if non-nil, is called on each retry to pick up updated socket_addr files.
func dialSocket(addr string, refreshAddr func() string) (net.Conn, error) {
if strings.Contains(addr, ":") && !strings.HasPrefix(addr, "/") {
conn, err := dialTCPRetry(addr, 15*time.Second, refreshAddr)
if err != nil {
return nil, err
}
if auth := currentRelayAuth(addr); auth != nil {
if err := authenticateRelayConn(conn, auth); err != nil {
conn.Close()
return nil, err
}
}
return conn, nil
}
return net.Dial("unix", addr)
}
// dialTCPRetry attempts a TCP connection, retrying on "connection refused" for up to timeout.
// This handles the case where the SSH reverse relay hasn't finished establishing yet.
// If refreshAddr is non-nil, it's called on each retry to pick up updated addresses
// (e.g. when socket_addr is rewritten by a new relay process).
func dialTCPRetry(addr string, timeout time.Duration, refreshAddr func() string) (net.Conn, error) {
deadline := time.Now().Add(timeout)
interval := 250 * time.Millisecond
printed := false
for {
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err == nil {
return conn, nil
}
if time.Now().After(deadline) {
return nil, err
}
// Only retry on connection refused (relay not ready yet)
if !isConnectionRefused(err) {
return nil, err
}
if !printed {
fmt.Fprintf(os.Stderr, "cmux: waiting for relay on %s...\n", addr)
printed = true
}
time.Sleep(interval)
// Re-read socket_addr in case the relay port has changed
if refreshAddr != nil {
if newAddr := refreshAddr(); newAddr != "" && newAddr != addr {
addr = newAddr
fmt.Fprintf(os.Stderr, "cmux: relay address updated to %s\n", addr)
}
}
}
}
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

@ -1,696 +0,0 @@
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)
}
// 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()
dir := t.TempDir()
sockPath := filepath.Join(dir, "cmux.sock")
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()
dir := t.TempDir()
sockPath := filepath.Join(dir, "cmux.sock")
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 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 TestDialTCPRetrySuccess(t *testing.T) {
// Get a free port, then close the listener so connection is refused initially.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := ln.Addr().String()
ln.Close()
// Start a listener after a delay so the retry logic finds it.
go func() {
time.Sleep(400 * time.Millisecond)
ln2, err := net.Listen("tcp", addr)
if err != nil {
return
}
defer ln2.Close()
conn, err := ln2.Accept()
if err != nil {
return
}
conn.Close()
}()
conn, err := dialTCPRetry(addr, 3*time.Second, nil)
if err != nil {
t.Fatalf("dialTCPRetry should succeed after retry, got: %v", err)
}
conn.Close()
}
func TestDialTCPRetryTimeout(t *testing.T) {
// Get a free port and close it — nothing will ever listen.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
addr := ln.Addr().String()
ln.Close()
start := time.Now()
_, err = dialTCPRetry(addr, 600*time.Millisecond, nil)
elapsed := time.Since(start)
if err == nil {
t.Fatal("dialTCPRetry should fail when nothing is listening")
}
if elapsed < 500*time.Millisecond {
t.Fatalf("should have retried for ~600ms, only 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")
var received string
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)
received = 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)
}
if received != "close_window win-42" {
t.Fatalf("expected 'close_window win-42', got %q", received)
}
}
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")
var receivedParams map[string]any
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)
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)
}
if receivedParams["workspace_id"] != "ws-abc" {
t.Fatalf("expected workspace_id=ws-abc, got %v", receivedParams)
}
}
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 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"}
result := parseFlags(args, []string{"workspace", "surface"})
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 _, ok := result.flags["unknown"]; ok {
t.Errorf("unknown flag should not be parsed")
}
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")
var receivedParams map[string]any
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)
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)
}
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"])
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,531 +0,0 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"math"
"net"
"strconv"
"strings"
"testing"
"time"
)
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 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 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"))
}()
server := &rpcServer{
nextStreamID: 1,
nextSessionID: 1,
streams: map[string]net.Conn{},
sessions: map[string]*sessionState{},
}
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.read",
Params: map[string]any{
"stream_id": streamID,
"max_bytes": 8,
"timeout_ms": 1000,
},
})
if !readResp.OK {
t.Fatalf("proxy.read failed: %+v", readResp)
}
readResult, _ := readResp.Result.(map[string]any)
dataBase64, _ := readResult["data_base64"].(string)
data, decodeErr := base64.StdEncoding.DecodeString(dataBase64)
if decodeErr != nil {
t.Fatalf("proxy.read returned invalid base64: %v", decodeErr)
}
if string(data) != "pong" {
t.Fatalf("proxy.read 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 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]net.Conn{},
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]net.Conn{},
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]net.Conn{},
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
}
}

View file

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

View file

@ -1,214 +0,0 @@
# 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/239
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.read`).
- `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 `ControlPath=none` (avoids ControlMaster multiplexing and inherited `RemoteForward` directives) and `ExitOnForwardFailure=no` (inherited forwards from user ssh config failing should not kill the relay).
- `DONE` relay address written to `~/.cmux/socket_addr` on the remote with a 3s delay after the relay process starts, giving SSH time to establish the `-R` forward.
- `DONE` Go CLI re-reads `~/.cmux/socket_addr` on each TCP retry to pick up updated relay ports when multiple workspaces overwrite the file.
- `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.read`).
2. `DONE` local side now runs a shared local broker that serves SOCKS5/CONNECT and tunnels each stream over persistent daemon stdio RPC.
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/read` implemented |
| M-008 | WebView proxy auto-wiring for remote workspaces | DONE | Workspace-scoped `WKWebsiteDataStore.proxyConfigurations` wiring is active |
| M-009 | PTY resize coordinator (`smallest screen wins`) | DONE | Daemon session RPC now tracks attachments and applies min cols/rows semantics with unit tests |
| M-010 | Resize + proxy reconnect e2e test suites | DONE | `tests_v2/test_ssh_remote_docker_forwarding.py` validates HTTP/websocket egress plus SOCKS pipelined-payload handling; `tests_v2/test_ssh_remote_docker_reconnect.py` verifies reconnect recovery and repeats SOCKS pipelined-payload checks after host restart; `tests_v2/test_ssh_remote_proxy_bind_conflict.py` validates structured `proxy_unavailable` bind-conflict surfacing and `local_proxy_port` status retention under bind conflict; `tests_v2/test_ssh_remote_daemon_resize_stdio.py` validates session resize semantics over real stdio RPC process boundaries; `tests_v2/test_ssh_remote_cli_metadata.py` validates `workspace.remote.configure` numeric-string compatibility, explicit `null` clear semantics (including `workspace.remote.status` reflection), strict `port`/`local_proxy_port` validation (bounds/type), case-insensitive SSH option override precedence for StrictHostKeyChecking/control-socket keys, and `local_proxy_port` payload echo for deterministic bind-conflict test hook behavior |
## 7. Acceptance Test Matrix (With Status)
### 7.1 Terminal + Reconnect
| ID | Scenario | Status |
|---|---|---|
| T-001 | baseline remote connect | DONE |
| T-002 | identical host reuse semantics | DONE |
| T-003 | no `--name` | DONE |
| T-004 | reconnect API success/error paths | DONE |
| T-005 | retry count visible in daemon error detail | DONE |
### 7.2 CLI Relay
| ID | Scenario | Status |
|---|---|---|
| C-001 | `cmux ping` from remote session | DONE |
| C-002 | `cmux list-workspaces --json` from remote | DONE |
| C-003 | `cmux new-workspace` from remote | DONE |
| C-004 | `cmux rpc system.capabilities` passthrough | DONE |
| C-005 | TCP retry handles relay not yet established | DONE |
| C-006 | multi-workspace port conflict silent skip | DONE |
| C-007 | ephemeral port filtering excludes relay ports | DONE |
### 7.3 Browser Proxy (Target)
| ID | Scenario | Status |
|---|---|---|
| W-001 | remote workspace browser auto-proxied | DONE |
| W-002 | browser egress equals remote network path | DONE |
| W-003 | websocket via SOCKS5/CONNECT through remote daemon | DONE |
| W-004 | reconnect restores browser proxy path automatically | DONE |
| W-005 | local proxy bind conflict yields structured `proxy_unavailable` | DONE |
| W-006 | proxy transport failure triggers daemon re-bootstrap and recovers after host recreation | DONE |
| W-007 | SOCKS greeting/connect + immediate pipelined payload in same write remains intact | DONE |
### 7.4 Resize
| ID | Scenario | Status |
|---|---|---|
| RZ-001 | two attachments, smallest wins | DONE |
| RZ-002 | grow one attachment, PTY stays bounded by smallest | DONE |
| RZ-003 | detach smallest, PTY expands to next smallest | DONE |
| RZ-004 | reconnect preserves session + applies recomputed size | DONE |
| RZ-005 | daemon stdio RPC round-trip enforces resize semantics end-to-end | DONE |
## 8. Removal Checklist (Port Mirroring)
Before declaring browser proxying complete:
1. `DONE` remove remote port probe loop and `-L` auto-forward orchestration
2. `DONE` remove mirror-specific routing behavior as default remote behavior
3. `DONE` replace mirroring docker assertions with proxy egress assertions
4. `DONE` keep optional explicit user-driven forwarding out of this path; no automatic mirroring remains in browser routing
## 9. Open Decisions
1. Proxy auth policy for local broker (`none` vs optional credentials).
2. Reconnect backoff profile and max retry budget.
## 10. Socket API Contract Notes
### 10.1 `workspace.remote.configure` Port Fields
1. `port` and `local_proxy_port` accept integer values and numeric strings.
2. Explicit `null` clears each field.
3. Out-of-range values and invalid types (for example booleans/non-numeric strings/fractional numbers) return `invalid_params`.
4. `local_proxy_port` is an internal deterministic test hook to force local bind conflicts in regression coverage.
### 10.2 SSH Option Precedence
1. `StrictHostKeyChecking` default (`accept-new`) is only injected when no user override is present.
2. Control-socket defaults (`ControlMaster`, `ControlPersist`, `ControlPath`) are only injected when missing.
3. SSH option key matching is case-insensitive for precedence checks in both CLI-built commands and remote configure payloads.
### 10.3 SSH Docker E2E Harness Knobs
1. `CMUX_SSH_TEST_DOCKER_HOST` sets the SSH destination host/IP used by docker-backed SSH fixtures (default `127.0.0.1`).
2. `CMUX_SSH_TEST_DOCKER_BIND_ADDR` sets the bind address used in fixture container publish mappings (default `127.0.0.1`).
3. Defaults preserve loopback behavior on a single host; override both when docker runs on a different host (for example VM -> host OrbStack).

View file

@ -1,140 +0,0 @@
#!/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"
rm -f "$OUTPUT_DIR"/cmuxd-remote-* "$OUTPUT_DIR"/cmuxd-remote-checksums.txt "$OUTPUT_DIR"/cmuxd-remote-manifest.json
RELEASE_URL="https://github.com/${REPO}/releases/download/${RELEASE_TAG}"
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"
)
declare -a manifest_entries=()
: > "$CHECKSUMS_PATH"
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 build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \
-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"
manifest_entries+=("{\"goOS\":\"${GOOS}\",\"goArch\":\"${GOARCH}\",\"assetName\":\"${ASSET_NAME}\",\"downloadURL\":\"${RELEASE_URL}/${ASSET_NAME}\",\"sha256\":\"${SHA256}\"}")
done
ENTRIES_FILE="$(mktemp "${TMPDIR:-/tmp}/cmuxd-remote-entries.XXXXXX")"
trap 'rm -f "$ENTRIES_FILE"' EXIT
printf '%s\n' "${manifest_entries[@]}" > "$ENTRIES_FILE"
ENTRIES_JSON="$(python3 - <<'PY' "$ENTRIES_FILE"
import json
import sys
from pathlib import Path
entries = [json.loads(line) for line in Path(sys.argv[1]).read_text(encoding="utf-8").splitlines() if line.strip()]
print(json.dumps(entries, separators=(",", ":")))
PY
)"
python3 - <<'PY' "$VERSION" "$RELEASE_TAG" "$RELEASE_URL" "$CHECKSUMS_ASSET_NAME" "$CHECKSUMS_PATH" "$MANIFEST_PATH" "$ENTRIES_JSON"
import json
import sys
from pathlib import Path
version, release_tag, release_url, checksums_asset_name, checksums_path, manifest_path, entries_json = sys.argv[1:]
checksums_url = f"{release_url}/{checksums_asset_name}"
manifest = {
"schemaVersion": 1,
"appVersion": version,
"releaseTag": release_tag,
"releaseURL": release_url,
"checksumsAssetName": checksums_asset_name,
"checksumsURL": checksums_url,
"entries": json.loads(entries_json),
}
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,5 +3,4 @@
# Format: <ghostty_sha> <sha256>
7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207
a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d
c47010b80cd9ae6d1ab744c120f011a465521ea3 d6904870a3c920b2787b1c4b950cfdef232606bb9876964f5e8497081d5cb5df
0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de

View file

@ -1,15 +1,6 @@
"use strict";
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 IMMUTABLE_RELEASE_ASSETS = ["cmux-macos.dmg", "appcast.xml"];
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: [...IMMUTABLE_RELEASE_ASSETS, "notes.txt"],
existingAssetNames: ["cmux-macos.dmg", "appcast.xml", "notes.txt"],
});
assert.deepEqual(result.conflicts, IMMUTABLE_RELEASE_ASSETS);
@ -36,16 +36,12 @@ 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: partialAssets,
existingAssetNames: ["appcast.xml"],
});
assert.deepEqual(result.conflicts, partialAssets);
assert.deepEqual(
result.missingImmutableAssets,
IMMUTABLE_RELEASE_ASSETS.filter((assetName) => !partialAssets.includes(assetName)),
);
assert.deepEqual(result.conflicts, ["appcast.xml"]);
assert.deepEqual(result.missingImmutableAssets, ["cmux-macos.dmg"]);
assert.equal(result.guardState, RELEASE_ASSET_GUARD_STATE.PARTIAL);
assert.equal(result.hasPartialConflict, true);
assert.equal(result.shouldSkipBuildAndUpload, false);

View file

@ -10,85 +10,6 @@ BUNDLE_SET=0
DERIVED_SET=0
TAG=""
CMUX_DEBUG_LOG=""
CLI_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
}
usage() {
cat <<'EOF'
@ -358,10 +279,6 @@ 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_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
@ -377,21 +294,6 @@ 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
@ -423,8 +325,6 @@ 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
@ -445,11 +345,10 @@ 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" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH"
"${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"
elif [[ -n "${TAG_SLUG:-}" ]]; then
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 CMUXTERM_REPO_ROOT="$PWD" open -g "$APP_PATH"
"${OPEN_CLEAN_ENV[@]}" CMUX_TAG="$TAG_SLUG" CMUX_DEBUG_LOG="$CMUX_DEBUG_LOG" 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
@ -477,16 +376,3 @@ 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

View file

@ -1,20 +0,0 @@
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"]

View file

@ -1,38 +0,0 @@
#!/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

View file

@ -1,31 +0,0 @@
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

View file

@ -1,132 +0,0 @@
#!/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) -> bytes:
data = bytearray()
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) -> bytes:
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")
return bytes(data)
def _read_frame(conn: socket.socket) -> tuple[int, bytes]:
first, second = _recv_exact(conn, 2)
opcode = first & 0x0F
masked = (second & 0x80) != 0
length = second & 0x7F
if length == 126:
length = struct.unpack("!H", _recv_exact(conn, 2))[0]
elif length == 127:
length = struct.unpack("!Q", _recv_exact(conn, 8))[0]
mask_key = _recv_exact(conn, 4) if masked else b""
payload = _recv_exact(conn, length) 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 = _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)
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

@ -32,17 +32,13 @@ def resolve_cmux_cli() -> str:
raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.")
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"
def run(cli_path: str, *args: str) -> tuple[int, str, str]:
proc = subprocess.run(
[cli_path, *args],
text=True,
capture_output=True,
check=False,
)
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()

View file

@ -1,65 +0,0 @@
#!/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

@ -1,79 +0,0 @@
#!/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

@ -1,100 +0,0 @@
#!/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_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

@ -1,304 +0,0 @@
#!/usr/bin/env python3
"""Regression: `ls` output remains in scrollback after pane.resize."""
from __future__ import annotations
import os
import re
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
DEFAULT_SOCKET_PATHS = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"]
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 _scrollback_has_exact_line(client: cmux, workspace_id: str, surface_id: str, token: str) -> bool:
text = _surface_scrollback_text(client, workspace_id, surface_id)
lines = [_clean_line(raw) for raw in text.splitlines()]
return token in lines
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 _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 _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]:
panes = [p for p in _layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids]
if len(panes) < 2:
raise cmuxError(f"Need >=2 panes for resize test, got {panes}")
def x_of(p: dict) -> float:
return float((p.get("frame") or {}).get("x") or 0.0)
def y_of(p: dict) -> float:
return float((p.get("frame") or {}).get("y") or 0.0)
x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes)
y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes)
if x_span >= y_span:
left_pane = min(panes, key=x_of)
left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "")
return ("right" if target_pane == left_id else "left"), "width"
top_pane = min(panes, key=y_of)
top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "")
return ("down" if target_pane == top_id else "up"), "height"
def _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

@ -1,263 +0,0 @@
#!/usr/bin/env python3
"""Regression: pane.resize preserves terminal content drawn before resize."""
from __future__ import annotations
import os
import re
import secrets
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
DEFAULT_SOCKET_PATHS = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"]
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"
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,6 +55,14 @@ 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)
@ -74,7 +82,7 @@ def main() -> int:
_must(bool(surface_id), f"surface.current returned no surface_id: {current}")
socket_title = f"socket rename {stamp}"
socket_payload = c._call(
c._call(
"tab.action",
{
"workspace_id": ws_id,
@ -83,20 +91,14 @@ def main() -> int:
"title": socket_title,
},
)
_must(
str((socket_payload or {}).get("title") or "") == socket_title,
f"tab.action rename response missing requested title: {socket_payload}",
)
_must(_surface_title(c, ws_id, surface_id) == socket_title, "tab.action rename did not update tab title")
cli_title = f"cli rename {stamp}"
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}",
)
_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")
env_title = f"env rename {stamp}"
env_out = _run_cli(
_run_cli(
cli,
["rename-tab", env_title],
env={
@ -104,10 +106,7 @@ def main() -> int:
"CMUX_TAB_ID": surface_id,
},
)
_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}",
)
_must(_surface_title(c, ws_id, surface_id) == env_title, "rename-tab via CMUX_TAB_ID did not update tab title")
invalid = subprocess.run(
[cli, "--socket", SOCKET_PATH, "rename-tab", "--workspace", ws_id],

View file

@ -1,297 +0,0 @@
#!/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

@ -1,630 +0,0 @@
#!/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 _has_ssh_option_key(options: list[str], key: str) -> bool:
lowered_key = key.lower()
for option in options:
token = re.split(r"[=\s]+", str(option).strip(), maxsplit=1)[0].strip().lower()
if token == lowered_key:
return True
return False
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(
(
f"RemoteCommand=export PATH=\"$HOME/.cmux/bin:$PATH\"; "
f"export CMUX_SOCKET_PATH={remote_socket_addr}; "
"exec \"${SHELL:-/bin/zsh}\" -l"
) in ssh_command,
f"cmux ssh should use -o RemoteCommand for PATH/bootstrap env pinning (not positional command): {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}",
)
remote_ssh_options = [str(item) for item in (remote.get("ssh_options") or [])]
_must(
_has_ssh_option_key(remote_ssh_options, "ControlMaster"),
f"workspace.remote.configure should include ControlMaster default: {remote}",
)
_must(
_has_ssh_option_key(remote_ssh_options, "ControlPersist"),
f"workspace.remote.configure should include ControlPersist default: {remote}",
)
_must(
_has_ssh_option_key(remote_ssh_options, "ControlPath"),
f"workspace.remote.configure should include ControlPath default: {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 {}
strict_override_options = [str(item) for item in (strict_override_remote.get("ssh_options") or [])]
_must(
any(item.lower() == "stricthostkeychecking=no" for item in strict_override_options),
f"workspace.remote.configure should preserve explicit StrictHostKeyChecking override: {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 {}
case_override_options = [str(item) for item in (case_override_remote.get("ssh_options") or [])]
_must(
any(item.lower() == "stricthostkeychecking=no" for item in case_override_options),
f"workspace.remote.configure should preserve lowercase StrictHostKeyChecking override: {case_override_remote}",
)
_must(
not any(item.lower() == "stricthostkeychecking=accept-new" for item in case_override_options),
f"workspace.remote.configure should not inject default StrictHostKeyChecking when lowercase override is supplied: {case_override_remote}",
)
_must(
any(item.lower() == "controlmaster=no" for item in case_override_options),
f"workspace.remote.configure should preserve lowercase ControlMaster override: {case_override_remote}",
)
_must(
not any(item.lower() == "controlmaster=auto" for item in case_override_options),
f"workspace.remote.configure should not inject default ControlMaster when lowercase override is supplied: {case_override_remote}",
)
_must(
any(item.lower() == "controlpersist=0" for item in case_override_options),
f"workspace.remote.configure should preserve lowercase ControlPersist override: {case_override_remote}",
)
_must(
not any(item.lower() == "controlpersist=600" for item in case_override_options),
f"workspace.remote.configure should not inject default ControlPersist when lowercase override is supplied: {case_override_remote}",
)
_must(
any(item.lower() == "controlpath=/tmp/cmux-ssh-%c-custom" for item in case_override_options),
f"workspace.remote.configure should preserve lowercase ControlPath override: {case_override_remote}",
)
_must(
sum(1 for item in case_override_options if item.lower().startswith("controlpath=")) == 1,
f"workspace.remote.configure should include exactly one ControlPath when lowercase override is supplied: {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", {"initial_command": "echo invalid-local-proxy-port"}) 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

@ -1,392 +0,0 @@
#!/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(49152 <= remote_relay_port <= 65535, f"remote_relay_port should be in ephemeral range: {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(49152 <= remote_relay_port_2 <= 65535, f"second remote_relay_port out of range: {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

@ -1,188 +0,0 @@
#!/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):
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

@ -1,258 +0,0 @@
#!/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

@ -1,742 +0,0 @@
#!/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

@ -1,612 +0,0 @@
#!/usr/bin/env python3
"""Docker integration: remote SSH reconnect after host restart."""
from __future__ import annotations
import glob
import hashlib
import json
import os
import secrets
import shutil
import socket
import struct
import subprocess
import sys
import tempfile
import time
from base64 import b64encode
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
REMOTE_HTTP_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_HTTP_PORT", "43173"))
REMOTE_WS_PORT = int(os.environ.get("CMUX_SSH_TEST_REMOTE_WS_PORT", "43174"))
DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1")
DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
if check and proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
return proc
def _run_cli_json(cli: str, args: list[str]) -> dict:
env = dict(os.environ)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
env.pop("CMUX_TAB_ID", None)
proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env)
try:
return json.loads(proc.stdout or "{}")
except Exception as exc: # noqa: BLE001
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
def _docker_available() -> bool:
if shutil.which("docker") is None:
return False
probe = _run(["docker", "info"], check=False)
return probe.returncode == 0
def _curl_via_socks(proxy_port: int, target_url: str) -> str:
if shutil.which("curl") is None:
raise cmuxError("curl is required for SOCKS proxy verification")
proc = _run(
[
"curl",
"--silent",
"--show-error",
"--max-time",
"5",
"--socks5-hostname",
f"127.0.0.1:{proxy_port}",
target_url,
],
check=False,
)
if proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"curl via SOCKS proxy failed: {merged}")
return proc.stdout
def _find_free_loopback_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _recv_exact(sock: socket.socket, n: int) -> bytes:
out = bytearray()
while len(out) < n:
chunk = sock.recv(n - len(out))
if not chunk:
raise cmuxError("unexpected EOF while reading socket")
out.extend(chunk)
return bytes(out)
def _recv_until(sock: socket.socket, marker: bytes, limit: int = 16384) -> bytes:
out = bytearray()
while marker not in out:
chunk = sock.recv(1024)
if not chunk:
raise cmuxError("unexpected EOF while reading response headers")
out.extend(chunk)
if len(out) > limit:
raise cmuxError("response headers too large")
return bytes(out)
def _read_socks5_connect_reply(sock: socket.socket) -> None:
head = _recv_exact(sock, 4)
if len(head) != 4 or head[0] != 0x05:
raise cmuxError(f"invalid SOCKS5 reply: {head!r}")
if head[1] != 0x00:
raise cmuxError(f"SOCKS5 connect failed with status=0x{head[1]:02x}")
reply_atyp = head[3]
if reply_atyp == 0x01:
_ = _recv_exact(sock, 4)
elif reply_atyp == 0x03:
ln = _recv_exact(sock, 1)[0]
_ = _recv_exact(sock, ln)
elif reply_atyp == 0x04:
_ = _recv_exact(sock, 16)
else:
raise cmuxError(f"invalid SOCKS5 atyp in reply: 0x{reply_atyp:02x}")
_ = _recv_exact(sock, 2)
def _read_http_response_from_connected_socket(sock: socket.socket) -> str:
response = _recv_until(sock, b"\r\n\r\n")
header_end = response.index(b"\r\n\r\n") + 4
header_blob = response[:header_end]
body = bytearray(response[header_end:])
header_text = header_blob.decode("utf-8", errors="replace")
status_line = header_text.split("\r\n", 1)[0]
if "200" not in status_line:
raise cmuxError(f"HTTP over SOCKS tunnel failed: {status_line!r}")
content_length: int | None = None
for line in header_text.split("\r\n")[1:]:
if line.lower().startswith("content-length:"):
try:
content_length = int(line.split(":", 1)[1].strip())
except Exception: # noqa: BLE001
content_length = None
break
if content_length is not None:
while len(body) < content_length:
chunk = sock.recv(4096)
if not chunk:
break
body.extend(chunk)
else:
while True:
try:
chunk = sock.recv(4096)
except socket.timeout:
break
if not chunk:
break
body.extend(chunk)
return bytes(body).decode("utf-8", errors="replace")
def _socks5_connect(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket:
sock = socket.create_connection((proxy_host, proxy_port), timeout=6)
sock.settimeout(6)
sock.sendall(b"\x05\x01\x00")
greeting = _recv_exact(sock, 2)
if greeting != b"\x05\x00":
sock.close()
raise cmuxError(f"SOCKS5 greeting failed: {greeting!r}")
try:
host_bytes = socket.inet_aton(target_host)
atyp = b"\x01"
addr = host_bytes
except OSError:
host_encoded = target_host.encode("utf-8")
if len(host_encoded) > 255:
sock.close()
raise cmuxError("target host too long for SOCKS5 domain form")
atyp = b"\x03"
addr = bytes([len(host_encoded)]) + host_encoded
req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port)
sock.sendall(req)
try:
_read_socks5_connect_reply(sock)
except Exception:
sock.close()
raise
return sock
def _socks5_http_get_pipelined(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> str:
sock = socket.create_connection((proxy_host, proxy_port), timeout=6)
sock.settimeout(6)
try:
try:
host_bytes = socket.inet_aton(target_host)
atyp = b"\x01"
addr = host_bytes
except OSError:
host_encoded = target_host.encode("utf-8")
if len(host_encoded) > 255:
raise cmuxError("target host too long for SOCKS5 domain form")
atyp = b"\x03"
addr = bytes([len(host_encoded)]) + host_encoded
greeting = b"\x05\x01\x00"
connect_req = b"\x05\x01\x00" + atyp + addr + struct.pack("!H", target_port)
http_get = (
"GET / HTTP/1.1\r\n"
f"Host: {target_host}:{target_port}\r\n"
"Connection: close\r\n"
"\r\n"
).encode("utf-8")
sock.sendall(greeting + connect_req + http_get)
greeting_reply = _recv_exact(sock, 2)
if greeting_reply != b"\x05\x00":
raise cmuxError(f"SOCKS5 greeting failed: {greeting_reply!r}")
_read_socks5_connect_reply(sock)
return _read_http_response_from_connected_socket(sock)
finally:
try:
sock.close()
except Exception:
pass
def _http_connect_tunnel(proxy_host: str, proxy_port: int, target_host: str, target_port: int) -> socket.socket:
sock = socket.create_connection((proxy_host, proxy_port), timeout=6)
sock.settimeout(6)
request = (
f"CONNECT {target_host}:{target_port} HTTP/1.1\r\n"
f"Host: {target_host}:{target_port}\r\n"
"Proxy-Connection: Keep-Alive\r\n"
"\r\n"
).encode("utf-8")
sock.sendall(request)
header_blob = _recv_until(sock, b"\r\n\r\n")
header_text = header_blob.decode("utf-8", errors="replace")
status_line = header_text.split("\r\n", 1)[0]
if "200" not in status_line:
sock.close()
raise cmuxError(f"HTTP CONNECT tunnel failed: {status_line!r}")
return sock
def _encode_client_text_frame(payload: str) -> bytes:
data = payload.encode("utf-8")
first = 0x81
mask = secrets.token_bytes(4)
length = len(data)
if length < 126:
header = bytes([first, 0x80 | length])
elif length <= 0xFFFF:
header = bytes([first, 0x80 | 126]) + struct.pack("!H", length)
else:
header = bytes([first, 0x80 | 127]) + struct.pack("!Q", length)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
return header + mask + masked
def _read_server_text_frame(sock: socket.socket) -> str:
first, second = _recv_exact(sock, 2)
opcode = first & 0x0F
masked = (second & 0x80) != 0
length = second & 0x7F
if length == 126:
length = struct.unpack("!H", _recv_exact(sock, 2))[0]
elif length == 127:
length = struct.unpack("!Q", _recv_exact(sock, 8))[0]
mask = _recv_exact(sock, 4) if masked else b""
payload = _recv_exact(sock, length) if length else b""
if masked and payload:
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
if opcode != 0x1:
raise cmuxError(f"Expected websocket text frame opcode=0x1, got opcode=0x{opcode:x}")
try:
return payload.decode("utf-8")
except Exception as exc: # noqa: BLE001
raise cmuxError(f"WebSocket response payload is not valid UTF-8: {exc}")
def _websocket_echo_on_connected_socket(sock: socket.socket, ws_host: str, ws_port: int, message: str, path_label: str) -> str:
ws_key = b64encode(secrets.token_bytes(16)).decode("ascii")
request = (
"GET /echo HTTP/1.1\r\n"
f"Host: {ws_host}:{ws_port}\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {ws_key}\r\n"
"Sec-WebSocket-Version: 13\r\n"
"\r\n"
).encode("utf-8")
sock.sendall(request)
header_blob = _recv_until(sock, b"\r\n\r\n")
header_text = header_blob.decode("utf-8", errors="replace")
status_line = header_text.split("\r\n", 1)[0]
if "101" not in status_line:
raise cmuxError(f"WebSocket handshake failed over {path_label}: {status_line!r}")
expected_accept = b64encode(
hashlib.sha1((ws_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("utf-8")).digest()
).decode("ascii")
lowered_headers = {
line.split(":", 1)[0].strip().lower(): line.split(":", 1)[1].strip()
for line in header_text.split("\r\n")[1:]
if ":" in line
}
if lowered_headers.get("sec-websocket-accept", "") != expected_accept:
raise cmuxError(f"WebSocket handshake over {path_label} returned invalid Sec-WebSocket-Accept")
sock.sendall(_encode_client_text_frame(message))
return _read_server_text_frame(sock)
def _websocket_echo_via_socks(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str:
sock = _socks5_connect("127.0.0.1", proxy_port, ws_host, ws_port)
try:
return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "SOCKS proxy")
finally:
try:
sock.close()
except Exception:
pass
def _websocket_echo_via_connect(proxy_port: int, ws_host: str, ws_port: int, message: str) -> str:
sock = _http_connect_tunnel("127.0.0.1", proxy_port, ws_host, ws_port)
try:
return _websocket_echo_on_connected_socket(sock, ws_host, ws_port, message, "HTTP CONNECT proxy")
finally:
try:
sock.close()
except Exception:
pass
def _start_container(image_tag: str, container_name: str, pubkey: str, host_ssh_port: int) -> None:
for _ in range(20):
proc = _run(
[
"docker",
"run",
"-d",
"--rm",
"--name",
container_name,
"-e",
f"AUTHORIZED_KEY={pubkey}",
"-e",
f"REMOTE_HTTP_PORT={REMOTE_HTTP_PORT}",
"-e",
f"REMOTE_WS_PORT={REMOTE_WS_PORT}",
"-p",
f"{DOCKER_PUBLISH_ADDR}:{host_ssh_port}:22",
image_tag,
],
check=False,
)
if proc.returncode == 0:
return
time.sleep(0.5)
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"Failed to start ssh test container on fixed port {host_ssh_port}: {merged}")
def _wait_remote_connected(client: cmux, workspace_id: str, timeout: float) -> dict:
deadline = time.time() + timeout
last_status = {}
while time.time() < deadline:
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
remote = last_status.get("remote") or {}
proxy = remote.get("proxy") or {}
port_value = proxy.get("port")
proxy_port: int | None
if isinstance(port_value, int):
proxy_port = port_value
elif isinstance(port_value, str) and port_value.isdigit():
proxy_port = int(port_value)
else:
proxy_port = None
if str(remote.get("state") or "") == "connected" and proxy_port is not None:
return last_status
time.sleep(0.5)
raise cmuxError(f"Remote did not reach connected+proxy-ready state: {last_status}")
def _wait_remote_degraded(client: cmux, workspace_id: str, timeout: float) -> dict:
deadline = time.time() + timeout
last_status = {}
while time.time() < deadline:
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
remote = last_status.get("remote") or {}
state = str(remote.get("state") or "")
if state in {"error", "connecting", "disconnected"}:
return last_status
time.sleep(0.5)
raise cmuxError(f"Remote did not enter reconnecting/degraded state: {last_status}")
def main() -> int:
if not _docker_available():
print("SKIP: docker is not available")
return 0
cli = _find_cli_binary()
repo_root = Path(__file__).resolve().parents[1]
fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote"
_must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}")
temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-reconnect-"))
image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}"
container_name = f"cmux-ssh-reconnect-{secrets.token_hex(4)}"
host_ssh_port = _find_free_loopback_port()
workspace_id = ""
container_running = False
try:
key_path = temp_dir / "id_ed25519"
_run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)])
pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip()
_must(bool(pubkey), "Generated SSH public key was empty")
_run(["docker", "build", "-t", image_tag, str(fixture_dir)])
_start_container(image_tag, container_name, pubkey, host_ssh_port)
container_running = True
with cmux(SOCKET_PATH) as client:
payload = _run_cli_json(
cli,
[
"ssh",
f"root@{DOCKER_SSH_HOST}",
"--name",
"docker-ssh-reconnect",
"--port",
str(host_ssh_port),
"--identity",
str(key_path),
"--ssh-option",
"UserKnownHostsFile=/dev/null",
"--ssh-option",
"StrictHostKeyChecking=no",
],
)
workspace_id = str(payload.get("workspace_id") or "")
workspace_ref = str(payload.get("workspace_ref") or "")
if not workspace_id and workspace_ref.startswith("workspace:"):
listed = client._call("workspace.list", {}) or {}
for row in listed.get("workspaces") or []:
if str(row.get("ref") or "") == workspace_ref:
workspace_id = str(row.get("id") or "")
break
_must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}")
first_status = _wait_remote_connected(client, workspace_id, timeout=45.0)
first_daemon = ((first_status.get("remote") or {}).get("daemon") or {})
_must(str(first_daemon.get("state") or "") == "ready", f"daemon should be ready after first connect: {first_status}")
first_capabilities = {str(item) for item in (first_daemon.get("capabilities") or [])}
_must("proxy.stream" in first_capabilities, f"daemon should advertise proxy.stream: {first_status}")
_must("proxy.socks5" in first_capabilities, f"daemon should advertise proxy.socks5: {first_status}")
_must("proxy.http_connect" in first_capabilities, f"daemon should advertise proxy.http_connect: {first_status}")
first_proxy = ((first_status.get("remote") or {}).get("proxy") or {})
first_proxy_port = first_proxy.get("port")
if isinstance(first_proxy_port, str) and first_proxy_port.isdigit():
first_proxy_port = int(first_proxy_port)
_must(isinstance(first_proxy_port, int), f"connected status should include proxy port: {first_status}")
first_body = ""
first_deadline_http = time.time() + 15.0
while time.time() < first_deadline_http:
try:
first_body = _curl_via_socks(int(first_proxy_port), f"http://127.0.0.1:{REMOTE_HTTP_PORT}/")
except Exception:
time.sleep(0.5)
continue
if "cmux-ssh-forward-ok" in first_body:
break
time.sleep(0.3)
_must("cmux-ssh-forward-ok" in first_body, f"Forwarded HTTP endpoint failed before reconnect: {first_body[:120]!r}")
first_pipelined_body = _socks5_http_get_pipelined("127.0.0.1", int(first_proxy_port), "127.0.0.1", REMOTE_HTTP_PORT)
_must(
"cmux-ssh-forward-ok" in first_pipelined_body,
f"SOCKS pipelined greeting/connect+payload failed before reconnect: {first_pipelined_body[:120]!r}",
)
first_ws_socks_message = "cmux-reconnect-before-over-socks"
echoed_before_socks = _websocket_echo_via_socks(int(first_proxy_port), "127.0.0.1", REMOTE_WS_PORT, first_ws_socks_message)
_must(
echoed_before_socks == first_ws_socks_message,
f"WebSocket echo over SOCKS proxy failed before reconnect: {echoed_before_socks!r} != {first_ws_socks_message!r}",
)
first_ws_connect_message = "cmux-reconnect-before-over-connect"
echoed_before_connect = _websocket_echo_via_connect(int(first_proxy_port), "127.0.0.1", REMOTE_WS_PORT, first_ws_connect_message)
_must(
echoed_before_connect == first_ws_connect_message,
f"WebSocket echo over CONNECT proxy failed before reconnect: {echoed_before_connect!r} != {first_ws_connect_message!r}",
)
_run(["docker", "rm", "-f", container_name], check=False)
container_running = False
_wait_remote_degraded(client, workspace_id, timeout=20.0)
_start_container(image_tag, container_name, pubkey, host_ssh_port)
container_running = True
second_status = _wait_remote_connected(client, workspace_id, timeout=60.0)
second_daemon = ((second_status.get("remote") or {}).get("daemon") or {})
_must(str(second_daemon.get("state") or "") == "ready", f"daemon should be ready after reconnect: {second_status}")
second_capabilities = {str(item) for item in (second_daemon.get("capabilities") or [])}
_must("proxy.stream" in second_capabilities, f"daemon should advertise proxy.stream after reconnect: {second_status}")
_must("proxy.socks5" in second_capabilities, f"daemon should advertise proxy.socks5 after reconnect: {second_status}")
_must("proxy.http_connect" in second_capabilities, f"daemon should advertise proxy.http_connect after reconnect: {second_status}")
second_proxy = ((second_status.get("remote") or {}).get("proxy") or {})
second_proxy_port = second_proxy.get("port")
if isinstance(second_proxy_port, str) and second_proxy_port.isdigit():
second_proxy_port = int(second_proxy_port)
_must(isinstance(second_proxy_port, int), f"reconnected status should include proxy port: {second_status}")
second_body = ""
deadline_http = time.time() + 15.0
while time.time() < deadline_http:
try:
second_body = _curl_via_socks(int(second_proxy_port), f"http://127.0.0.1:{REMOTE_HTTP_PORT}/")
except Exception:
time.sleep(0.5)
continue
if "cmux-ssh-forward-ok" in second_body:
break
time.sleep(0.3)
_must("cmux-ssh-forward-ok" in second_body, f"Forwarded HTTP endpoint failed after reconnect: {second_body[:120]!r}")
second_pipelined_body = _socks5_http_get_pipelined("127.0.0.1", int(second_proxy_port), "127.0.0.1", REMOTE_HTTP_PORT)
_must(
"cmux-ssh-forward-ok" in second_pipelined_body,
f"SOCKS pipelined greeting/connect+payload failed after reconnect: {second_pipelined_body[:120]!r}",
)
second_ws_socks_message = "cmux-reconnect-after-over-socks"
echoed_after_socks = _websocket_echo_via_socks(int(second_proxy_port), "127.0.0.1", REMOTE_WS_PORT, second_ws_socks_message)
_must(
echoed_after_socks == second_ws_socks_message,
f"WebSocket echo over SOCKS proxy failed after reconnect: {echoed_after_socks!r} != {second_ws_socks_message!r}",
)
second_ws_connect_message = "cmux-reconnect-after-over-connect"
echoed_after_connect = _websocket_echo_via_connect(int(second_proxy_port), "127.0.0.1", REMOTE_WS_PORT, second_ws_connect_message)
_must(
echoed_after_connect == second_ws_connect_message,
f"WebSocket echo over CONNECT proxy failed after reconnect: {echoed_after_connect!r} != {second_ws_connect_message!r}",
)
try:
client.close_workspace(workspace_id)
except Exception:
pass
workspace_id = ""
print("PASS: docker SSH remote reconnects and re-establishes HTTP + WebSocket egress over SOCKS and CONNECT")
return 0
finally:
if workspace_id:
try:
with cmux(SOCKET_PATH) as cleanup_client:
cleanup_client.close_workspace(workspace_id)
except Exception:
pass
if container_running:
_run(["docker", "rm", "-f", container_name], check=False)
_run(["docker", "rmi", "-f", image_tag], check=False)
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,249 +0,0 @@
#!/usr/bin/env python3
"""Regression: interactive `cmux ssh` shells must resolve `cmux` to the relay wrapper."""
from __future__ import annotations
import glob
import json
import os
import re
import secrets
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip()
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_cli_json(cli: str, args: list[str]) -> dict:
env = dict(os.environ)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
env.pop("CMUX_TAB_ID", None)
import subprocess
proc = subprocess.run(
[cli, "--socket", SOCKET_PATH, "--json", *args],
capture_output=True,
text=True,
check=False,
env=env,
)
if proc.returncode != 0:
raise cmuxError(f"CLI failed ({' '.join(args)}): {(proc.stdout + proc.stderr).strip()}")
try:
return json.loads(proc.stdout or "{}")
except Exception as exc: # noqa: BLE001
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
def _workspace_id_from_payload(client: cmux, payload: dict) -> str:
workspace_id = str(payload.get("workspace_id") or "")
if workspace_id:
return workspace_id
workspace_ref = str(payload.get("workspace_ref") or "")
if workspace_ref.startswith("workspace:"):
rows = (client._call("workspace.list", {}) or {}).get("workspaces") or []
for row in rows:
if str(row.get("ref") or "") == workspace_ref:
return str(row.get("id") or "")
return ""
def _wait_remote_ready(client: cmux, workspace_id: str, timeout: float = 25.0) -> None:
deadline = time.time() + timeout
last_status = {}
while time.time() < deadline:
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
remote = last_status.get("remote") or {}
daemon = remote.get("daemon") or {}
if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready":
return
time.sleep(0.25)
raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}")
def _wait_surface_id(client: cmux, workspace_id: str, timeout: float = 10.0) -> str:
deadline = time.time() + timeout
while time.time() < deadline:
surfaces = client.list_surfaces(workspace_id)
if surfaces:
return str(surfaces[0][1])
time.sleep(0.1)
raise cmuxError(f"No terminal surface appeared for workspace {workspace_id}")
def _wait_text(client: cmux, surface_id: str, token: str, timeout: float = 12.0) -> str:
deadline = time.time() + timeout
last = ""
while time.time() < deadline:
last = client.read_terminal_text(surface_id)
if token in last:
return last
time.sleep(0.15)
raise cmuxError(f"Timed out waiting for {token!r} in surface {surface_id}: {last[-1200:]!r}")
def _wait_shell_ready(client: cmux, surface_id: str, timeout: float = 20.0) -> None:
token = f"__CMUX_SHELL_READY_{secrets.token_hex(6)}__"
client.send_surface(surface_id, f"printf '{token}'; echo")
client.send_key_surface(surface_id, "enter")
_wait_text(client, surface_id, token, timeout=timeout)
def _run_remote_shell_command(client: cmux, surface_id: str, command: str, timeout: float = 12.0) -> tuple[int, str, str]:
token = f"__CMUX_REMOTE_CMD_{secrets.token_hex(6)}__"
start_marker = f"{token}:START"
status_marker = f"{token}:STATUS"
end_marker = f"{token}:END"
client.send_surface(
surface_id,
(
f"printf '{start_marker}'; echo; "
f"{command}; "
"__cmux_status=$?; "
f"printf '{status_marker}:%s' \"$__cmux_status\"; echo; "
f"printf '{end_marker}'; echo"
),
)
client.send_key_surface(surface_id, "enter")
deadline = time.time() + timeout
text = ""
while time.time() < deadline:
text = client.read_terminal_text(surface_id)
if (
text.count(start_marker) >= 2
and text.count(status_marker) >= 2
and text.count(end_marker) >= 2
):
break
time.sleep(0.15)
pattern = re.compile(
re.escape(start_marker) + r"\n(.*?)" + re.escape(status_marker) + r":(\d+)\n" + re.escape(end_marker),
re.S,
)
matches = pattern.findall(text)
if not matches:
raise cmuxError(f"Missing command result token for {command!r}: {text[-1200:]!r}")
output, status_raw = matches[-1]
return int(status_raw), output, text
def main() -> int:
if not SSH_HOST:
print("SKIP: set CMUX_SSH_TEST_HOST to run interactive ssh cmux command regression")
return 0
cli = _find_cli_binary()
workspace_ids: list[str] = []
try:
with cmux(SOCKET_PATH) as client:
payload = _run_cli_json(cli, ["ssh", SSH_HOST])
workspace_id = _workspace_id_from_payload(client, payload)
_must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}")
workspace_ids.append(workspace_id)
_wait_remote_ready(client, workspace_id)
surface_id = _wait_surface_id(client, workspace_id)
_wait_shell_ready(client, surface_id)
which_status, which_output, which_text = _run_remote_shell_command(client, surface_id, "command -v cmux")
_must(which_status == 0, f"`command -v cmux` failed: output={which_output!r} tail={which_text[-1200:]!r}")
_must(
"/.cmux/bin/cmux" in which_output,
f"interactive ssh shell should resolve cmux to relay wrapper, got {which_output!r}",
)
ping_status, ping_output, ping_text = _run_remote_shell_command(client, surface_id, "cmux ping")
_must(ping_status == 0, f"`cmux ping` failed in interactive shell: output={ping_output!r} tail={ping_text[-1200:]!r}")
_must("pong" in ping_output.lower(), f"`cmux ping` should return pong, got {ping_output!r}")
_must(
"Socket not found at 127.0.0.1:" not in ping_text,
f"interactive ssh shell still routed cmux to a unix-socket-only binary: {ping_text[-1200:]!r}",
)
_must(
"waiting for relay on 127.0.0.1:" not in ping_text and "failed to connect to 127.0.0.1:" not in ping_text,
f"`cmux ping` hit a dead ssh relay instead of the local app socket: {ping_text[-1200:]!r}",
)
notify_status, notify_output, notify_text = _run_remote_shell_command(
client,
surface_id,
"cmux notify --body interactive-ssh-regression",
)
_must(
notify_status == 0,
f"`cmux notify` failed in interactive shell: output={notify_output!r} tail={notify_text[-1200:]!r}",
)
_must(
"Socket not found at 127.0.0.1:" not in notify_text,
f"`cmux notify` still failed via wrong cmux binary: {notify_text[-1200:]!r}",
)
_must(
"waiting for relay on 127.0.0.1:" not in notify_text and "failed to connect to 127.0.0.1:" not in notify_text,
f"`cmux notify` still failed because the ssh relay listener was not running: {notify_text[-1200:]!r}",
)
shell_status, shell_output, shell_text = _run_remote_shell_command(
client,
surface_id,
r'''printf 'TERM=%s\n' "${TERM:-}"; printf 'TERM_PROGRAM=%s\n' "${TERM_PROGRAM:-}"; printf 'TERM_PROGRAM_VERSION=%s\n' "${TERM_PROGRAM_VERSION:-}"; printf 'GHOSTTY_SHELL_FEATURES=%s\n' "${GHOSTTY_SHELL_FEATURES:-}"; bindkey "^A"; bindkey "^K"; bindkey "^[^?"; bindkey "^[b"; bindkey "^[f"''',
)
_must(shell_status == 0, f"ssh shell env/bindkey probe failed: output={shell_output!r} tail={shell_text[-1200:]!r}")
_must("TERM=xterm-ghostty" in shell_output, f"ssh shell lost TERM=xterm-ghostty: {shell_output!r}")
_must("TERM_PROGRAM=ghostty" in shell_output, f"ssh shell lost TERM_PROGRAM=ghostty: {shell_output!r}")
_must("GHOSTTY_SHELL_FEATURES=" in shell_output, f"ssh shell lost GHOSTTY_SHELL_FEATURES: {shell_output!r}")
_must("ssh-env" in shell_output, f"ssh shell missing ssh-env feature: {shell_output!r}")
_must("ssh-terminfo" in shell_output, f"ssh shell missing ssh-terminfo feature: {shell_output!r}")
_must('"^A" beginning-of-line' in shell_output, f"Ctrl-A binding regressed in ssh shell: {shell_output!r}")
_must('"^K" kill-line' in shell_output, f"Ctrl-K binding regressed in ssh shell: {shell_output!r}")
_must('"^[^?" backward-kill-word' in shell_output, f"Opt-Backspace binding regressed in ssh shell: {shell_output!r}")
_must('"^[b" backward-word' in shell_output, f"Opt-Left binding regressed in ssh shell: {shell_output!r}")
_must('"^[f" forward-word' in shell_output, f"Opt-Right binding regressed in ssh shell: {shell_output!r}")
finally:
if workspace_ids:
try:
with cmux(SOCKET_PATH) as client:
for workspace_id in workspace_ids:
try:
client._call("workspace.close", {"workspace_id": workspace_id})
except Exception:
pass
except Exception:
pass
print("PASS: interactive ssh shell resolves cmux to relay wrapper and remote cmux commands succeed")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,259 +0,0 @@
#!/usr/bin/env python3
"""Regression: closing the last SSH surface should clear remote workspace state."""
from __future__ import annotations
import glob
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip()
SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip()
SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip()
SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip()
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
if check and proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
return proc
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_cli_json(cli: str, args: list[str]) -> dict:
env = dict(os.environ)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
env.pop("CMUX_TAB_ID", None)
proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env)
try:
return json.loads(proc.stdout or "{}")
except Exception as exc: # noqa: BLE001
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None:
deadline = time.time() + timeout_s
while time.time() < deadline:
if pred():
return
time.sleep(step_s)
raise cmuxError("Timed out waiting for condition")
def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 45.0) -> None:
deadline = time.time() + timeout_s
last_status = {}
while time.time() < deadline:
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
remote = last_status.get("remote") or {}
daemon = remote.get("daemon") or {}
if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready":
return
time.sleep(0.25)
raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}")
def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str:
workspace_id = str(payload.get("workspace_id") or "")
if workspace_id:
return workspace_id
workspace_ref = str(payload.get("workspace_ref") or "")
if workspace_ref.startswith("workspace:"):
listed = client._call("workspace.list", {}) or {}
for row in listed.get("workspaces") or []:
if str(row.get("ref") or "") == workspace_ref:
resolved = str(row.get("id") or "")
if resolved:
return resolved
current = {wid for _index, wid, _title, _focused in client.list_workspaces()}
new_ids = sorted(current - before_workspace_ids)
if len(new_ids) == 1:
return new_ids[0]
raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}")
def _workspace_row(client: cmux, workspace_id: str) -> dict:
rows = (client._call("workspace.list", {}) or {}).get("workspaces") or []
for row in rows:
if str(row.get("id") or "") == workspace_id:
return row
raise cmuxError(f"workspace.list missing {workspace_id}: {rows}")
def _remote_session_count(client: cmux, workspace_id: str) -> int:
row = _workspace_row(client, workspace_id)
remote = row.get("remote") or {}
return int(remote.get("active_terminal_sessions") or 0)
def _run_surface_probe(client: cmux, surface_id: str, command: str, token_prefix: str, timeout_s: float = 12.0) -> str:
token = f"__CMUX_{token_prefix}_{int(time.time() * 1000)}__"
client.send_surface(
surface_id,
(
f"printf '{token}:START'; echo; "
f"{command}; "
f"printf '{token}:END'; echo"
),
)
client.send_key_surface(surface_id, "enter")
deadline = time.time() + timeout_s
last = ""
pattern = re.compile(re.escape(token) + r":START\n(.*?)" + re.escape(token) + r":END", re.S)
while time.time() < deadline:
last = client.read_terminal_text(surface_id)
matches = pattern.findall(last)
if matches:
return matches[-1]
time.sleep(0.15)
raise cmuxError(f"Timed out waiting for probe {token!r}: {last[-1200:]!r}")
def _open_ssh_workspace(client: cmux, cli: str, *, name: str) -> str:
before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()}
ssh_args = ["ssh", SSH_HOST, "--name", name]
if SSH_PORT:
ssh_args.extend(["--port", SSH_PORT])
if SSH_IDENTITY:
ssh_args.extend(["--identity", SSH_IDENTITY])
if SSH_OPTIONS_RAW:
for option in SSH_OPTIONS_RAW.split(","):
trimmed = option.strip()
if trimmed:
ssh_args.extend(["--ssh-option", trimmed])
payload = _run_cli_json(cli, ssh_args)
workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids)
_wait_remote_ready(client, workspace_id)
client.select_workspace(workspace_id)
_wait_for(lambda: client.current_workspace() == workspace_id, timeout_s=8.0)
return workspace_id
def main() -> int:
if not SSH_HOST:
print("SKIP: set CMUX_SSH_TEST_HOST to run ssh last-surface remote state regression")
return 0
cli = _find_cli_binary()
workspace_id = ""
try:
with cmux(SOCKET_PATH) as client:
workspace_id = _open_ssh_workspace(
client,
cli,
name=f"ssh-last-surface-{int(time.time())}",
)
row = _workspace_row(client, workspace_id)
remote = row.get("remote") or {}
_must(bool(remote.get("enabled")) is True, f"workspace should start as remote-enabled: {row}")
_must(int(remote.get("active_terminal_sessions") or 0) == 1, f"workspace should start with one active ssh terminal session: {row}")
surfaces = client.list_surfaces(workspace_id)
_must(len(surfaces) == 1, f"expected one initial ssh surface, got {surfaces}")
split_surface_id = client.new_split("right")
_wait_for(lambda: len(client.list_surfaces(workspace_id)) == 2, timeout_s=10.0, step_s=0.1)
_wait_for(lambda: _remote_session_count(client, workspace_id) == 2, timeout_s=10.0, step_s=0.1)
client.send_surface(split_surface_id, "exit")
client.send_key_surface(split_surface_id, "enter")
_wait_for(lambda: _remote_session_count(client, workspace_id) == 1, timeout_s=15.0, step_s=0.15)
row_after_first_exit = _workspace_row(client, workspace_id)
remote_after_first_exit = row_after_first_exit.get("remote") or {}
_must(bool(remote_after_first_exit.get("enabled")) is True, f"workspace should stay remote while one ssh terminal remains: {row_after_first_exit}")
remaining_surface_id = next(
surface_id
for _index, surface_id, _focused in client.list_surfaces(workspace_id)
if surface_id != split_surface_id
)
client.send_surface(remaining_surface_id, "exit")
client.send_key_surface(remaining_surface_id, "enter")
def _remote_cleared() -> bool:
row_now = _workspace_row(client, workspace_id)
remote_now = row_now.get("remote") or {}
if bool(remote_now.get("enabled")):
return False
surfaces_now = client.list_surfaces(workspace_id)
return len(surfaces_now) == 2
_wait_for(_remote_cleared, timeout_s=15.0, step_s=0.15)
final_row = _workspace_row(client, workspace_id)
final_remote = final_row.get("remote") or {}
_must(bool(final_remote.get("enabled")) is False, f"workspace remote metadata should clear after last ssh surface closes: {final_row}")
_must(str(final_remote.get("state") or "") == "disconnected", f"workspace should end disconnected after remote metadata clears: {final_row}")
_must(int(final_remote.get("active_terminal_sessions") or 0) == 0, f"workspace should report zero active ssh terminal sessions after last ssh surface closes: {final_row}")
local_surface_ids = [surface_id for _index, surface_id, _focused in client.list_surfaces(workspace_id)]
_must(len(local_surface_ids) == 2, f"expected both panes to remain as local terminals after ssh exits, got {local_surface_ids}")
for idx, surface_id in enumerate(local_surface_ids):
socket_output = _run_surface_probe(
client,
surface_id,
r'''printf '%s' "${CMUX_SOCKET_PATH:-}"''',
f"SSH_LAST_SURFACE_SOCKET_{idx}",
).strip()
_must(
not socket_output.startswith("127.0.0.1:"),
f"surface {surface_id} should be local after clearing remote state, got CMUX_SOCKET_PATH={socket_output!r}",
)
finally:
if workspace_id:
try:
with cmux(SOCKET_PATH) as cleanup_client:
cleanup_client._call("workspace.close", {"workspace_id": workspace_id})
except Exception:
pass
print("PASS: exiting all ssh panes clears remote workspace state while fallback local panes remain local")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,246 +0,0 @@
#!/usr/bin/env python3
"""Docker integration: local proxy bind conflict surfaces proxy_unavailable."""
from __future__ import annotations
import glob
import os
import secrets
import shutil
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1")
DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
if check and proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
return proc
def _docker_available() -> bool:
if shutil.which("docker") is None:
return False
probe = _run(["docker", "info"], check=False)
return probe.returncode == 0
def _parse_host_port(docker_port_output: str) -> int:
text = docker_port_output.strip()
if not text:
raise cmuxError("docker port output was empty")
last = text.split(":")[-1]
return int(last)
def _shell_single_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]:
return _run(
[
"ssh",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"StrictHostKeyChecking=no",
"-o",
"ConnectTimeout=5",
"-p",
str(host_port),
"-i",
str(key_path),
host,
f"sh -lc {_shell_single_quote(script)}",
],
check=check,
)
def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
probe = _ssh_run(host, host_port, key_path, "echo ready", check=False)
if probe.returncode == 0 and "ready" in probe.stdout:
return
time.sleep(0.5)
raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready")
def _find_free_loopback_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _wait_for_proxy_conflict_status(client: cmux, workspace_id: str, expected_local_proxy_port: int, timeout: float = 30.0) -> dict:
deadline = time.time() + timeout
last_status = {}
while time.time() < deadline:
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
remote = last_status.get("remote") or {}
proxy = remote.get("proxy") or {}
daemon = remote.get("daemon") or {}
if str(remote.get("state") or "") == "error" and str(proxy.get("state") or "") == "error":
detail = str(remote.get("detail") or "")
_must(
proxy.get("error_code") == "proxy_unavailable",
f"proxy error should be proxy_unavailable under bind conflict: {last_status}",
)
_must(
int(remote.get("local_proxy_port") or 0) == expected_local_proxy_port,
f"remote status should retain configured local_proxy_port under bind conflict: {last_status}",
)
_must(
(
"Failed to start local daemon proxy" in detail
or "Local proxy listener failed" in detail
),
f"remote detail should surface local proxy bind failure: {last_status}",
)
_must(
"Address already in use" in detail,
f"remote detail should preserve bind-conflict root cause: {last_status}",
)
_must(
str(daemon.get("state") or "") == "ready",
f"daemon should remain ready for local-only bind conflicts: {last_status}",
)
return last_status
time.sleep(0.5)
raise cmuxError(f"Remote did not reach structured proxy_unavailable status for bind conflict: {last_status}")
def main() -> int:
if not _docker_available():
print("SKIP: docker is not available")
return 0
_ = _find_cli_binary() # enforce same test prerequisites as other SSH remote suites
repo_root = Path(__file__).resolve().parents[1]
fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote"
_must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}")
temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-proxy-conflict-"))
image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}"
container_name = f"cmux-ssh-proxy-conflict-{secrets.token_hex(4)}"
workspace_id = ""
conflict_listener: socket.socket | None = None
try:
key_path = temp_dir / "id_ed25519"
_run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)])
pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip()
_must(bool(pubkey), "Generated SSH public key was empty")
_run(["docker", "build", "-t", image_tag, str(fixture_dir)])
_run([
"docker", "run", "-d", "--rm",
"--name", container_name,
"-e", f"AUTHORIZED_KEY={pubkey}",
"-p", f"{DOCKER_PUBLISH_ADDR}::22",
image_tag,
])
port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout
host_ssh_port = _parse_host_port(port_info)
host = f"root@{DOCKER_SSH_HOST}"
_wait_for_ssh(host, host_ssh_port, key_path)
conflict_port = _find_free_loopback_port()
conflict_listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
conflict_listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
conflict_listener.bind(("127.0.0.1", conflict_port))
conflict_listener.listen(1)
with cmux(SOCKET_PATH) as client:
created = client._call("workspace.create", {"initial_command": "echo ssh-proxy-conflict"})
workspace_id = str((created or {}).get("workspace_id") or "")
_must(bool(workspace_id), f"workspace.create did not return workspace_id: {created}")
configured = client._call("workspace.remote.configure", {
"workspace_id": workspace_id,
"destination": host,
"port": host_ssh_port,
"identity_file": str(key_path),
"ssh_options": ["UserKnownHostsFile=/dev/null", "StrictHostKeyChecking=no"],
"auto_connect": True,
"local_proxy_port": conflict_port,
})
_must(bool(configured), "workspace.remote.configure returned empty response")
_ = _wait_for_proxy_conflict_status(
client,
workspace_id,
expected_local_proxy_port=conflict_port,
timeout=30.0,
)
try:
client.close_workspace(workspace_id)
except Exception:
pass
workspace_id = ""
print("PASS: local proxy bind conflict surfaces structured proxy_unavailable without degrading daemon readiness")
return 0
finally:
if conflict_listener is not None:
try:
conflict_listener.close()
except Exception:
pass
if workspace_id:
try:
with cmux(SOCKET_PATH) as cleanup_client:
cleanup_client.close_workspace(workspace_id)
except Exception:
pass
_run(["docker", "rm", "-f", container_name], check=False)
_run(["docker", "rmi", "-f", image_tag], check=False)
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,357 +0,0 @@
#!/usr/bin/env python3
"""Regression: ssh workspace keeps large pre-resize scrollback across split resize churn."""
from __future__ import annotations
import glob
import json
import os
import re
import secrets
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux.sock")
SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip()
SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip()
SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip()
SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip()
LS_ENTRY_COUNT = int(os.environ.get("CMUX_SSH_TEST_LS_COUNT", "320"))
RESIZE_ITERATIONS = int(os.environ.get("CMUX_SSH_TEST_RESIZE_ITERATIONS", "48"))
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
if check and proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
return proc
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_cli_json(cli: str, args: list[str]) -> dict:
env = dict(os.environ)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
env.pop("CMUX_TAB_ID", None)
proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env)
try:
return json.loads(proc.stdout or "{}")
except Exception as exc: # noqa: BLE001
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None:
deadline = time.time() + timeout_s
while time.time() < deadline:
if pred():
return
time.sleep(step_s)
raise cmuxError("Timed out waiting for condition")
def _wait_remote_connected(client: cmux, workspace_id: str, timeout_s: float = 45.0) -> None:
deadline = time.time() + timeout_s
last = {}
while time.time() < deadline:
last = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
remote = last.get("remote") or {}
daemon = remote.get("daemon") or {}
if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready":
return
time.sleep(0.25)
raise cmuxError(f"Remote did not reach connected+ready state: {last}")
def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str:
workspace_id = str(payload.get("workspace_id") or "")
if workspace_id:
return workspace_id
workspace_ref = str(payload.get("workspace_ref") or "")
if workspace_ref.startswith("workspace:"):
listed = client._call("workspace.list", {}) or {}
for row in listed.get("workspaces") or []:
if str(row.get("ref") or "") == workspace_ref:
resolved = str(row.get("id") or "")
if resolved:
return resolved
current = {wid for _index, wid, _title, _focused in client.list_workspaces()}
new_ids = sorted(current - before_workspace_ids)
if len(new_ids) == 1:
return new_ids[0]
raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}")
def _clean_line(raw: str) -> str:
line = OSC_ESCAPE_RE.sub("", raw)
line = ANSI_ESCAPE_RE.sub("", line)
line = line.replace("\r", "")
return line.strip()
def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) -> str:
payload = client._call(
"surface.read_text",
{"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True},
) or {}
return str(payload.get("text") or "")
def _surface_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]:
return [_clean_line(raw) for raw in _surface_scrollback_text(client, workspace_id, surface_id).splitlines()]
def _wait_surface_contains(
client: cmux,
workspace_id: str,
surface_id: str,
token: str,
*,
exact_line: bool = False,
timeout_s: float = 25.0,
) -> None:
deadline = time.time() + timeout_s
while time.time() < deadline:
if exact_line:
if token in _surface_scrollback_lines(client, workspace_id, surface_id):
return
elif token in _surface_scrollback_text(client, workspace_id, surface_id):
return
time.sleep(0.2)
raise cmuxError(f"Timed out waiting for terminal token: {token}")
def _pane_for_surface(client: cmux, surface_id: str) -> str:
target_id = str(client._resolve_surface_id(surface_id))
for _idx, pane_id, _count, _focused in client.list_panes():
rows = client.list_pane_surfaces(pane_id)
for _row_idx, sid, _title, _selected in rows:
try:
candidate_id = str(client._resolve_surface_id(sid))
except cmuxError:
continue
if candidate_id == target_id:
return pane_id
raise cmuxError(f"Surface {surface_id} is not present in current workspace panes")
def _valid_resize_directions(client: cmux, workspace_id: str, pane_id: str) -> list[str]:
valid: list[str] = []
for direction in ("left", "right", "up", "down"):
try:
client._call(
"pane.resize",
{
"workspace_id": workspace_id,
"pane_id": pane_id,
"direction": direction,
"amount": 10,
},
)
valid.append(direction)
except cmuxError:
pass
return valid
def _choose_resize_pair(client: cmux, workspace_id: str, pane_ids: list[str]) -> list[tuple[str, str]]:
by_pane: dict[str, list[str]] = {}
for pane_id in pane_ids:
by_pane[pane_id] = _valid_resize_directions(client, workspace_id, pane_id)
for pane_a, directions_a in by_pane.items():
if "right" not in directions_a:
continue
for pane_b, directions_b in by_pane.items():
if pane_b == pane_a:
continue
if "left" in directions_b:
return [(pane_a, "right"), (pane_b, "left")]
for pane_a, directions_a in by_pane.items():
if "down" not in directions_a:
continue
for pane_b, directions_b in by_pane.items():
if pane_b == pane_a:
continue
if "up" in directions_b:
return [(pane_a, "down"), (pane_b, "up")]
raise cmuxError(f"Could not find oscillating resize pair across panes: {by_pane}")
def main() -> int:
if not SSH_HOST:
print("SKIP: set CMUX_SSH_TEST_HOST to run remote resize scrollback regression")
return 0
if LS_ENTRY_COUNT < 64:
print("SKIP: CMUX_SSH_TEST_LS_COUNT must be >= 64 for meaningful scrollback coverage")
return 0
cli = _find_cli_binary()
workspace_id = ""
try:
with cmux(SOCKET_PATH) as client:
before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()}
ssh_args = ["ssh", SSH_HOST, "--name", f"ssh-resize-regression-{secrets.token_hex(4)}"]
if SSH_PORT:
ssh_args.extend(["--port", SSH_PORT])
if SSH_IDENTITY:
ssh_args.extend(["--identity", SSH_IDENTITY])
if SSH_OPTIONS_RAW:
for option in SSH_OPTIONS_RAW.split(","):
trimmed = option.strip()
if trimmed:
ssh_args.extend(["--ssh-option", trimmed])
payload = _run_cli_json(cli, ssh_args)
workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids)
_wait_remote_connected(client, workspace_id, timeout_s=50.0)
surfaces = client.list_surfaces(workspace_id)
_must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}")
surface_id = surfaces[0][1]
stamp = secrets.token_hex(4)
ls_entries = [f"CMUX_REMOTE_RESIZE_LS_{stamp}_{index:04d}.txt" for index in range(1, LS_ENTRY_COUNT + 1)]
ls_start = f"CMUX_REMOTE_RESIZE_LS_START_{stamp}"
ls_end = f"CMUX_REMOTE_RESIZE_LS_END_{stamp}"
ls_prefix = f"CMUX_REMOTE_RESIZE_LS_{stamp}_"
ls_script = (
"tmpdir=$(mktemp -d); "
f"echo {ls_start}; "
f"for i in $(seq 1 {LS_ENTRY_COUNT}); do "
"n=$(printf '%04d' \"$i\"); "
f"touch \"$tmpdir/{ls_prefix}$n.txt\"; "
"done; "
"LC_ALL=C CLICOLOR=0 ls -1 \"$tmpdir\"; "
f"echo {ls_end}; "
"rm -rf \"$tmpdir\""
)
client.send_surface(surface_id, f"{ls_script}\n")
_wait_surface_contains(
client,
workspace_id,
surface_id,
ls_end,
exact_line=True,
timeout_s=45.0,
)
pre_resize_lines = _surface_scrollback_lines(client, workspace_id, surface_id)
_must(
all(entry in pre_resize_lines for entry in ls_entries),
"pre-resize scrollback missing ls fixture lines in ssh workspace",
)
pre_resize_anchors = [ls_entries[0], ls_entries[len(ls_entries) // 2], ls_entries[-1]]
client.select_workspace(workspace_id)
client.activate_app()
pane_count_before_split = len(client.list_panes())
client.simulate_shortcut("cmd+d")
_wait_for(lambda: len(client.list_panes()) >= pane_count_before_split + 1, timeout_s=10.0)
# Ensure the original surface remains selected before resize churn.
client.focus_surface(surface_id)
pane_ids = [pid for _idx, pid, _count, _focused in client.list_panes()]
_must(len(pane_ids) >= 2, f"expected split workspace with >=2 panes: {pane_ids}")
_ = _pane_for_surface(client, surface_id)
resize_pair = _choose_resize_pair(client, workspace_id, pane_ids)
for iteration in range(1, RESIZE_ITERATIONS + 1):
pane_id, direction = resize_pair[(iteration - 1) % len(resize_pair)]
_ = client._call(
"pane.resize",
{
"workspace_id": workspace_id,
"pane_id": pane_id,
"direction": direction,
"amount": 80,
},
)
if iteration % 8 == 0:
sampled_lines = _surface_scrollback_lines(client, workspace_id, surface_id)
_must(
all(anchor in sampled_lines for anchor in pre_resize_anchors),
f"resize iteration {iteration} lost pre-resize anchor lines in ssh workspace",
)
post_token = f"CMUX_REMOTE_RESIZE_POST_{secrets.token_hex(6)}"
client.send_surface(surface_id, f"echo {post_token}\n")
_wait_surface_contains(
client,
workspace_id,
surface_id,
post_token,
exact_line=True,
timeout_s=25.0,
)
post_resize_lines = _surface_scrollback_lines(client, workspace_id, surface_id)
_must(
all(entry in post_resize_lines for entry in ls_entries),
"post-resize scrollback lost ls fixture lines in ssh workspace",
)
_must(
post_token in post_resize_lines,
f"post-resize scrollback missing post token: {post_token}",
)
client.close_workspace(workspace_id)
workspace_id = ""
print(
"PASS: cmux ssh split+resize churn preserved large pre-resize scrollback "
f"(entries={LS_ENTRY_COUNT}, iterations={RESIZE_ITERATIONS})"
)
return 0
finally:
if workspace_id:
try:
with cmux(SOCKET_PATH) as cleanup_client:
cleanup_client.close_workspace(workspace_id)
except Exception:
pass
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,175 +0,0 @@
#!/usr/bin/env python3
"""Regression: opening a second `cmux ssh` workspace to the same host must not mux-refuse."""
from __future__ import annotations
import glob
import json
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip()
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_cli_json(cli: str, args: list[str]) -> dict:
env = dict(os.environ)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
env.pop("CMUX_TAB_ID", None)
import subprocess
proc = subprocess.run(
[cli, "--socket", SOCKET_PATH, "--json", *args],
capture_output=True,
text=True,
check=False,
env=env,
)
if proc.returncode != 0:
raise cmuxError(f"CLI failed ({' '.join(args)}): {(proc.stdout + proc.stderr).strip()}")
try:
return json.loads(proc.stdout or "{}")
except Exception as exc: # noqa: BLE001
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
def _wait_remote_ready(client: cmux, workspace_id: str, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
last_status = {}
while time.time() < deadline:
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
remote = last_status.get("remote") or {}
daemon = remote.get("daemon") or {}
if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready":
return
time.sleep(0.25)
raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}")
def _wait_surface_id(client: cmux, workspace_id: str, timeout: float = 10.0) -> str:
deadline = time.time() + timeout
while time.time() < deadline:
surfaces = client.list_surfaces(workspace_id)
if surfaces:
return str(surfaces[0][1])
time.sleep(0.1)
raise cmuxError(f"No terminal surface appeared for workspace {workspace_id}")
def _workspace_id_from_payload(client: cmux, payload: dict) -> str:
workspace_id = str(payload.get("workspace_id") or "")
if workspace_id:
return workspace_id
workspace_ref = str(payload.get("workspace_ref") or "")
if workspace_ref.startswith("workspace:"):
rows = (client._call("workspace.list", {}) or {}).get("workspaces") or []
for row in rows:
if str(row.get("ref") or "") == workspace_ref:
return str(row.get("id") or "")
return ""
def _wait_text_contains(client: cmux, surface_id: str, needle: str, timeout: float = 8.0) -> str:
deadline = time.time() + timeout
last = ""
while time.time() < deadline:
last = client.read_terminal_text(surface_id)
if needle in last:
return last
time.sleep(0.1)
raise cmuxError(f"Timed out waiting for {needle!r} in surface {surface_id}: {last[-800:]!r}")
def main() -> int:
if not SSH_HOST:
print("SKIP: set CMUX_SSH_TEST_HOST to run second-session ssh mux regression")
return 0
cli = _find_cli_binary()
workspace_ids: list[str] = []
try:
with cmux(SOCKET_PATH) as client:
first = _run_cli_json(cli, ["ssh", SSH_HOST])
first_workspace_id = _workspace_id_from_payload(client, first)
_must(bool(first_workspace_id), f"first cmux ssh output missing workspace_id: {first}")
workspace_ids.append(first_workspace_id)
_wait_remote_ready(client, first_workspace_id)
first_surface_id = _wait_surface_id(client, first_workspace_id)
_wait_text_contains(client, first_surface_id, "cmux in ~", timeout=12.0)
second = _run_cli_json(cli, ["ssh", SSH_HOST])
second_workspace_id = _workspace_id_from_payload(client, second)
_must(bool(second_workspace_id), f"second cmux ssh output missing workspace_id: {second}")
workspace_ids.append(second_workspace_id)
_wait_remote_ready(client, second_workspace_id)
second_surface_id = _wait_surface_id(client, second_workspace_id)
text = _wait_text_contains(client, second_surface_id, "cmux in ~", timeout=12.0)
refusal_markers = [
"mux_client_request_session: session request failed: Session open refused by peer",
"ControlSocket ",
"disabling multiplexing",
]
hits = [marker for marker in refusal_markers if marker in text]
_must(
not hits,
"second cmux ssh session printed mux refusal text instead of starting cleanly: "
f"markers={hits!r} tail={text[-1200:]!r}",
)
client.send_surface(second_surface_id, "printf '__SECOND_SESSION_OK__\\n'")
text = _wait_text_contains(client, second_surface_id, "__SECOND_SESSION_OK__", timeout=6.0)
_must(
"command not found" not in text,
f"second cmux ssh session accepted corrupted input after startup: {text[-1200:]!r}",
)
finally:
if workspace_ids:
try:
with cmux(SOCKET_PATH) as client:
for workspace_id in workspace_ids:
try:
client._call("workspace.close", {"workspace_id": workspace_id})
except Exception:
pass
except Exception:
pass
print("PASS: second cmux ssh session opens cleanly without mux refusal")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,577 +0,0 @@
#!/usr/bin/env python3
"""Docker integration: prove cmux ssh applies Ghostty ssh-env/ssh-terminfo niceties."""
from __future__ import annotations
import glob
import json
import os
import re
import secrets
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1")
DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1")
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
if check and proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
return proc
def _run_cli_json(cli: str, args: list[str]) -> dict:
env = dict(os.environ)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
env.pop("CMUX_TAB_ID", None)
proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env)
try:
return json.loads(proc.stdout or "{}")
except Exception as exc: # noqa: BLE001
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
def _docker_available() -> bool:
if shutil.which("docker") is None:
return False
probe = _run(["docker", "info"], check=False)
return probe.returncode == 0
def _parse_host_port(docker_port_output: str) -> int:
text = docker_port_output.strip()
if not text:
raise cmuxError("docker port output was empty")
return int(text.split(":")[-1])
def _shell_single_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
def _ssh_run(host: str, host_port: int, key_path: Path, script: str, *, check: bool = True) -> subprocess.CompletedProcess[str]:
return _run(
[
"ssh",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"StrictHostKeyChecking=no",
"-o",
"ConnectTimeout=5",
"-p",
str(host_port),
"-i",
str(key_path),
host,
f"sh -lc {_shell_single_quote(script)}",
],
check=check,
)
def _wait_for_ssh(host: str, host_port: int, key_path: Path, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
probe = _ssh_run(host, host_port, key_path, "echo ready", check=False)
if probe.returncode == 0 and "ready" in probe.stdout:
return
time.sleep(0.5)
raise cmuxError("Timed out waiting for SSH server in docker fixture to become ready")
def _wait_remote_connected(client: cmux, workspace_id: str, timeout: float) -> dict:
deadline = time.time() + timeout
last_status = {}
while time.time() < deadline:
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
remote = last_status.get("remote") or {}
daemon = remote.get("daemon") or {}
if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready":
return last_status
time.sleep(0.4)
raise cmuxError(f"Remote did not reach connected+ready state: {last_status}")
def _is_terminal_surface_not_found(exc: Exception) -> bool:
return "terminal surface not found" in str(exc).lower()
def _read_probe_value(client: cmux, surface_id: str, command: str, timeout: float = 20.0) -> str:
token = f"__CMUX_PROBE_{secrets.token_hex(6)}__"
client.send_surface(surface_id, f"{command}; printf '{token}%s\\n' $?\\n")
pattern = re.compile(re.escape(token) + r"([^\r\n]*)")
deadline = time.time() + timeout
saw_missing_surface = False
while time.time() < deadline:
try:
text = client.read_terminal_text(surface_id)
except cmuxError as exc:
if _is_terminal_surface_not_found(exc):
saw_missing_surface = True
time.sleep(0.2)
continue
raise
matches = pattern.findall(text)
for raw in reversed(matches):
value = raw.strip()
if value and value != "%s" and "$(" not in value and "printf" not in value:
return value
time.sleep(0.2)
if saw_missing_surface:
raise cmuxError("terminal surface not found")
raise cmuxError(f"Timed out waiting for probe token for command: {command}")
def _read_probe_payload(client: cmux, surface_id: str, payload_command: str, timeout: float = 20.0) -> str:
token = f"__CMUX_PAYLOAD_{secrets.token_hex(6)}__"
client.send_surface(surface_id, f"printf '{token}%s\\n' \"$({payload_command})\"\\n")
pattern = re.compile(re.escape(token) + r"([^\r\n]*)")
deadline = time.time() + timeout
saw_missing_surface = False
while time.time() < deadline:
try:
text = client.read_terminal_text(surface_id)
except cmuxError as exc:
if _is_terminal_surface_not_found(exc):
saw_missing_surface = True
time.sleep(0.2)
continue
raise
matches = pattern.findall(text)
for raw in reversed(matches):
value = raw.strip()
if value and value != "%s" and "$(" not in value and "printf" not in value:
return value
time.sleep(0.2)
if saw_missing_surface:
raise cmuxError("terminal surface not found")
raise cmuxError(f"Timed out waiting for payload token for command: {payload_command}")
def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None:
deadline = time.time() + timeout_s
while time.time() < deadline:
if pred():
return
time.sleep(step_s)
raise cmuxError("Timed out waiting for condition")
def _wait_for_pane_count(client: cmux, minimum_count: int, timeout: float = 8.0) -> list[str]:
deadline = time.time() + timeout
last: list[str] = []
while time.time() < deadline:
last = [pid for _idx, pid, _count, _focused in client.list_panes()]
if len(last) >= minimum_count:
return last
time.sleep(0.1)
raise cmuxError(f"Timed out waiting for pane count >= {minimum_count}; saw {len(last)} panes: {last}")
def _surface_text_scrollback(client: cmux, workspace_id: str, surface_id: str) -> str:
payload = client._call(
"surface.read_text",
{"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True},
) or {}
return str(payload.get("text") or "")
def _clean_line(raw: str) -> str:
line = OSC_ESCAPE_RE.sub("", raw)
line = ANSI_ESCAPE_RE.sub("", line)
line = line.replace("\r", "")
return line.strip()
def _surface_text_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]:
return [_clean_line(raw) for raw in _surface_text_scrollback(client, workspace_id, surface_id).splitlines()]
def _scrollback_has_all_lines(
client: cmux,
workspace_id: str,
surface_id: str,
lines: list[str],
) -> bool:
available = set(_surface_text_scrollback_lines(client, workspace_id, surface_id))
return all(line in available for line in lines)
def _wait_surface_contains(
client: cmux,
workspace_id: str,
surface_id: str,
token: str,
*,
timeout: float = 20.0,
) -> None:
deadline = time.time() + timeout
saw_missing_surface = False
while time.time() < deadline:
try:
if token in _surface_text_scrollback(client, workspace_id, surface_id):
return
except cmuxError as exc:
if _is_terminal_surface_not_found(exc):
saw_missing_surface = True
time.sleep(0.2)
continue
raise
time.sleep(0.2)
if saw_missing_surface:
raise cmuxError("terminal surface not found")
raise cmuxError(f"Timed out waiting for terminal token: {token}")
def _layout_panes(client: cmux) -> list[dict]:
layout_payload = client.layout_debug() or {}
layout = layout_payload.get("layout") or {}
return list(layout.get("panes") or [])
def _pane_extent(client: cmux, pane_id: str, axis: str) -> float:
panes = _layout_panes(client)
for pane in panes:
pid = str(pane.get("paneId") or pane.get("pane_id") or "")
if pid != pane_id:
continue
frame = pane.get("frame") or {}
return float(frame.get(axis) or 0.0)
raise cmuxError(f"Pane {pane_id} missing from debug layout panes: {panes}")
def _pane_for_surface(client: cmux, surface_id: str) -> str:
target_id = str(client._resolve_surface_id(surface_id))
for _idx, pane_id, _count, _focused in client.list_panes():
rows = client.list_pane_surfaces(pane_id)
for _row_idx, sid, _title, _selected in rows:
try:
candidate_id = str(client._resolve_surface_id(sid))
except cmuxError:
continue
if candidate_id == target_id:
return pane_id
raise cmuxError(f"Surface {surface_id} is not present in current workspace panes")
def _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]:
panes = [p for p in _layout_panes(client) if str(p.get("paneId") or p.get("pane_id") or "") in pane_ids]
if len(panes) < 2:
raise cmuxError(f"Need >=2 panes for resize test, got {panes}")
def x_of(p: dict) -> float:
return float((p.get("frame") or {}).get("x") or 0.0)
def y_of(p: dict) -> float:
return float((p.get("frame") or {}).get("y") or 0.0)
x_span = max(x_of(p) for p in panes) - min(x_of(p) for p in panes)
y_span = max(y_of(p) for p in panes) - min(y_of(p) for p in panes)
if x_span >= y_span:
left_pane = min(panes, key=x_of)
left_id = str(left_pane.get("paneId") or left_pane.get("pane_id") or "")
return ("right" if target_pane == left_id else "left"), "width"
top_pane = min(panes, key=y_of)
top_id = str(top_pane.get("paneId") or top_pane.get("pane_id") or "")
return ("down" if target_pane == top_id else "up"), "height"
def main() -> int:
if not _docker_available():
print("SKIP: docker is not available")
return 0
if shutil.which("infocmp") is None:
print("SKIP: local infocmp is not available (required for ssh-terminfo)")
return 0
cli = _find_cli_binary()
repo_root = Path(__file__).resolve().parents[1]
fixture_dir = repo_root / "tests" / "fixtures" / "ssh-remote"
_must(fixture_dir.is_dir(), f"Missing docker fixture directory: {fixture_dir}")
temp_dir = Path(tempfile.mkdtemp(prefix="cmux-ssh-shell-integration-"))
image_tag = f"cmux-ssh-test:{secrets.token_hex(4)}"
container_name = f"cmux-ssh-shell-{secrets.token_hex(4)}"
workspace_id = ""
try:
key_path = temp_dir / "id_ed25519"
_run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(key_path)])
pubkey = (key_path.with_suffix(".pub")).read_text(encoding="utf-8").strip()
_must(bool(pubkey), "Generated SSH public key was empty")
_run(["docker", "build", "-t", image_tag, str(fixture_dir)])
_run([
"docker",
"run",
"-d",
"--rm",
"--name",
container_name,
"-e",
f"AUTHORIZED_KEY={pubkey}",
"-p",
f"{DOCKER_PUBLISH_ADDR}::22",
image_tag,
])
port_info = _run(["docker", "port", container_name, "22/tcp"]).stdout
host_ssh_port = _parse_host_port(port_info)
host = f"root@{DOCKER_SSH_HOST}"
if shutil.which("ghostty") is not None:
_run(["ghostty", "+ssh-cache", f"--remove={host}"], check=False)
_wait_for_ssh(host, host_ssh_port, key_path)
pre = _ssh_run(host, host_ssh_port, key_path, "if infocmp xterm-ghostty >/dev/null 2>&1; then echo present; else echo missing; fi")
_must("missing" in pre.stdout, f"Fresh container should not have xterm-ghostty terminfo preinstalled: {pre.stdout!r}")
with cmux(SOCKET_PATH) as client:
payload = _run_cli_json(
cli,
[
"ssh",
host,
"--name",
"docker-ssh-shell-integration",
"--port",
str(host_ssh_port),
"--identity",
str(key_path),
"--ssh-option",
"UserKnownHostsFile=/dev/null",
"--ssh-option",
"StrictHostKeyChecking=no",
],
)
workspace_id = str(payload.get("workspace_id") or "")
workspace_ref = str(payload.get("workspace_ref") or "")
if not workspace_id and workspace_ref.startswith("workspace:"):
listed = client._call("workspace.list", {}) or {}
for row in listed.get("workspaces") or []:
if str(row.get("ref") or "") == workspace_ref:
workspace_id = str(row.get("id") or "")
break
_must(bool(workspace_id), f"cmux ssh output missing workspace_id: {payload}")
_wait_remote_connected(client, workspace_id, timeout=45.0)
surfaces = client.list_surfaces(workspace_id)
_must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}")
surface_id = surfaces[0][1]
terminal_text = client.read_terminal_text(surface_id)
_must(
"Reconstructed via infocmp" not in terminal_text,
"ssh-terminfo bootstrap should not leak raw infocmp output into the interactive shell",
)
_must(
"Warning: Failed to install terminfo." not in terminal_text,
"ssh shell bootstrap should not show a false terminfo failure warning",
)
try:
term_value = _read_probe_payload(client, surface_id, "printf '%s' \"$TERM\"")
terminfo_state = _read_probe_value(client, surface_id, "infocmp xterm-ghostty >/dev/null 2>&1")
except cmuxError as exc:
if _is_terminal_surface_not_found(exc):
print("SKIP: terminal surface unavailable for shell integration probes")
return 0
raise
_must(terminfo_state in {"0", "1"}, f"unexpected terminfo probe exit status: {terminfo_state!r}")
if terminfo_state == "0":
_must(
term_value == "xterm-ghostty",
f"when terminfo install succeeds, TERM should remain xterm-ghostty (got {term_value!r})",
)
else:
_must(
term_value == "xterm-256color",
f"when terminfo is unavailable, ssh-env fallback should use TERM=xterm-256color (got {term_value!r})",
)
colorterm_value = _read_probe_payload(client, surface_id, "printf '%s' \"${COLORTERM:-}\"")
_must(
colorterm_value == "truecolor",
f"ssh-env should propagate COLORTERM=truecolor, got: {colorterm_value!r}",
)
term_program = _read_probe_payload(client, surface_id, "printf '%s' \"${TERM_PROGRAM:-}\"")
_must(
term_program == "ghostty",
f"ssh-env should propagate TERM_PROGRAM=ghostty when AcceptEnv allows it, got: {term_program!r}",
)
term_program_version = _read_probe_payload(client, surface_id, "printf '%s' \"${TERM_PROGRAM_VERSION:-}\"")
_must(bool(term_program_version), "ssh-env should propagate non-empty TERM_PROGRAM_VERSION")
ls_stamp = secrets.token_hex(4)
ls_entries = [f"CMUX_RESIZE_LS_{ls_stamp}_{index:02d}" for index in range(1, 17)]
ls_start = f"CMUX_RESIZE_LS_START_{ls_stamp}"
ls_end = f"CMUX_RESIZE_LS_END_{ls_stamp}"
names = " ".join(ls_entries)
ls_script = (
"tmpdir=$(mktemp -d); "
f"echo {ls_start}; "
f"for name in {names}; do touch \"$tmpdir/$name\"; done; "
"ls -1 \"$tmpdir\"; "
f"echo {ls_end}; "
"rm -rf \"$tmpdir\""
)
client.send_surface(surface_id, f"{ls_script}\n")
_wait_surface_contains(client, workspace_id, surface_id, ls_end)
pre_resize_scrollback_lines = _surface_text_scrollback_lines(client, workspace_id, surface_id)
_must(
all(line in pre_resize_scrollback_lines for line in ls_entries),
"pre-resize scrollback missing ls output fixture lines",
)
pre_resize_anchors = [ls_entries[0], ls_entries[len(ls_entries) // 2], ls_entries[-1]]
_must(
len(pre_resize_anchors) == 3,
f"pre-resize scrollback missing anchor lines: {pre_resize_anchors}",
)
pre_resize_visible = client.read_terminal_text(surface_id)
pre_visible_lines = [line for line in ls_entries if line in pre_resize_visible]
_must(
len(pre_visible_lines) >= 2,
"pre-resize viewport did not contain enough reference lines for continuity checks",
)
client.select_workspace(workspace_id)
client.activate_app()
pane_count_before_split = len(client.list_panes())
client.simulate_shortcut("cmd+d")
pane_ids = _wait_for_pane_count(client, pane_count_before_split + 1, timeout=8.0)
pane_id = _pane_for_surface(client, surface_id)
resize_direction, resize_axis = _pick_resize_direction_for_pane(client, pane_ids, pane_id)
opposite_direction = {
"left": "right",
"right": "left",
"up": "down",
"down": "up",
}[resize_direction]
expected_sign_by_direction = {
resize_direction: +1,
opposite_direction: -1,
}
resize_sequence = [resize_direction, opposite_direction] * 8
current_extent = _pane_extent(client, pane_id, resize_axis)
for index, direction in enumerate(resize_sequence, start=1):
resize_result = client._call(
"pane.resize",
{
"workspace_id": workspace_id,
"pane_id": pane_id,
"direction": direction,
"amount": 80,
},
) or {}
_must(
str(resize_result.get("pane_id") or "") == pane_id,
f"pane.resize response missing expected pane_id: {resize_result}",
)
if expected_sign_by_direction[direction] > 0:
_wait_for(lambda: _pane_extent(client, pane_id, resize_axis) > current_extent + 1.0, timeout_s=5.0)
else:
_wait_for(lambda: _pane_extent(client, pane_id, resize_axis) < current_extent - 1.0, timeout_s=5.0)
current_extent = _pane_extent(client, pane_id, resize_axis)
_must(
_scrollback_has_all_lines(client, workspace_id, surface_id, pre_resize_anchors),
f"resize iteration {index} lost pre-resize scrollback anchors",
)
post_resize_visible = client.read_terminal_text(surface_id)
visible_overlap = [line for line in pre_visible_lines if line in post_resize_visible]
_must(
bool(visible_overlap),
f"resize lost all pre-resize visible lines from viewport: {pre_visible_lines}",
)
resize_post_token = f"CMUX_RESIZE_POST_{secrets.token_hex(6)}"
client.send_surface(surface_id, f"echo {resize_post_token}\n")
_wait_surface_contains(client, workspace_id, surface_id, resize_post_token)
scrollback_lines = _surface_text_scrollback_lines(client, workspace_id, surface_id)
_must(
all(anchor in scrollback_lines for anchor in pre_resize_anchors),
"terminal scrollback lost pre-resize lines after pane resize",
)
_must(
resize_post_token in scrollback_lines,
f"terminal scrollback missing post-resize token after pane resize: {resize_post_token}",
)
try:
client.close_workspace(workspace_id)
workspace_id = ""
except Exception:
pass
print(
"PASS: cmux ssh enables Ghostty shell integration niceties and preserves pre-resize terminal content "
f"(TERM={term_value}, COLORTERM={colorterm_value}, TERM_PROGRAM={term_program})"
)
return 0
finally:
if workspace_id:
try:
with cmux(SOCKET_PATH) as cleanup_client:
cleanup_client.close_workspace(workspace_id)
except Exception:
pass
_run(["docker", "rm", "-f", container_name], check=False)
_run(["docker", "rmi", "-f", image_tag], check=False)
shutil.rmtree(temp_dir, ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,281 +0,0 @@
#!/usr/bin/env python3
"""Regression: new tabs and splits from an ssh terminal must stay on the remote shell."""
from __future__ import annotations
import glob
import json
import os
import re
import secrets
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
SSH_HOST = os.environ.get("CMUX_SSH_TEST_HOST", "").strip()
SSH_PORT = os.environ.get("CMUX_SSH_TEST_PORT", "").strip()
SSH_IDENTITY = os.environ.get("CMUX_SSH_TEST_IDENTITY", "").strip()
SSH_OPTIONS_RAW = os.environ.get("CMUX_SSH_TEST_OPTIONS", "").strip()
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _run(cmd: list[str], *, env: dict[str, str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
proc = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
if check and proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"Command failed ({' '.join(cmd)}): {merged}")
return proc
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_cli_json(cli: str, args: list[str]) -> dict:
env = dict(os.environ)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
env.pop("CMUX_TAB_ID", None)
proc = _run([cli, "--socket", SOCKET_PATH, "--json", *args], env=env)
try:
return json.loads(proc.stdout or "{}")
except Exception as exc: # noqa: BLE001
raise cmuxError(f"Invalid JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})")
def _wait_for(pred, timeout_s: float = 8.0, step_s: float = 0.1) -> None:
deadline = time.time() + timeout_s
while time.time() < deadline:
if pred():
return
time.sleep(step_s)
raise cmuxError("Timed out waiting for condition")
def _wait_remote_ready(client: cmux, workspace_id: str, timeout_s: float = 45.0) -> None:
deadline = time.time() + timeout_s
last_status = {}
while time.time() < deadline:
last_status = client._call("workspace.remote.status", {"workspace_id": workspace_id}) or {}
remote = last_status.get("remote") or {}
daemon = remote.get("daemon") or {}
if str(remote.get("state") or "") == "connected" and str(daemon.get("state") or "") == "ready":
return
time.sleep(0.25)
raise cmuxError(f"Remote did not become ready for {workspace_id}: {last_status}")
def _resolve_workspace_id(client: cmux, payload: dict, *, before_workspace_ids: set[str]) -> str:
workspace_id = str(payload.get("workspace_id") or "")
if workspace_id:
return workspace_id
workspace_ref = str(payload.get("workspace_ref") or "")
if workspace_ref.startswith("workspace:"):
listed = client._call("workspace.list", {}) or {}
for row in listed.get("workspaces") or []:
if str(row.get("ref") or "") == workspace_ref:
resolved = str(row.get("id") or "")
if resolved:
return resolved
current = {wid for _index, wid, _title, _focused in client.list_workspaces()}
new_ids = sorted(current - before_workspace_ids)
if len(new_ids) == 1:
return new_ids[0]
raise cmuxError(f"Unable to resolve workspace_id from payload: {payload}")
def _focused_surface_id(client: cmux) -> str:
ident = client.identify()
focused = ident.get("focused") or {}
surface_id = str(focused.get("surface_id") or "")
if not surface_id:
raise cmuxError(f"Missing focused surface in identify payload: {ident}")
return surface_id
def _run_remote_shell_probe(client: cmux, surface_id: str, probe_label: str) -> str:
token = f"__CMUX_REMOTE_SOCKET_{probe_label}_{secrets.token_hex(4)}__"
client.send_surface(
surface_id,
(
f"__cmux_socket_path=\"${{CMUX_SOCKET_PATH:-}}\"; "
f"printf '{token}:%s:__CMUX_REMOTE_SOCKET_END__\\n' \"$__cmux_socket_path\"\n"
),
)
deadline = time.time() + 15.0
last = ""
pattern = re.compile(re.escape(token) + r":(.*?):__CMUX_REMOTE_SOCKET_END__")
while time.time() < deadline:
last = client.read_terminal_text(surface_id)
matches = pattern.findall(last)
if matches:
for candidate in reversed(matches):
cleaned = candidate.strip()
if cleaned and cleaned != "%s":
return cleaned
time.sleep(0.15)
raise cmuxError(f"Timed out waiting for socket token {token!r}: {last[-1200:]!r}")
def _assert_remote_socket_path(client: cmux, surface_id: str, shortcut_name: str) -> None:
socket_path = _run_remote_shell_probe(client, surface_id, shortcut_name)
_must(
socket_path.startswith("127.0.0.1:"),
f"{shortcut_name} should keep the new terminal on the ssh relay, got CMUX_SOCKET_PATH={socket_path!r}",
)
def _open_ssh_workspace(client: cmux, cli: str, *, name: str) -> str:
before_workspace_ids = {wid for _index, wid, _title, _focused in client.list_workspaces()}
ssh_args = ["ssh", SSH_HOST, "--name", name]
if SSH_PORT:
ssh_args.extend(["--port", SSH_PORT])
if SSH_IDENTITY:
ssh_args.extend(["--identity", SSH_IDENTITY])
if SSH_OPTIONS_RAW:
for option in SSH_OPTIONS_RAW.split(","):
trimmed = option.strip()
if trimmed:
ssh_args.extend(["--ssh-option", trimmed])
payload = _run_cli_json(cli, ssh_args)
workspace_id = _resolve_workspace_id(client, payload, before_workspace_ids=before_workspace_ids)
_wait_remote_ready(client, workspace_id)
client.select_workspace(workspace_id)
_wait_for(lambda: client.current_workspace() == workspace_id, timeout_s=8.0)
return workspace_id
def _assert_shortcut_creates_remote_terminal(
client: cmux,
workspace_id: str,
shortcut: str,
shortcut_name: str,
*,
expect_new_pane: bool,
) -> None:
before_surfaces = {sid for _index, sid, _focused in client.list_surfaces(workspace_id)}
before_pane_count = len(client.list_panes())
client.activate_app()
client.simulate_app_active()
client.simulate_shortcut(shortcut)
_wait_for(
lambda: len({sid for _index, sid, _focused in client.list_surfaces(workspace_id)} - before_surfaces) == 1,
timeout_s=12.0,
)
if expect_new_pane:
_wait_for(lambda: len(client.list_panes()) >= before_pane_count + 1, timeout_s=12.0)
after_surfaces = {sid for _index, sid, _focused in client.list_surfaces(workspace_id)}
new_surface_ids = sorted(after_surfaces - before_surfaces)
_must(len(new_surface_ids) == 1, f"{shortcut_name} should create exactly one new surface: {new_surface_ids}")
focused_surface_id = _focused_surface_id(client)
_must(
focused_surface_id == new_surface_ids[0],
f"{shortcut_name} should focus the new terminal surface: focused={focused_surface_id!r} new={new_surface_ids[0]!r}",
)
_assert_remote_socket_path(client, focused_surface_id, shortcut_name)
def main() -> int:
if not SSH_HOST:
print("SKIP: set CMUX_SSH_TEST_HOST to run ssh shortcut inheritance regression")
return 0
cli = _find_cli_binary()
workspace_ids: list[str] = []
try:
with cmux(SOCKET_PATH) as client:
workspace_id = _open_ssh_workspace(
client,
cli,
name=f"ssh-shortcut-cmdt-{secrets.token_hex(4)}",
)
workspace_ids.append(workspace_id)
_assert_shortcut_creates_remote_terminal(
client,
workspace_id,
"cmd+t",
"cmd+t",
expect_new_pane=False,
)
workspace_id = _open_ssh_workspace(
client,
cli,
name=f"ssh-shortcut-cmdd-{secrets.token_hex(4)}",
)
workspace_ids.append(workspace_id)
_assert_shortcut_creates_remote_terminal(
client,
workspace_id,
"cmd+d",
"cmd+d",
expect_new_pane=True,
)
workspace_id = _open_ssh_workspace(
client,
cli,
name=f"ssh-shortcut-cmdshiftd-{secrets.token_hex(4)}",
)
workspace_ids.append(workspace_id)
_assert_shortcut_creates_remote_terminal(
client,
workspace_id,
"cmd+shift+d",
"cmd+shift+d",
expect_new_pane=True,
)
finally:
if workspace_ids:
try:
with cmux(SOCKET_PATH) as client:
for workspace_id in workspace_ids:
try:
client._call("workspace.close", {"workspace_id": workspace_id})
except Exception:
pass
except Exception:
pass
print("PASS: cmd+t/cmd+d/cmd+shift+d keep ssh terminals on the remote relay")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,86 +0,0 @@
#!/usr/bin/env python3
"""Regression: workspace.create must apply initial_env to the initial terminal."""
import os
import sys
import time
import base64
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _wait_for_text(c: cmux, workspace_id: str, needle: str, timeout_s: float = 8.0) -> str:
deadline = time.time() + timeout_s
last_text = ""
while time.time() < deadline:
payload = c._call(
"surface.read_text",
{"workspace_id": workspace_id},
) or {}
if "text" in payload:
last_text = str(payload.get("text") or "")
else:
b64 = str(payload.get("base64") or "")
raw = base64.b64decode(b64) if b64 else b""
last_text = raw.decode("utf-8", errors="replace")
if needle in last_text:
return last_text
time.sleep(0.1)
raise cmuxError(f"Timed out waiting for {needle!r} in panel text: {last_text!r}")
def main() -> int:
with cmux(SOCKET_PATH) as c:
baseline_workspace = c.current_workspace()
created_workspace = ""
try:
token = f"tok_{int(time.time() * 1000)}"
payload = c._call(
"workspace.create",
{
"initial_env": {"CMUX_INITIAL_ENV_TOKEN": token},
},
) or {}
created_workspace = str(payload.get("workspace_id") or "")
_must(bool(created_workspace), f"workspace.create returned no workspace_id: {payload}")
_must(c.current_workspace() == baseline_workspace, "workspace.create should not steal workspace focus")
# Terminal surfaces in background workspaces may not be attached/render-ready yet.
# Select it before reading text so the initial command output is available.
c.select_workspace(created_workspace)
listed = c._call("surface.list", {"workspace_id": created_workspace}) or {}
rows = list(listed.get("surfaces") or [])
_must(bool(rows), "Expected at least one surface in the created workspace")
terminal_row = next((row for row in rows if str(row.get("type") or "") == "terminal"), None)
_must(terminal_row is not None, f"Expected a terminal surface in workspace.create result: {rows}")
c.send("printf 'CMUX_ENV_CHECK=%s\\n' \"$CMUX_INITIAL_ENV_TOKEN\"\\n")
text = _wait_for_text(c, created_workspace, f"CMUX_ENV_CHECK={token}")
_must(
f"CMUX_ENV_CHECK={token}" in text,
f"initial_env token missing from terminal output: {text!r}",
)
c.select_workspace(baseline_workspace)
finally:
if created_workspace:
try:
c.close_workspace(created_workspace)
except Exception:
pass
print("PASS: workspace.create applies initial_env to initial terminal")
return 0
if __name__ == "__main__":
raise SystemExit(main())