From f86acc64e6e5aa72ef6dbe8a5507657a2c7ff077 Mon Sep 17 00:00:00 2001 From: "Zhang J.W." Date: Wed, 1 Jul 2026 19:03:38 +0800 Subject: [PATCH] Fix Codex CLI lookup for quota display --- .github/workflows/ci.yml | 2 + .../Services/CodexExecutableResolver.swift | 59 +++++++++++++++ .../Services/CodexQuotaService.swift | 17 ++--- .../CodexExecutableResolverFixtureCheck.swift | 75 +++++++++++++++++++ script/test_codex_executable_resolver.sh | 18 +++++ 5 files changed, 162 insertions(+), 9 deletions(-) create mode 100644 TokenStepSwift/Sources/TokenStepSwift/Services/CodexExecutableResolver.swift create mode 100644 TokenStepSwift/Tests/Fixtures/CodexExecutableResolverFixtureCheck.swift create mode 100755 script/test_codex_executable_resolver.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1357d4..c98b912 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,8 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@v4 + - name: Run Codex executable resolver checks + run: ./script/test_codex_executable_resolver.sh - name: Run usage collector fixture checks run: ./script/test_ccswitch_proxy_collector.sh - name: Build TokenStep diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/CodexExecutableResolver.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/CodexExecutableResolver.swift new file mode 100644 index 0000000..720616d --- /dev/null +++ b/TokenStepSwift/Sources/TokenStepSwift/Services/CodexExecutableResolver.swift @@ -0,0 +1,59 @@ +import Foundation + +enum CodexExecutableResolver { + static func resolveExecutable(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL? { + for candidate in executableCandidates(environment: environment) { + if FileManager.default.isExecutableFile(atPath: candidate) { + return URL(fileURLWithPath: candidate).standardizedFileURL + } + } + return nil + } + + static func augmentedPATH(environment: [String: String] = ProcessInfo.processInfo.environment) -> String { + let existingDirectories = pathDirectories(environment: environment) + return unique(existingDirectories + fallbackDirectories(environment: environment)) + .joined(separator: ":") + } + + private static func executableCandidates(environment: [String: String]) -> [String] { + unique(pathDirectories(environment: environment) + fallbackDirectories(environment: environment)) + .map { URL(fileURLWithPath: $0).appendingPathComponent("codex").path } + } + + private static func pathDirectories(environment: [String: String]) -> [String] { + (environment["PATH"] ?? "") + .split(separator: ":") + .map(String.init) + .filter { !$0.isEmpty } + } + + private static func fallbackDirectories(environment: [String: String]) -> [String] { + let home = homeDirectory(environment: environment) + return [ + "\(home)/.local/bin", + "\(home)/.codex/packages/standalone/current/bin", + "\(home)/.npm-global/bin", + "\(home)/.bun/bin", + "\(home)/.cargo/bin", + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin" + ] + } + + private static func homeDirectory(environment: [String: String]) -> String { + if let home = environment["HOME"], !home.isEmpty { + return home + } + return FileManager.default.homeDirectoryForCurrentUser.path + } + + private static func unique(_ values: [String]) -> [String] { + var seen = Set() + return values.filter { seen.insert($0).inserted } + } +} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/CodexQuotaService.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/CodexQuotaService.swift index 6c67bb9..26e4182 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Services/CodexQuotaService.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Services/CodexQuotaService.swift @@ -58,9 +58,13 @@ enum CodexQuotaService { var errorOutput = Data() var didReceiveQuotaResponse = false - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = ["codex", "app-server", "--listen", "stdio://"] - process.environment = appServerEnvironment() + let environment = appServerEnvironment() + guard let codexExecutable = CodexExecutableResolver.resolveExecutable(environment: environment) else { + throw TokenStepError.message(L("暂未读取到 Codex 额度")) + } + process.executableURL = codexExecutable + process.arguments = ["app-server", "--listen", "stdio://"] + process.environment = environment process.standardInput = inputPipe process.standardOutput = outputPipe process.standardError = errorPipe @@ -190,12 +194,7 @@ enum CodexQuotaService { private static func appServerEnvironment() -> [String: String] { var environment = ProcessInfo.processInfo.environment - let defaultPath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" - if let existing = environment["PATH"], !existing.isEmpty { - environment["PATH"] = "\(defaultPath):\(existing)" - } else { - environment["PATH"] = defaultPath - } + environment["PATH"] = CodexExecutableResolver.augmentedPATH(environment: environment) return environment } } diff --git a/TokenStepSwift/Tests/Fixtures/CodexExecutableResolverFixtureCheck.swift b/TokenStepSwift/Tests/Fixtures/CodexExecutableResolverFixtureCheck.swift new file mode 100644 index 0000000..a70c42c --- /dev/null +++ b/TokenStepSwift/Tests/Fixtures/CodexExecutableResolverFixtureCheck.swift @@ -0,0 +1,75 @@ +import Foundation + +@main +struct CodexExecutableResolverFixtureCheck { + static func main() throws { + try checkResolvesUserLocalCodexWhenAppPathOmitsIt() + try checkPathCodexTakesPriorityOverFallbacks() + try checkStandaloneCodexFallback() + print("Codex executable resolver fixture checks passed") + } + + private static func checkResolvesUserLocalCodexWhenAppPathOmitsIt() throws { + try withTemporaryHome { home in + let codex = try makeExecutable(at: home.appendingPathComponent(".local/bin/codex")) + let resolved = CodexExecutableResolver.resolveExecutable( + environment: [ + "HOME": home.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin" + ] + ) + assertEqual(resolved, codex, "expected ~/.local/bin/codex fallback") + } + } + + private static func checkPathCodexTakesPriorityOverFallbacks() throws { + try withTemporaryHome { home in + let pathCodex = try makeExecutable(at: home.appendingPathComponent("toolchain/bin/codex")) + _ = try makeExecutable(at: home.appendingPathComponent(".local/bin/codex")) + let resolved = CodexExecutableResolver.resolveExecutable( + environment: [ + "HOME": home.path, + "PATH": pathCodex.deletingLastPathComponent().path + ] + ) + assertEqual(resolved, pathCodex, "expected PATH codex to take priority") + } + } + + private static func checkStandaloneCodexFallback() throws { + try withTemporaryHome { home in + let codex = try makeExecutable(at: home.appendingPathComponent(".codex/packages/standalone/current/bin/codex")) + let resolved = CodexExecutableResolver.resolveExecutable( + environment: [ + "HOME": home.path, + "PATH": "/usr/bin:/bin" + ] + ) + assertEqual(resolved, codex, "expected standalone Codex fallback") + } + } + + private static func withTemporaryHome(_ body: (URL) throws -> Void) throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent("TokenStepCodexResolverFixture-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: home) + } + try body(home) + } + + private static func makeExecutable(at url: URL) throws -> URL { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + FileManager.default.createFile(atPath: url.path, contents: Data("#!/bin/sh\n".utf8)) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url.standardizedFileURL + } + + private static func assertEqual(_ actual: URL?, _ expected: URL, _ message: String) { + guard actual?.standardizedFileURL == expected.standardizedFileURL else { + fputs("Assertion failed: \(message). got \(actual?.path ?? "nil"), expected \(expected.path)\n", stderr) + exit(1) + } + } +} diff --git a/script/test_codex_executable_resolver.sh b/script/test_codex_executable_resolver.sh new file mode 100755 index 0000000..6c76061 --- /dev/null +++ b/script/test_codex_executable_resolver.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SWIFT_DIR="$ROOT_DIR/TokenStepSwift" +BUILD_DIR="$SWIFT_DIR/.build/codex-executable-resolver-fixture" +EXECUTABLE="$BUILD_DIR/codex-executable-resolver-fixture-check" + +mkdir -p "$BUILD_DIR" + +swiftc \ + -target arm64-apple-macos14.0 \ + -parse-as-library \ + "$SWIFT_DIR/Sources/TokenStepSwift/Services/CodexExecutableResolver.swift" \ + "$SWIFT_DIR/Tests/Fixtures/CodexExecutableResolverFixtureCheck.swift" \ + -o "$EXECUTABLE" + +"$EXECUTABLE"