diff --git a/Sources/SocketControlSettings.swift b/Sources/SocketControlSettings.swift index a2586136..da220b15 100644 --- a/Sources/SocketControlSettings.swift +++ b/Sources/SocketControlSettings.swift @@ -163,6 +163,8 @@ struct SocketControlSettings { static let legacyEnabledKey = "socketControlEnabled" static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE" static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD" + static let launchTagEnvKey = "CMUX_TAG" + static let baseDebugBundleIdentifier = "com.cmuxterm.app.debug" private static func normalizeMode(_ raw: String) -> String { raw @@ -211,6 +213,53 @@ struct SocketControlSettings { #endif } + static func launchTag( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String? { + guard let raw = environment[launchTagEnvKey] else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + static func shouldBlockUntaggedDebugLaunch( + environment: [String: String] = ProcessInfo.processInfo.environment, + bundleIdentifier: String? = Bundle.main.bundleIdentifier, + isDebugBuild: Bool = SocketControlSettings.isDebugBuild + ) -> Bool { + guard isDebugBuild else { return false } + if isRunningUnderXCTest(environment: environment) { + return false + } + + guard let bundleIdentifier = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), + !bundleIdentifier.isEmpty else { + return false + } + + if bundleIdentifier.hasPrefix("\(baseDebugBundleIdentifier).") { + return false + } + + guard bundleIdentifier == baseDebugBundleIdentifier else { + return false + } + + return launchTag(environment: environment) == nil + } + + static func isRunningUnderXCTest(environment: [String: String]) -> Bool { + let indicators = [ + "XCTestConfigurationFilePath", + "XCTestBundlePath", + "XCTestSessionIdentifier", + "XCInjectBundleInto", + ] + return indicators.contains { key in + guard let value = environment[key] else { return false } + return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + static func socketPath( environment: [String: String] = ProcessInfo.processInfo.environment, bundleIdentifier: String? = Bundle.main.bundleIdentifier, diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index f857ffa0..004406e7 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -35,6 +35,10 @@ struct cmuxApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { + if SocketControlSettings.shouldBlockUntaggedDebugLaunch() { + Self.terminateForMissingLaunchTag() + } + Self.configureGhosttyEnvironment() let startupAppearance = AppearanceSettings.resolvedMode() @@ -58,6 +62,14 @@ struct cmuxApp: App { appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState) } + private static func terminateForMissingLaunchTag() -> Never { + let message = "error: refusing to launch untagged cmux DEV; start with ./scripts/reload.sh --tag (or set CMUX_TAG for test harnesses)" + fputs("\(message)\n", stderr) + fflush(stderr) + NSLog("%@", message) + Darwin.exit(64) + } + private static func configureGhosttyEnvironment() { let fileManager = FileManager.default let ghosttyAppResources = "/Applications/Ghostty.app/Contents/Resources/ghostty" diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 220767ba..a788043b 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -691,6 +691,56 @@ final class SocketControlSettingsTests: XCTestCase { "/tmp/cmux-staging.sock" ) } + + func testUntaggedDebugBundleBlockedWithoutLaunchTag() { + XCTAssertTrue( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: [:], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } + + func testUntaggedDebugBundleAllowedWithLaunchTag() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: ["CMUX_TAG": "tests-v1"], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } + + func testTaggedDebugBundleAllowedWithoutLaunchTag() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: [:], + bundleIdentifier: "com.cmuxterm.app.debug.tests-v1", + isDebugBuild: true + ) + ) + } + + func testReleaseBuildIgnoresLaunchTagGate() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: [:], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: false + ) + ) + } + + func testXCTestLaunchIgnoresLaunchTagGate() { + XCTAssertFalse( + SocketControlSettings.shouldBlockUntaggedDebugLaunch( + environment: ["XCTestConfigurationFilePath": "/tmp/fake.xctestconfiguration"], + bundleIdentifier: "com.cmuxterm.app.debug", + isDebugBuild: true + ) + ) + } } final class PostHogAnalyticsPropertiesTests: XCTestCase { diff --git a/scripts/run-tests-v1.sh b/scripts/run-tests-v1.sh index 317d19cf..12592e70 100755 --- a/scripts/run-tests-v1.sh +++ b/scripts/run-tests-v1.sh @@ -13,6 +13,7 @@ cd "$(dirname "$0")/.." DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v1" APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app" +RUN_TAG="tests-v1" echo "== build ==" # Work around stale explicit-module cache artifacts (notably Sentry headers) that can @@ -51,7 +52,7 @@ launch_and_wait() { defaults write com.cmuxterm.app.debug socketControlMode -string full >/dev/null 2>&1 || true # Launch directly with UI test mode enabled so startup follows deterministic test codepaths. - CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 & + CMUX_TAG="$RUN_TAG" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 & SOCK="" for _ in {1..120}; do @@ -70,7 +71,7 @@ launch_and_wait() { export CMUX_SOCKET="$SOCK" # Ensure LaunchServices has a visible/main window attached for rendering checks. - open "$APP" >/dev/null 2>&1 || true + CMUX_TAG="$RUN_TAG" open "$APP" >/dev/null 2>&1 || true sleep 0.5 echo "== wait ready ==" diff --git a/scripts/run-tests-v2.sh b/scripts/run-tests-v2.sh index 4be4e854..e17cc6c2 100755 --- a/scripts/run-tests-v2.sh +++ b/scripts/run-tests-v2.sh @@ -13,6 +13,7 @@ cd "$(dirname "$0")/.." DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v2" APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app" +RUN_TAG="tests-v2" echo "== build ==" # Work around stale explicit-module cache artifacts (notably Sentry headers) that can @@ -51,7 +52,7 @@ launch_and_wait() { defaults write com.cmuxterm.app.debug socketControlMode -string full >/dev/null 2>&1 || true # Launch directly with UI test mode enabled so startup follows deterministic test codepaths. - CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 & + CMUX_TAG="$RUN_TAG" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 & SOCK="" for _ in {1..120}; do @@ -70,7 +71,7 @@ launch_and_wait() { export CMUX_SOCKET="$SOCK" # Ensure LaunchServices has a visible/main window attached for rendering checks. - open "$APP" >/dev/null 2>&1 || true + CMUX_TAG="$RUN_TAG" open "$APP" >/dev/null 2>&1 || true sleep 0.5 echo "== wait ready =="