diff --git a/Packages/macOS/CmuxComputerUse/Sources/CmuxComputerUse/ComputerUseHelperLaunchConfiguration.swift b/Packages/macOS/CmuxComputerUse/Sources/CmuxComputerUse/ComputerUseHelperLaunchConfiguration.swift index 60a84a22e648..403702f99f08 100644 --- a/Packages/macOS/CmuxComputerUse/Sources/CmuxComputerUse/ComputerUseHelperLaunchConfiguration.swift +++ b/Packages/macOS/CmuxComputerUse/Sources/CmuxComputerUse/ComputerUseHelperLaunchConfiguration.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import CmuxFoundation @@ -6,6 +7,29 @@ struct ComputerUseHelperLaunchConfiguration: Equatable, Sendable { let arguments: [String] let environment: [String: String] + @MainActor + func workspaceConfiguration( + helperURL: URL, + fileManager: FileManager = .default + ) -> NSWorkspace.OpenConfiguration? { + let executable = helperURL.appendingPathComponent("Contents/MacOS/cmux-cua") + guard + let attributes = try? fileManager.attributesOfItem(atPath: executable.path), + attributes[.type] as? FileAttributeType == .typeRegular, + fileManager.isExecutableFile(atPath: executable.path) + else { return nil } + + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = false + configuration.createsNewApplicationInstance = true + // Health checks retry in the background. Launch failures must reach the + // completion handler without waiting on another Finder error dialog. + configuration.promptsUserIfNeeded = false + configuration.arguments = arguments + configuration.environment = environment + return configuration + } + init?( paths: ComputerUseRuntimePaths, profile: ComputerUseDaemonProfile = .native, diff --git a/Packages/macOS/CmuxComputerUse/Sources/CmuxComputerUse/ComputerUseRuntimeService.swift b/Packages/macOS/CmuxComputerUse/Sources/CmuxComputerUse/ComputerUseRuntimeService.swift index 3875f0fe9093..ee3a350ef8d1 100644 --- a/Packages/macOS/CmuxComputerUse/Sources/CmuxComputerUse/ComputerUseRuntimeService.swift +++ b/Packages/macOS/CmuxComputerUse/Sources/CmuxComputerUse/ComputerUseRuntimeService.swift @@ -1146,11 +1146,9 @@ public final class ComputerUseRuntimeService { return false } - let configuration = NSWorkspace.OpenConfiguration() - configuration.activates = false - configuration.createsNewApplicationInstance = true - configuration.arguments = launch.arguments - configuration.environment = launch.environment + guard let configuration = launch.workspaceConfiguration(helperURL: helperURL) else { + return false + } let launchedProcessIdentifier: pid_t? = await withCheckedContinuation { continuation in NSWorkspace.shared.openApplication( at: helperURL, diff --git a/Packages/macOS/CmuxComputerUse/Tests/CmuxComputerUseTests/ComputerUseHelperLaunchConfigurationTests.swift b/Packages/macOS/CmuxComputerUse/Tests/CmuxComputerUseTests/ComputerUseHelperLaunchConfigurationTests.swift new file mode 100644 index 000000000000..4307d2381677 --- /dev/null +++ b/Packages/macOS/CmuxComputerUse/Tests/CmuxComputerUseTests/ComputerUseHelperLaunchConfigurationTests.swift @@ -0,0 +1,70 @@ +import AppKit +import Foundation +import Testing +@testable import CmuxComputerUse + +@MainActor +struct ComputerUseHelperLaunchConfigurationTests { + @Test(arguments: ComputerUseDaemonProfile.allCases) + func backgroundLaunchDoesNotWaitForUserToDismissErrors( + profile: ComputerUseDaemonProfile + ) throws { + let fixture = try HelperBundleFixture() + defer { fixture.remove() } + let launch = try makeLaunch(fixture: fixture, profile: profile) + let configuration = try #require(launch.workspaceConfiguration(helperURL: fixture.bundle)) + + #expect(!configuration.promptsUserIfNeeded) + #expect(!configuration.activates) + #expect(configuration.createsNewApplicationInstance) + #expect(configuration.arguments == launch.arguments) + #expect(configuration.environment == launch.environment) + } + + @Test func nonExecutableHelperIsRejectedUntilPermissionsAreRepaired() throws { + let fixture = try HelperBundleFixture() + defer { fixture.remove() } + let launch = try makeLaunch(fixture: fixture) + try FileManager.default.setAttributes( + [.posixPermissions: 0o644], ofItemAtPath: fixture.executable.path + ) + + #expect(launch.workspaceConfiguration(helperURL: fixture.bundle) == nil) + #expect(!FileManager.default.isExecutableFile(atPath: fixture.executable.path)) + + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: fixture.executable.path + ) + #expect(launch.workspaceConfiguration(helperURL: fixture.bundle) != nil) + } + + @Test func missingExecutableAndDirectoryAreRejected() throws { + let fixture = try HelperBundleFixture() + defer { fixture.remove() } + let launch = try makeLaunch(fixture: fixture) + try FileManager.default.removeItem(at: fixture.executable) + #expect(launch.workspaceConfiguration(helperURL: fixture.bundle) == nil) + + try FileManager.default.createDirectory( + at: fixture.executable, withIntermediateDirectories: false + ) + #expect(launch.workspaceConfiguration(helperURL: fixture.bundle) == nil) + } + + private func makeLaunch( + fixture: HelperBundleFixture, + profile: ComputerUseDaemonProfile = .native + ) throws -> ComputerUseHelperLaunchConfiguration { + try #require(ComputerUseHelperLaunchConfiguration( + paths: ComputerUseRuntimePaths( + homeDirectoryURL: fixture.root, + socketRootDirectoryURL: fixture.root, + environment: [:], + bundleIdentifier: "com.cmuxterm.tests.helper-launch", + authenticationToken: "test-token", + hostAuthenticationToken: "test-host-token" + ), + profile: profile + )) + } +}