From c8bbcb575c259c2a626ecb4255a2373e61f7a123 Mon Sep 17 00:00:00 2001 From: Peyton Spencer Date: Fri, 19 Jun 2026 00:44:12 -0400 Subject: [PATCH] fix runner PATH for Homebrew tools --- Sources/Services/ProcessManager.swift | 16 ++---- Sources/Services/RunnerEnvironment.swift | 58 +++++++++++++++++++++ Sources/Services/RunnerInstaller.swift | 8 +++ Tests/MacRunnerTests/MacRunnerTests.swift | 63 +++++++++++++++++++++++ 4 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 Sources/Services/RunnerEnvironment.swift diff --git a/Sources/Services/ProcessManager.swift b/Sources/Services/ProcessManager.swift index 92cb6f2..9ed700f 100644 --- a/Sources/Services/ProcessManager.swift +++ b/Sources/Services/ProcessManager.swift @@ -48,19 +48,9 @@ class ProcessManager { proc.standardOutput = logHandle proc.standardError = logHandle - // Configure environment for headless mode (remove GUI access) - if !enableGUI { - var env = ProcessInfo.processInfo.environment - // Remove GUI-related environment variables - env.removeValue(forKey: "DISPLAY") - env.removeValue(forKey: "WAYLAND_DISPLAY") - env.removeValue(forKey: "XDG_SESSION_TYPE") - env.removeValue(forKey: "XDG_RUNTIME_DIR") - // Explicitly mark as headless - env["CI"] = "true" - env["HEADLESS"] = "true" - proc.environment = env - } + let env = RunnerEnvironment.environment(enableGUI: enableGUI) + try RunnerEnvironment.writePathSnapshot(in: workingDirectory, environment: env) + proc.environment = env do { try proc.run() diff --git a/Sources/Services/RunnerEnvironment.swift b/Sources/Services/RunnerEnvironment.swift new file mode 100644 index 0000000..2090e46 --- /dev/null +++ b/Sources/Services/RunnerEnvironment.swift @@ -0,0 +1,58 @@ +import Foundation + +enum RunnerEnvironment { + private static let preferredPathEntries = [ + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/local/sbin", + ] + + private static let fallbackPath = "/usr/bin:/bin:/usr/sbin:/sbin" + + static func environment( + from base: [String: String] = ProcessInfo.processInfo.environment, + enableGUI: Bool + ) -> [String: String] { + var environment = base + environment["PATH"] = normalizedPath(base["PATH"]) + + if !enableGUI { + environment.removeValue(forKey: "DISPLAY") + environment.removeValue(forKey: "WAYLAND_DISPLAY") + environment.removeValue(forKey: "XDG_SESSION_TYPE") + environment.removeValue(forKey: "XDG_RUNTIME_DIR") + environment["CI"] = "true" + environment["HEADLESS"] = "true" + } + + return environment + } + + static func normalizedPath(_ path: String?) -> String { + let sourcePath = path.flatMap { $0.isEmpty ? nil : $0 } ?? fallbackPath + let existingEntries = sourcePath + .split(separator: ":", omittingEmptySubsequences: true) + .map(String.init) + + var seen = Set() + var entries: [String] = [] + + for entry in preferredPathEntries + existingEntries where seen.insert(entry).inserted { + entries.append(entry) + } + + return entries.joined(separator: ":") + } + + static func writePathSnapshot( + in runnerDirectory: String, + environment: [String: String] = Self.environment(enableGUI: false) + ) throws { + let path = normalizedPath(environment["PATH"]) + let pathFile = URL(fileURLWithPath: runnerDirectory) + .appendingPathComponent(".path") + + try (path + "\n").write(to: pathFile, atomically: true, encoding: .utf8) + } +} diff --git a/Sources/Services/RunnerInstaller.swift b/Sources/Services/RunnerInstaller.swift index 0aa0a7d..7ce1b60 100644 --- a/Sources/Services/RunnerInstaller.swift +++ b/Sources/Services/RunnerInstaller.swift @@ -96,6 +96,7 @@ class RunnerInstaller { process = Process() process.executableURL = URL(fileURLWithPath: "/bin/bash") process.arguments = ["-c", configCommand] + process.environment = RunnerEnvironment.environment(enableGUI: false) case .dedicatedUser(let username): // Run config.sh as the service user @@ -115,6 +116,13 @@ class RunnerInstaller { throw InstallerError.configurationFailed(output) } + if isolation == .none || isolation == .container { + try RunnerEnvironment.writePathSnapshot( + in: directory, + environment: RunnerEnvironment.environment(enableGUI: false) + ) + } + print("Runner configured successfully") } diff --git a/Tests/MacRunnerTests/MacRunnerTests.swift b/Tests/MacRunnerTests/MacRunnerTests.swift index 744ee22..7648566 100644 --- a/Tests/MacRunnerTests/MacRunnerTests.swift +++ b/Tests/MacRunnerTests/MacRunnerTests.swift @@ -143,6 +143,69 @@ final class MacRunnerTests: XCTestCase { XCTAssertEqual(ResourceLimits.shellCommand("echo test", openFileLimit: 0), "echo test") } + func testRunnerEnvironmentPrependsHomebrewPathsToLaunchServicesPath() { + let path = RunnerEnvironment.normalizedPath("/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin") + + XCTAssertTrue(path.hasPrefix("/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:")) + XCTAssertEqual(path.components(separatedBy: ":").filter { $0 == "/usr/local/bin" }.count, 1) + } + + func testRunnerEnvironmentMarksHeadlessRuns() { + let environment = RunnerEnvironment.environment( + from: [ + "PATH": "/usr/bin:/bin", + "DISPLAY": ":0", + "WAYLAND_DISPLAY": "wayland-0", + "XDG_SESSION_TYPE": "x11", + "XDG_RUNTIME_DIR": "/tmp/runtime", + ], + enableGUI: false + ) + + XCTAssertNil(environment["DISPLAY"]) + XCTAssertNil(environment["WAYLAND_DISPLAY"]) + XCTAssertNil(environment["XDG_SESSION_TYPE"]) + XCTAssertNil(environment["XDG_RUNTIME_DIR"]) + XCTAssertEqual(environment["CI"], "true") + XCTAssertEqual(environment["HEADLESS"], "true") + XCTAssertTrue(environment["PATH"]?.contains("/opt/homebrew/bin") == true) + } + + func testRunnerEnvironmentPreservesGUIVariablesWhenEnabled() { + let environment = RunnerEnvironment.environment( + from: [ + "PATH": "/usr/bin:/bin", + "DISPLAY": ":0", + ], + enableGUI: true + ) + + XCTAssertEqual(environment["DISPLAY"], ":0") + XCTAssertNil(environment["CI"]) + XCTAssertNil(environment["HEADLESS"]) + } + + func testRunnerEnvironmentWritesPathSnapshot() throws { + let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + try RunnerEnvironment.writePathSnapshot( + in: temporaryDirectory.path, + environment: ["PATH": "/usr/bin:/bin"] + ) + + let written = try String( + contentsOf: temporaryDirectory.appendingPathComponent(".path"), + encoding: .utf8 + ) + XCTAssertEqual( + written, + "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin\n" + ) + } + func testAdministratorAuthenticationProcessUsesInteractiveTerminalHandles() { let process = UserIsolationService.makeAdministratorAuthenticationProcess()