Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>()
return values.filter { seen.insert($0).inserted }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
18 changes: 18 additions & 0 deletions script/test_codex_executable_resolver.sh
Original file line number Diff line number Diff line change
@@ -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"