import Foundation #if canImport(Darwin) import Darwin #elseif canImport(Glibc) import Glibc #elseif canImport(Musl) import Musl #endif #if os(macOS) import Security #endif public enum PathPurpose: Hashable, Sendable { case rpc case tty case nodeTooling } public struct PathDebugSnapshot: Equatable, Sendable { public let codexBinary: String? public let claudeBinary: String? public let geminiBinary: String? public let effectivePATH: String public let loginShellPATH: String? public static let empty = PathDebugSnapshot( codexBinary: nil, claudeBinary: nil, geminiBinary: nil, effectivePATH: "", loginShellPATH: nil) public init( codexBinary: String?, claudeBinary: String?, geminiBinary: String? = nil, effectivePATH: String, loginShellPATH: String?) { self.codexBinary = codexBinary self.claudeBinary = claudeBinary self.geminiBinary = geminiBinary self.effectivePATH = effectivePATH self.loginShellPATH = loginShellPATH } } public enum BinaryLocator { /// Test-only override so parallel Gemini suites can point at fake binaries /// without mutating process-wide `GEMINI_CLI_PATH`. @TaskLocal public static var geminiBinaryPathOverrideForTesting: String? public static func resolveClaudeBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV, aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator .resolveAlias, fileManager: FileManager = .default, home: String = NSHomeDirectory()) -> String? { self.resolveBinary( name: "claude", overrideKey: "CLAUDE_CLI_PATH", env: env, loginPATH: loginPATH, commandV: commandV, aliasResolver: aliasResolver, wellKnownPaths: self.claudeWellKnownPaths(home: home), fileManager: fileManager, home: home) } /// Well-known installation paths for the Claude CLI binary. /// Covers Anthropic's native installer (`~/.local/bin`), the `claude migrate-installer` /// self-updating location (`~/.claude/local`), the legacy per-user installer /// (`~/.claude/bin`), Homebrew, and the macOS Terminal installer (cmux.app). static func claudeWellKnownPaths(home: String) -> [String] { [ "\(home)/.local/bin/claude", "\(home)/.claude/local/claude", "\(home)/.claude/bin/claude", "/opt/homebrew/bin/claude", "/usr/local/bin/claude", "/Applications/cmux.app/Contents/Resources/bin/claude", ] } public static func resolveAntigravityBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV, aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator .resolveAlias, fileManager: FileManager = .default, home: String = NSHomeDirectory()) -> String? { self.resolveBinary( name: "agy", overrideKey: "ANTIGRAVITY_CLI_PATH", env: env, loginPATH: loginPATH, commandV: commandV, aliasResolver: aliasResolver, wellKnownPaths: [ "\(home)/.local/bin/agy", "/opt/homebrew/bin/agy", "/usr/local/bin/agy", ], fileManager: fileManager, home: home) } public static func resolveCodexBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV, aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator .resolveAlias, launchCandidateFilter: (String, FileManager) -> Bool = CodexLaunchPreflight.isLaunchCandidateAllowed, fileManager: FileManager = .default, home: String = NSHomeDirectory()) -> String? { self.resolveBinary( name: "codex", overrideKey: "CODEX_CLI_PATH", env: env, loginPATH: loginPATH, commandV: commandV, aliasResolver: aliasResolver, wellKnownPaths: self.codexWellKnownPaths(home: home), launchCandidateFilter: launchCandidateFilter, fileManager: fileManager, home: home) } /// Well-known installation paths for the signed Codex CLI bundled with current and legacy desktop apps. /// Keep these after PATH lookups, but use them as a safe fallback when a PATH shim is blocked. static func codexWellKnownPaths(home: String) -> [String] { #if os(macOS) [ "\(home)/Applications/ChatGPT.app/Contents/Resources/codex", "\(home)/Applications/Codex.app/Contents/Resources/codex", "/Applications/ChatGPT.app/Contents/Resources/codex", "/Applications/Codex.app/Contents/Resources/codex", ] #else [] #endif } public static func resolveGeminiBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV, aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator .resolveAlias, fileManager: FileManager = .default, home: String = NSHomeDirectory()) -> String? { if let override = self.geminiBinaryPathOverrideForTesting, fileManager.isExecutableFile(atPath: override) { return override } return self.resolveBinary( name: "gemini", overrideKey: "GEMINI_CLI_PATH", env: env, loginPATH: loginPATH, commandV: commandV, aliasResolver: aliasResolver, fileManager: fileManager, home: home) } public static func resolveGrokBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV, aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator .resolveAlias, fileManager: FileManager = .default, home: String = NSHomeDirectory()) -> String? { self.resolveBinary( name: "grok", overrideKey: "GROK_CLI_PATH", env: env, loginPATH: loginPATH, commandV: commandV, aliasResolver: aliasResolver, wellKnownPaths: self.grokWellKnownPaths(home: home), fileManager: fileManager, home: home) } public static func resolveAmpBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV, aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator .resolveAlias, fileManager: FileManager = .default, home: String = NSHomeDirectory()) -> String? { self.resolveBinary( name: "amp", overrideKey: "AMP_CLI_PATH", env: env, loginPATH: loginPATH, commandV: commandV, aliasResolver: aliasResolver, wellKnownPaths: self.ampWellKnownPaths(home: home), fileManager: fileManager, home: home) } static func ampWellKnownPaths(home: String) -> [String] { [ "\(home)/.local/bin/amp", "\(home)/.amp/bin/amp", "/opt/homebrew/bin/amp", "/usr/local/bin/amp", ] } /// Well-known install locations for the Grok Build CLI binary. /// Covers the installer's default (`~/.grok/bin/grok`) and the symlinks it sometimes /// creates into `~/.local/bin` and `/usr/local/bin`. static func grokWellKnownPaths(home: String) -> [String] { [ "\(home)/.grok/bin/grok", "\(home)/.local/bin/grok", "/usr/local/bin/grok", "/opt/homebrew/bin/grok", ] } public static func resolveAWSBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV, aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator .resolveAlias, fileManager: FileManager = .default, home: String = NSHomeDirectory()) -> String? { self.resolveBinary( name: "aws", overrideKey: "AWS_CLI_PATH", env: env, loginPATH: loginPATH, commandV: commandV, aliasResolver: aliasResolver, wellKnownPaths: self.awsWellKnownPaths(home: home), fileManager: fileManager, home: home) } /// Well-known install locations for the AWS CLI v2 (`aws`). /// Covers Homebrew (Apple Silicon + Intel) and the per-user pip/uv install path. static func awsWellKnownPaths(home: String) -> [String] { [ "/opt/homebrew/bin/aws", "/usr/local/bin/aws", "\(home)/.local/bin/aws", ] } public static func resolveAuggieBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV, aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator .resolveAlias, fileManager: FileManager = .default, home: String = NSHomeDirectory()) -> String? { self.resolveBinary( name: "auggie", overrideKey: "AUGGIE_CLI_PATH", env: env, loginPATH: loginPATH, commandV: commandV, aliasResolver: aliasResolver, fileManager: fileManager, home: home) } // swiftlint:disable function_parameter_count private static func resolveBinary( name: String, overrideKey: String, env: [String: String], loginPATH: [String]?, commandV: (String, String?, TimeInterval, FileManager) -> String?, aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String?, wellKnownPaths: [String] = [], launchCandidateFilter: (String, FileManager) -> Bool = { _, _ in true }, fileManager: FileManager, home: String) -> String? { // swiftlint:enable function_parameter_count // 1) Explicit override if let override = env[overrideKey], fileManager.isExecutableFile(atPath: override) { return override } // 2) Login-shell PATH (captured once per launch) if let loginPATH, let pathHit = self.find( name, in: loginPATH, fileManager: fileManager, launchCandidateFilter: launchCandidateFilter) { return pathHit } // 3) Existing PATH if let existingPATH = env["PATH"], let pathHit = self.find( name, in: existingPATH.split(separator: ":").map(String.init), fileManager: fileManager, launchCandidateFilter: launchCandidateFilter) { return pathHit } // 4) Well-known installation paths (e.g. Homebrew, cmux.app bundle, ~/.claude/bin). // Prefer these before shell probing to avoid running interactive shell init for common installs. for candidate in wellKnownPaths where fileManager.isExecutableFile(atPath: candidate) && launchCandidateFilter(candidate, fileManager) { return candidate } // 5) Interactive login shell lookup (captures nvm/fnm/mise paths from .zshrc/.bashrc) if let shellHit = commandV(name, env["SHELL"], 2.0, fileManager), fileManager.isExecutableFile(atPath: shellHit), launchCandidateFilter(shellHit, fileManager) { return shellHit } // 5b) Alias fallback (login shell); only attempt after all standard lookups fail. if let aliasHit = aliasResolver(name, env["SHELL"], 2.0, fileManager, home), fileManager.isExecutableFile(atPath: aliasHit), launchCandidateFilter(aliasHit, fileManager) { return aliasHit } // 6) Minimal fallback let fallback = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] if let pathHit = self.find( name, in: fallback, fileManager: fileManager, launchCandidateFilter: launchCandidateFilter) { return pathHit } return nil } private static func find( _ binary: String, in paths: [String], fileManager: FileManager, launchCandidateFilter: (String, FileManager) -> Bool = { _, _ in true }) -> String? { for path in paths where !path.isEmpty { let candidate = "\(path.hasSuffix("/") ? String(path.dropLast()) : path)/\(binary)" if fileManager.isExecutableFile(atPath: candidate), launchCandidateFilter(candidate, fileManager) { return candidate } } return nil } } public enum CodexLaunchPreflight { struct GatekeeperAssessment { let output: String let exitStatus: Int32 } public static func isLaunchCandidateAllowed(path: String, fileManager: FileManager = .default) -> Bool { #if os(macOS) self.isLaunchCandidateAllowed( path: path, fileManager: fileManager, hasExtendedAttribute: self.hasExtendedAttribute, spctlAssessment: { self.spctlAssessment(path: $0) }, appSignatureIsTrusted: self.isExpectedOpenAIAppSignature, isMachOExecutable: self.isMachOExecutable) #else _ = path _ = fileManager return true #endif } #if os(macOS) // Keep each security boundary injectable so preflight tests never inspect or launch host binaries. // swiftlint:disable:next function_parameter_count static func isLaunchCandidateAllowed( path: String, fileManager: FileManager, hasExtendedAttribute: (String, String) -> Bool, spctlAssessment: (String) -> GatekeeperAssessment?, appSignatureIsTrusted: (String) -> Bool, isMachOExecutable: (String) -> Bool) -> Bool { let realPath = URL(fileURLWithPath: path).resolvingSymlinksInPath().path let sourceAppBundlePath = self.containingAppBundlePath(for: path) let resolvedAppBundlePath = self.containingAppBundlePath(for: realPath) let appBundlePath: String? if let sourceAppBundlePath { let resolvedSourceBundle = URL(fileURLWithPath: sourceAppBundlePath).resolvingSymlinksInPath().path guard resolvedAppBundlePath == resolvedSourceBundle else { return false } appBundlePath = resolvedSourceBundle } else { appBundlePath = resolvedAppBundlePath } let appBundlePaths = [sourceAppBundlePath, appBundlePath].compactMap(\.self) let pathsToCheck = [path, realPath] + appBundlePaths + self .nativeCodexExecutableCandidates( for: realPath, fileManager: fileManager) for candidate in Set(pathsToCheck) where hasExtendedAttribute(candidate, "com.apple.malware") { return false } let hasQuarantine = Set(pathsToCheck).contains { hasExtendedAttribute($0, "com.apple.quarantine") } if let appBundlePath { guard appSignatureIsTrusted(appBundlePath), let assessment = spctlAssessment(appBundlePath), assessment.exitStatus == 0, self.isAcceptedAssessment(assessment.output, path: appBundlePath), !self.isExplicitlyBlockedAssessment(assessment.output, path: appBundlePath) else { return false } return true } guard let native = pathsToCheck.first(where: isMachOExecutable) else { return !hasQuarantine } guard let assessment = spctlAssessment(native) else { return !hasQuarantine } return !self.isExplicitlyBlockedAssessment(assessment.output, path: native) } private static func containingAppBundlePath(for path: String) -> String? { var candidate = URL(fileURLWithPath: path).standardizedFileURL while candidate.path != "/" { if candidate.pathExtension.caseInsensitiveCompare("app") == .orderedSame { return candidate.path } let parent = candidate.deletingLastPathComponent() guard parent.path != candidate.path else { return nil } candidate = parent } return nil } private static func nativeCodexExecutableCandidates(for path: String, fileManager: FileManager) -> [String] { let url = URL(fileURLWithPath: path) guard url.lastPathComponent == "codex.js" else { return [] } let packageRoot = url.deletingLastPathComponent().deletingLastPathComponent() return self.npmNativeCodexCandidates(packageRoot: packageRoot) .map(\.path) .filter { fileManager.isExecutableFile(atPath: $0) } } private static func npmNativeCodexCandidates(packageRoot: URL) -> [URL] { guard let target = self.darwinCodexTarget else { return [] } let optionalPackage = packageRoot .appendingPathComponent("node_modules") .appendingPathComponent("@openai") .appendingPathComponent(target.packageName) return [ optionalPackage, packageRoot, ].map { $0.appendingPathComponent("vendor") .appendingPathComponent(target.triple) .appendingPathComponent("codex") .appendingPathComponent("codex") } } private static var darwinCodexTarget: (packageName: String, triple: String)? { #if arch(arm64) ("codex-darwin-arm64", "aarch64-apple-darwin") #elseif arch(x86_64) ("codex-darwin-x64", "x86_64-apple-darwin") #else nil #endif } private static func hasExtendedAttribute(path: String, name: String) -> Bool { path.withCString { pathPointer in name.withCString { namePointer in getxattr(pathPointer, namePointer, nil, 0, 0, 0) >= 0 } } } private static func isMachOExecutable(path: String) -> Bool { guard let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) else { return false } defer { try? handle.close() } guard let data = try? handle.read(upToCount: 4), data.count == 4 else { return false } let bytes = [UInt8](data) return bytes == [0xFE, 0xED, 0xFA, 0xCE] || bytes == [0xCE, 0xFA, 0xED, 0xFE] || bytes == [0xFE, 0xED, 0xFA, 0xCF] || bytes == [0xCF, 0xFA, 0xED, 0xFE] || bytes == [0xCA, 0xFE, 0xBA, 0xBE] || bytes == [0xCA, 0xFE, 0xBA, 0xBF] } private static func spctlAssessment(path: String, timeout: TimeInterval = 2.0) -> GatekeeperAssessment? { let spctlPath = "/usr/sbin/spctl" guard FileManager.default.isExecutableFile(atPath: spctlPath) else { return nil } let process = Process() process.executableURL = URL(fileURLWithPath: spctlPath) process.arguments = ["--assess", "--type", "execute", "--verbose=4", path] let output = Pipe() process.standardOutput = output process.standardError = output let finished = DispatchSemaphore(value: 0) process.terminationHandler = { _ in finished.signal() } do { try process.run() } catch { return nil } if finished.wait(timeout: .now() + timeout) != .success { process.terminate() return nil } let data = output.fileHandleForReading.readDataToEndOfFile() guard let text = String(data: data, encoding: .utf8) else { return nil } return GatekeeperAssessment(output: text, exitStatus: process.terminationStatus) } private static func isExpectedOpenAIAppSignature(path: String) -> Bool { let requirementText = "identifier \"com.openai.codex\" and anchor apple generic " + "and certificate leaf[subject.OU] = \"2DC432GLL2\"" var staticCode: SecStaticCode? guard SecStaticCodeCreateWithPath( URL(fileURLWithPath: path) as CFURL, SecCSFlags(), &staticCode) == errSecSuccess, let staticCode else { return false } var requirement: SecRequirement? guard SecRequirementCreateWithString( requirementText as CFString, SecCSFlags(), &requirement) == errSecSuccess else { return false } // Pin the publisher and bundle identity here; the Gatekeeper assessment below performs full bundle validation. let validationFlags = SecCSFlags(rawValue: kSecCSBasicValidateOnly) return SecStaticCodeCheckValidity(staticCode, validationFlags, requirement) == errSecSuccess } private static func isAcceptedAssessment(_ assessment: String, path: String) -> Bool { self.assessmentDiagnosticText(assessment, path: path) .split(whereSeparator: \.isNewline) .first? .trimmingCharacters(in: .whitespacesAndNewlines) .localizedCaseInsensitiveCompare("accepted") == .orderedSame } private static func isExplicitlyBlockedAssessment(_ assessment: String, path: String) -> Bool { let lower = self.assessmentDiagnosticText(assessment, path: path).lowercased() if lower.contains("denied") || lower.contains("cssmerr_tp_cert_revoked") || lower.contains("revoked") || lower.contains("malware") || lower.contains("quarantine") { return true } if lower.contains("rejected") { return !lower.contains("code is valid but does not seem to be an app") } return false } private static func assessmentDiagnosticText(_ assessment: String, path: String) -> String { assessment .split(whereSeparator: \.isNewline) .enumerated() .compactMap { offset, line -> String? in var text = line.trimmingCharacters(in: .whitespacesAndNewlines) if offset == 0, text.hasPrefix("\(path):") { text = String(text.dropFirst(path.count + 1)) .trimmingCharacters(in: .whitespacesAndNewlines) } let lower = text.lowercased() guard !lower.hasPrefix("source="), !lower.hasPrefix("origin=") else { return nil } return text } .joined(separator: "\n") } #endif } public enum ShellCommandLocator { #if canImport(Darwin) private static let shellSpawnFlags = Int16(POSIX_SPAWN_SETSID) #else private static let shellSpawnFlags: Int16 = 0x80 // glibc/musl POSIX_SPAWN_SETSID. #endif static func test_runShellCommand( shell: String, arguments: [String], timeout: TimeInterval) -> Data? { self.runShellCommand(shell: shell, arguments: arguments, timeout: timeout) } static var test_shellSpawnFlags: Int16 { self.shellSpawnFlags } public static func commandV( _ tool: String, _ shell: String?, _ timeout: TimeInterval, _ fileManager: FileManager) -> String? { let text = self.runShellCapture(shell, timeout, "command -v \(tool)")? .trimmingCharacters(in: .whitespacesAndNewlines) guard let text, !text.isEmpty else { return nil } let lines = text.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } for line in lines.reversed() where line.hasPrefix("/") { let path = line if fileManager.isExecutableFile(atPath: path) { return path } } return nil } public static func resolveAlias( _ tool: String, _ shell: String?, _ timeout: TimeInterval, _ fileManager: FileManager, _ home: String) -> String? { let command = "alias \(tool) 2>/dev/null; type -a \(tool) 2>/dev/null" guard let text = self.runShellCapture(shell, timeout, command) else { return nil } let lines = text.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } if let aliasPath = self.parseAliasPath(lines, tool: tool, home: home, fileManager: fileManager) { return aliasPath } for line in lines { if let path = self.extractPathCandidate(line: line, tool: tool, home: home), fileManager.isExecutableFile(atPath: path) { return path } } return nil } /// Thread-safe buffer for collecting pipe output from a readability handler. private final class CapturedData: @unchecked Sendable { private let lock = NSLock() private var data = Data() func append(_ other: Data) { self.lock.lock() self.data.append(other) self.lock.unlock() } func drain() -> Data { self.lock.lock() let result = self.data self.lock.unlock() return result } } /// Idempotent one-shot flag — `fire()` returns true exactly once. /// Used to make `DispatchGroup.leave()` safe to attempt from multiple paths. private final class OnceFlag: @unchecked Sendable { private let lock = NSLock() private var fired = false func fire() -> Bool { self.lock.lock() defer { self.lock.unlock() } if self.fired { return false } self.fired = true return true } } // swiftlint:disable cyclomatic_complexity function_body_length /// Runs a shell command, draining both stdout and stderr concurrently so that /// verbose shell init scripts (oh-my-zsh, nvm, pyenv, etc.) cannot deadlock on /// a full pipe buffer. The child is launched via `posix_spawn` with /// `POSIX_SPAWN_SETSID` so it cannot take ownership of the caller's controlling /// terminal. The new session also makes the child its own process-group leader /// before `exec`, which guarantees that subsequent `kill(-pgid, ...)` calls reach /// background helpers spawned by shell init, both after timeout and normal exit. fileprivate static func runShellCommand( shell: String, arguments: [String], timeout: TimeInterval) -> Data? { // Pipes for stdout/stderr. stdin is redirected from /dev/null in the child // via posix_spawn_file_actions_addopen below. var stdoutFds: (read: Int32, write: Int32) = (-1, -1) var stderrFds: (read: Int32, write: Int32) = (-1, -1) guard withUnsafeMutablePointer(to: &stdoutFds, { $0.withMemoryRebound(to: Int32.self, capacity: 2) { pipe($0) == 0 } }) else { return nil } guard withUnsafeMutablePointer(to: &stderrFds, { $0.withMemoryRebound(to: Int32.self, capacity: 2) { pipe($0) == 0 } }) else { close(stdoutFds.read); close(stdoutFds.write) return nil } // Build file actions: redirect stdin from /dev/null, dup pipe write ends to // fds 1 and 2, and close every pipe fd in the child. The init pattern // differs between platforms because the typedef is an opaque pointer on // Darwin and a struct on Linux C modules. #if canImport(Darwin) var fileActions: posix_spawn_file_actions_t? #else var fileActions = posix_spawn_file_actions_t() #endif guard posix_spawn_file_actions_init(&fileActions) == 0 else { close(stdoutFds.read); close(stdoutFds.write) close(stderrFds.read); close(stderrFds.write) return nil } defer { posix_spawn_file_actions_destroy(&fileActions) } posix_spawn_file_actions_addopen(&fileActions, 0, "/dev/null", O_RDONLY, 0) posix_spawn_file_actions_adddup2(&fileActions, stdoutFds.write, 1) posix_spawn_file_actions_adddup2(&fileActions, stderrFds.write, 2) posix_spawn_file_actions_addclose(&fileActions, stdoutFds.read) posix_spawn_file_actions_addclose(&fileActions, stdoutFds.write) posix_spawn_file_actions_addclose(&fileActions, stderrFds.read) posix_spawn_file_actions_addclose(&fileActions, stderrFds.write) // Build attributes: detach the child into a new session before exec. This // prevents interactive shell startup from changing the caller's foreground // process group while retaining a stable process group for cleanup. #if canImport(Darwin) var attr: posix_spawnattr_t? #else var attr = posix_spawnattr_t() #endif guard posix_spawnattr_init(&attr) == 0 else { close(stdoutFds.read); close(stdoutFds.write) close(stderrFds.read); close(stderrFds.write) return nil } defer { posix_spawnattr_destroy(&attr) } guard posix_spawnattr_setflags(&attr, self.shellSpawnFlags) == 0 else { close(stdoutFds.read); close(stdoutFds.write) close(stderrFds.read); close(stderrFds.write) return nil } // Build argv (argv[0] is conventionally the executable path). var cArgs: [UnsafeMutablePointer?] = [] cArgs.append(strdup(shell)) for arg in arguments { cArgs.append(strdup(arg)) } cArgs.append(nil) defer { for p in cArgs { if let p { free(p) } } } // Inherit the parent environment. Build a NULL-terminated `KEY=VALUE` // array since `extern char **environ` isn't directly visible from Swift. var cEnv: [UnsafeMutablePointer?] = [] for (key, value) in ProcessInfo.processInfo.environment { cEnv.append(strdup("\(key)=\(value)")) } cEnv.append(nil) defer { for p in cEnv { if let p { free(p) } } } var pid: pid_t = 0 let spawnResult = shell.withCString { execPath in posix_spawn(&pid, execPath, &fileActions, &attr, cArgs, cEnv) } // Close the write ends in the parent so EOF will arrive on the read ends // once every descendant in the process group also closes them. close(stdoutFds.write) close(stderrFds.write) guard spawnResult == 0 else { close(stdoutFds.read); close(stderrFds.read) return nil } // POSIX_SPAWN_SETSID guarantees the child's session ID and pgid equal its pid. let pgid: pid_t = pid // Track EOF on each pipe so we can wait for full drain instead of sleeping. // The readability handler fires with empty data when every writer end is // closed (i.e. the child *and* any inheriting background helpers are gone). let drainGroup = DispatchGroup() drainGroup.enter() drainGroup.enter() let stdoutDone = OnceFlag() let stderrDone = OnceFlag() let stdoutCollector = CapturedData() let stdoutHandle = FileHandle(fileDescriptor: stdoutFds.read, closeOnDealloc: true) stdoutHandle.readabilityHandler = { handle in let data = handle.availableData if data.isEmpty { handle.readabilityHandler = nil if stdoutDone.fire() { drainGroup.leave() } } else { stdoutCollector.append(data) } } let stderrHandle = FileHandle(fileDescriptor: stderrFds.read, closeOnDealloc: true) stderrHandle.readabilityHandler = { handle in let data = handle.availableData if data.isEmpty { handle.readabilityHandler = nil if stderrDone.fire() { drainGroup.leave() } } } // Reap the child on a background queue and signal a semaphore on exit. let exitSemaphore = DispatchSemaphore(value: 0) let waitPid = pid DispatchQueue.global(qos: .userInitiated).async { var status: Int32 = 0 while waitpid(waitPid, &status, 0) == -1, errno == EINTR { // retry } exitSemaphore.signal() } let finishedInTime = exitSemaphore.wait(timeout: .now() + timeout) == .success if !finishedInTime { kill(-pgid, SIGTERM) kill(pid, SIGTERM) if exitSemaphore.wait(timeout: .now() + 0.4) != .success { kill(-pgid, SIGKILL) kill(pid, SIGKILL) _ = exitSemaphore.wait(timeout: .now() + 1.0) } stdoutHandle.readabilityHandler = nil stderrHandle.readabilityHandler = nil if stdoutDone.fire() { drainGroup.leave() } if stderrDone.fire() { drainGroup.leave() } return nil } // Normal completion — clean up any background children spawned by shell init. // Without this, helpers that inherited stdout/stderr keep the pipe write ends // open and we never see EOF on the read ends. kill(-pgid, SIGTERM) // Wait for both pipes to deliver EOF so no buffered bytes are lost. // Bounded so a stuck handler can't hang the caller indefinitely. if drainGroup.wait(timeout: .now() + 0.4) != .success { kill(-pgid, SIGKILL) } if drainGroup.wait(timeout: .now() + 0.6) != .success { stdoutHandle.readabilityHandler = nil stderrHandle.readabilityHandler = nil if stdoutDone.fire() { drainGroup.leave() } if stderrDone.fire() { drainGroup.leave() } } return stdoutCollector.drain() } // swiftlint:enable cyclomatic_complexity function_body_length private static func runShellCapture(_ shell: String?, _ timeout: TimeInterval, _ command: String) -> String? { let shellPath = (shell?.isEmpty == false) ? shell! : "/bin/zsh" let isCI = ["1", "true"].contains(ProcessInfo.processInfo.environment["CI"]?.lowercased()) // Interactive login shell to pick up PATH mutations from shell init (nvm/fnm/mise). // CI runners can have shell init hooks that emit missing CLI errors; avoid them in CI. let args = isCI ? ["-c", command] : ["-l", "-i", "-c", command] guard let data = runShellCommand(shell: shellPath, arguments: args, timeout: timeout) else { return nil } return String(data: data, encoding: .utf8) } private static func parseAliasPath( _ lines: [String], tool: String, home: String, fileManager: FileManager) -> String? { for line in lines { if line.hasPrefix("alias \(tool)=") { let value = line.replacingOccurrences(of: "alias \(tool)=", with: "") if let path = self.extractAliasExpansion(value, home: home), fileManager.isExecutableFile(atPath: path) { return path } } if line.lowercased().contains("aliased to") { if let range = line.range(of: "aliased to") { let value = line[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines) if let path = self.extractAliasExpansion(String(value), home: home), fileManager.isExecutableFile(atPath: path) { return path } } } } return nil } private static func extractAliasExpansion(_ raw: String, home: String) -> String? { let trimmed = raw.trimmingCharacters(in: CharacterSet(charactersIn: " \t\"'`")) guard !trimmed.isEmpty else { return nil } let parts = trimmed.split(separator: " ").map(String.init) guard let first = parts.first else { return nil } return self.expandPath(first, home: home) } private static func extractPathCandidate(line: String, tool: String, home: String) -> String? { let tokens = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) for token in tokens { let candidate = self.expandPath(token, home: home) if candidate.hasPrefix("/"), URL(fileURLWithPath: candidate).lastPathComponent == tool { return candidate } } return nil } private static func expandPath(_ raw: String, home: String) -> String { if raw == "~" { return home } if raw.hasPrefix("~/") { return home + String(raw.dropFirst()) } return raw } } public enum PathBuilder { public static func effectivePATH( purposes _: Set, env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, home _: String = NSHomeDirectory()) -> String { var parts: [String] = [] if let loginPATH, !loginPATH.isEmpty { parts.append(contentsOf: loginPATH) } if let existing = env["PATH"], !existing.isEmpty { parts.append(contentsOf: existing.split(separator: ":").map(String.init)) } if parts.isEmpty { parts.append(contentsOf: ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]) } var seen = Set() let deduped = parts.compactMap { part -> String? in guard !part.isEmpty else { return nil } if seen.insert(part).inserted { return part } return nil } return deduped.joined(separator: ":") } public static func debugSnapshot( purposes: Set, env: [String: String] = ProcessInfo.processInfo.environment, home: String = NSHomeDirectory()) -> PathDebugSnapshot { let login = LoginShellPathCache.shared.current let effective = self.effectivePATH( purposes: purposes, env: env, loginPATH: login, home: home) let codex = BinaryLocator.resolveCodexBinary(env: env, loginPATH: login, home: home) let claude = BinaryLocator.resolveClaudeBinary(env: env, loginPATH: login, home: home) let gemini = BinaryLocator.resolveGeminiBinary(env: env, loginPATH: login, home: home) let loginString = login?.joined(separator: ":") return PathDebugSnapshot( codexBinary: codex, claudeBinary: claude, geminiBinary: gemini, effectivePATH: effective, loginShellPATH: loginString) } public static func debugSnapshotAsync( purposes: Set, env: [String: String] = ProcessInfo.processInfo.environment, home: String = NSHomeDirectory()) async -> PathDebugSnapshot { await Task.detached(priority: .userInitiated) { self.debugSnapshot(purposes: purposes, env: env, home: home) }.value } } enum LoginShellPathCapturer { static let defaultTimeout: TimeInterval = 6.0 static func capture( shell: String? = ProcessInfo.processInfo.environment["SHELL"], timeout: TimeInterval = Self.defaultTimeout) -> [String]? { let shellPath = (shell?.isEmpty == false) ? shell! : "/bin/zsh" let isCI = ["1", "true"].contains(ProcessInfo.processInfo.environment["CI"]?.lowercased()) let marker = "__CODEXBAR_PATH__" // Skip interactive login shells in CI to avoid noisy init hooks. let args = isCI ? ["-c", "printf '\(marker)%s\(marker)' \"$PATH\""] : ["-l", "-i", "-c", "printf '\(marker)%s\(marker)' \"$PATH\""] guard let data = ShellCommandLocator.runShellCommand( shell: shellPath, arguments: args, timeout: timeout), let raw = String(data: data, encoding: .utf8), !raw.isEmpty else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let extracted = if let start = trimmed.range(of: marker), let end = trimmed.range(of: marker, options: .backwards), start.upperBound <= end.lowerBound { String(trimmed[start.upperBound.. [String]? private var captured: [String]? private var isCapturing = false private var callbacks: [([String]?) -> Void] = [] init(capture: @escaping @Sendable (String?, TimeInterval) -> [String]? = LoginShellPathCapturer.capture) { self.capture = capture } public var current: [String]? { self.lock.lock() let value = self.captured self.lock.unlock() return value } public func captureOnce( shell: String? = ProcessInfo.processInfo.environment["SHELL"], timeout: TimeInterval = 6.0, onFinish: (([String]?) -> Void)? = nil) { self.lock.lock() if let captured { self.lock.unlock() onFinish?(captured) return } if let onFinish { self.callbacks.append(onFinish) } if self.isCapturing { self.lock.unlock() return } self.isCapturing = true self.lock.unlock() let capture = self.capture DispatchQueue.global(qos: .utility).async { [weak self] in let result = capture(shell, timeout) guard let self else { return } self.lock.lock() self.captured = result self.isCapturing = false let callbacks = self.callbacks self.callbacks.removeAll() self.lock.unlock() callbacks.forEach { $0(result) } } } public func currentOrCapture( shell: String? = ProcessInfo.processInfo.environment["SHELL"], timeout: TimeInterval = 6.0) -> [String]? { self.lock.lock() if let captured { self.lock.unlock() return captured } if self.isCapturing { let semaphore = DispatchSemaphore(value: 0) var callbackResult: [String]? self.callbacks.append { result in callbackResult = result semaphore.signal() } self.lock.unlock() let deadline = DispatchTime.now() + timeout _ = semaphore.wait(timeout: deadline) return callbackResult ?? self.current } self.isCapturing = true self.lock.unlock() let result = self.capture(shell, timeout) self.lock.lock() self.captured = result self.isCapturing = false let callbacks = self.callbacks self.callbacks.removeAll() self.lock.unlock() callbacks.forEach { $0(result) } return result } }