From 5b9bdf5b109ea02203593a310ad102b3c99056c7 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Sun, 6 Sep 2026 19:20:43 -0700 Subject: [PATCH] Fix NVM Codex launch environment --- .../Networking/CodexAppServerClient.swift | 33 ++++++++++++++++++- .../CodexAppServerClientTests.swift | 13 ++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/Sources/CodexToolbar/Networking/CodexAppServerClient.swift b/Sources/CodexToolbar/Networking/CodexAppServerClient.swift index 610b7bd..d090971 100644 --- a/Sources/CodexToolbar/Networking/CodexAppServerClient.swift +++ b/Sources/CodexToolbar/Networking/CodexAppServerClient.swift @@ -79,6 +79,7 @@ actor CodexAppServerClient: CodexRateLimitClient { let process = Process() process.executableURL = URL(fileURLWithPath: codexPath) process.arguments = ["app-server", "--listen", "stdio://"] + process.environment = Self.environmentForCodex(executablePath: codexPath) let stdinPipe = Pipe() let stdoutPipe = Pipe() @@ -176,6 +177,7 @@ actor CodexAppServerClient: CodexRateLimitClient { let process = Process() process.executableURL = URL(fileURLWithPath: codexPath) process.arguments = ["app-server", "--listen", "stdio://"] + process.environment = Self.environmentForCodex(executablePath: codexPath) let stdinPipe = Pipe() let stdoutPipe = Pipe() @@ -385,6 +387,7 @@ actor CodexAppServerClient: CodexRateLimitClient { URL(fileURLWithPath: String(pathComponent)).appendingPathComponent("codex").path } + let nvmCLICandidates = nvmCodexPathCandidates(homeDirectory: homeDirectory) let installedCLICandidates = [ URL(fileURLWithPath: homeDirectory).appendingPathComponent(".local/bin/codex").path, "/opt/homebrew/bin/codex", @@ -405,13 +408,40 @@ actor CodexAppServerClient: CodexRateLimitClient { let standalonePathCandidates = pathCandidates.filter { !appBundleCandidateSet.contains($0) } var seen = Set() - let orderedCandidates = standalonePathCandidates + installedCLICandidates + appBundleCandidates + let orderedCandidates = standalonePathCandidates + nvmCLICandidates + installedCLICandidates + appBundleCandidates return orderedCandidates.filter { candidate in seen.insert(candidate).inserted } } + nonisolated static func environmentForCodex( + executablePath: String, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> [String: String] { + var environment = environment + let executableDirectory = URL(fileURLWithPath: executablePath).deletingLastPathComponent().path + let existingPath = environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" + environment["PATH"] = "\(executableDirectory):\(existingPath)" + return environment + } + + private nonisolated static func nvmCodexPathCandidates(homeDirectory: String) -> [String] { + let versionsDirectory = URL(fileURLWithPath: homeDirectory) + .appendingPathComponent(".nvm/versions/node", isDirectory: true) + guard let versions = try? FileManager.default.contentsOfDirectory( + at: versionsDirectory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { + return [] + } + + return versions + .sorted { $0.lastPathComponent.compare($1.lastPathComponent, options: .numeric) == .orderedDescending } + .map { $0.appendingPathComponent("bin/codex").path } + } + nonisolated static func parseLoginStatus( exitStatus: Int32, stdout: String, @@ -451,6 +481,7 @@ actor CodexAppServerClient: CodexRateLimitClient { let process = Process() process.executableURL = URL(fileURLWithPath: executablePath) process.arguments = arguments + process.environment = environmentForCodex(executablePath: executablePath) let stdoutPipe = Pipe() let stderrPipe = Pipe() diff --git a/Tests/CodexToolbarTests/CodexAppServerClientTests.swift b/Tests/CodexToolbarTests/CodexAppServerClientTests.swift index d63ef85..19028b9 100644 --- a/Tests/CodexToolbarTests/CodexAppServerClientTests.swift +++ b/Tests/CodexToolbarTests/CodexAppServerClientTests.swift @@ -53,6 +53,19 @@ final class CodexAppServerClientTests: XCTestCase { XCTAssertEqual(candidates.filter { $0 == "/Applications/ChatGPT.app/Contents/Resources/codex" }.count, 1) } + func testCodexEnvironmentPrependsExecutableDirectoryToPath() { + let environment = CodexAppServerClient.environmentForCodex( + executablePath: "/Users/example/.nvm/versions/node/v22.16.0/bin/codex", + environment: ["PATH": "/usr/bin:/bin", "HOME": "/Users/example"] + ) + + XCTAssertEqual( + environment["PATH"], + "/Users/example/.nvm/versions/node/v22.16.0/bin:/usr/bin:/bin" + ) + XCTAssertEqual(environment["HOME"], "/Users/example") + } + func testParseLoginStatusTreatsExitZeroAsLoggedIn() { let status = CodexAppServerClient.parseLoginStatus( exitStatus: 0,