Files
steipete--codexbar/Sources/CodexBarCore/PathEnvironment.swift
T
2026-07-13 12:22:33 +08:00

1229 lines
45 KiB
Swift

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<CChar>?] = []
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<CChar>?] = []
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<PathPurpose>,
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<String>()
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<PathPurpose>,
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<PathPurpose>,
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..<end.lowerBound])
} else {
trimmed
}
let value = extracted.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return nil }
return value.split(separator: ":").map(String.init)
}
}
public final class LoginShellPathCache: @unchecked Sendable {
public static let shared = LoginShellPathCache()
private let lock = NSLock()
private let capture: @Sendable (String?, TimeInterval) -> [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
}
}