Skip to content
Closed
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
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ let package = Package(
.package(url: "https://github.com/apple/swift-log", from: "1.9.1"),
.package(url: "https://github.com/apple/swift-syntax", from: "600.0.1"),
.package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.4.0"),
.package(url: "https://github.com/openid/AppAuth-iOS.git", from: "1.7.6"),
sweetCookieKitDependency,
],
targets: {
Expand Down Expand Up @@ -82,6 +83,7 @@ let package = Package(
dependencies: [
.product(name: "Sparkle", package: "Sparkle"),
.product(name: "KeyboardShortcuts", package: "KeyboardShortcuts"),
.product(name: "AppAuth", package: "AppAuth-iOS"),
"CodexBarMacroSupport",
"CodexBarCore",
],
Expand Down
7 changes: 6 additions & 1 deletion Scripts/package_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,12 @@ elif [[ "$ALLOW_LLDB" == "1" ]]; then
CODESIGN_ARGS=(--force --sign "$CODESIGN_ID")
else
CODESIGN_ID="${APP_IDENTITY:-Developer ID Application: Peter Steinberger (Y5PE65HELJ)}"
CODESIGN_ARGS=(--force --timestamp --options runtime --sign "$CODESIGN_ID")
if [[ "$CODESIGN_ID" == "CodexBar Development" ]]; then
echo "INFO: '${CODESIGN_ID}' has no Team ID; signing without hardened runtime for local dev stability." >&2
CODESIGN_ARGS=(--force --sign "$CODESIGN_ID")
else
CODESIGN_ARGS=(--force --timestamp --options runtime --sign "$CODESIGN_ID")
fi
fi
function resign() { codesign "${CODESIGN_ARGS[@]}" "$1"; }
# Sign innermost binaries first, then the framework root to seal resources
Expand Down
272 changes: 163 additions & 109 deletions Sources/CodexBar/CodexLoginRunner.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import AppAuth
import AppKit
import CodexBarCore
import Darwin
import Foundation

struct CodexLoginRunner {
enum Phase {
case requesting
case waitingBrowser
}

struct Result {
enum Outcome {
case success
Expand All @@ -16,139 +22,187 @@ struct CodexLoginRunner {
let output: String
}

static func run(timeout: TimeInterval = 120) async -> Result {
await Task(priority: .userInitiated) {
var env = ProcessInfo.processInfo.environment
env["PATH"] = PathBuilder.effectivePATH(
purposes: [.rpc, .tty, .nodeTooling],
env: env,
loginPATH: LoginShellPathCache.shared.current)

guard let executable = BinaryLocator.resolveCodexBinary(
env: env,
loginPATH: LoginShellPathCache.shared.current)
else {
return Result(outcome: .missingBinary, output: "")
}

let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = [executable, "login"]
process.environment = env

let stdout = Pipe()
let stderr = Pipe()
process.standardOutput = stdout
process.standardError = stderr

var processGroup: pid_t?
do {
try process.run()
processGroup = self.attachProcessGroup(process)
} catch {
return Result(outcome: .launchFailed(error.localizedDescription), output: "")
}

let timedOut = await self.wait(for: process, timeout: timeout)
if timedOut {
self.terminate(process, processGroup: processGroup)
}

let output = await self.combinedOutput(stdout: stdout, stderr: stderr)
if timedOut {
return Result(outcome: .timedOut, output: output)
}

let status = process.terminationStatus
if status == 0 {
return Result(outcome: .success, output: output)
}
return Result(outcome: .failed(status: status), output: output)
}.value
}

private static func wait(for process: Process, timeout: TimeInterval) async -> Bool {
await withTaskGroup(of: Bool.self) { group -> Bool in
private static let authorizationEndpoint = URL(string: "https://auth.openai.com/oauth/authorize")!
private static let tokenEndpoint = URL(string: "https://auth.openai.com/oauth/token")!
private static let redirectPort: UInt16 = 1455
private static let redirectURL = URL(string: "http://localhost:1455/auth/callback")!
private static let successURL = URL(string: "https://chatgpt.com/")!
private static let clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
private static let scopes = ["openid", "profile", "email", "offline_access"]
private static let additionalParameters = [
"id_token_add_organizations": "true",
"codex_cli_simplified_flow": "true",
"originator": "codex_cli_rs",
]

@MainActor
static func run(
timeout: TimeInterval = 120,
onPhaseChange: (@Sendable (Phase) -> Void)? = nil,
credentialSource: String? = nil) async -> Result
{
let coordinator = NativeOAuthCoordinator(
onPhaseChange: onPhaseChange,
credentialSource: credentialSource)
return await withTaskGroup(of: Result.self) { group in
group.addTask {
process.waitUntilExit()
return false
await coordinator.run()
}
group.addTask {
let nanos = UInt64(max(0, timeout) * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanos)
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeout task uses try? await Task.sleep(...) and then unconditionally calls coordinator.cancelForTimeout(). If the task gets cancelled (e.g., because the login task completed first and group.cancelAll() runs), Task.sleep throws CancellationError which is swallowed by try?, so this task will still run cancelForTimeout() and may cancel the HTTP listener / finish the coordinator after a successful completion. Handle cancellation explicitly (e.g., do/try/catch around Task.sleep and return early on cancellation) so cancelForTimeout() only runs when the sleep actually completes without cancellation.

Suggested change
try? await Task.sleep(nanoseconds: nanos)
do {
try await Task.sleep(nanoseconds: nanos)
} catch is CancellationError {
// The timeout task was cancelled (e.g., because login completed first).
// Return a benign result without touching the coordinator.
return Result(outcome: .timedOut, output: "Timeout task cancelled.")
} catch {
// Any other unexpected error from Task.sleep; also avoid coordinator side-effects.
return Result(outcome: .failed(status: 1), output: "Timeout task failed: \(error)")
}

Copilot uses AI. Check for mistakes.
return true
return await coordinator.cancelForTimeout()
}
let result = await group.next() ?? false
let result = await group.next() ?? Result(outcome: .timedOut, output: "Codex login timed out.")
group.cancelAll()
return result
}
}

private static func terminate(_ process: Process, processGroup: pid_t?) {
if let pgid = processGroup {
kill(-pgid, SIGTERM)
}
if process.isRunning {
process.terminate()
}
static func _authorizationRequestForTesting(listenerBaseURL: URL) -> OIDAuthorizationRequest {
Self.makeAuthorizationRequest(listenerBaseURL: listenerBaseURL)
}

let deadline = Date().addingTimeInterval(2.0)
while process.isRunning, Date() < deadline {
usleep(100_000)
@MainActor
private final class NativeOAuthCoordinator {
private let onPhaseChange: (@Sendable (Phase) -> Void)?
private let credentialSource: String?
private let lock = NSLock()
private var continuation: CheckedContinuation<Result, Never>?
private var hasCompleted = false
private var redirectHandler: OIDRedirectHTTPHandler?

init(onPhaseChange: (@Sendable (Phase) -> Void)?, credentialSource: String?) {
self.onPhaseChange = onPhaseChange
self.credentialSource = credentialSource
}

if process.isRunning {
if let pgid = processGroup {
kill(-pgid, SIGKILL)
func run() async -> Result {
await withCheckedContinuation { continuation in
self.continuation = continuation
self.start()
}
kill(process.processIdentifier, SIGKILL)
}
}

private static func attachProcessGroup(_ process: Process) -> pid_t? {
let pid = process.processIdentifier
return setpgid(pid, pid) == 0 ? pid : nil
}
func cancelForTimeout() -> Result {
self.redirectHandler?.cancelHTTPListener()
let result = Result(outcome: .timedOut, output: "Codex login timed out.")
self.finish(result)
return result
}

private static func combinedOutput(stdout: Pipe, stderr: Pipe) async -> String {
async let out = self.readToEnd(stdout)
async let err = self.readToEnd(stderr)
let stdoutText = await out
let stderrText = await err
private func start() {
self.onPhaseChange?(.requesting)

let configuration = OIDServiceConfiguration(
authorizationEndpoint: CodexLoginRunner.authorizationEndpoint,
tokenEndpoint: CodexLoginRunner.tokenEndpoint)
let redirectHandler = OIDRedirectHTTPHandler(successURL: CodexLoginRunner.successURL)
self.redirectHandler = redirectHandler

var listenerError: NSError?
let listenerBaseURL = redirectHandler.startHTTPListener(
&listenerError,
withPort: CodexLoginRunner.redirectPort)
if let listenerError {
let message = listenerError.localizedDescription
self.finish(Result(outcome: .launchFailed(message), output: message))
return
}

let merged: String = if !stdoutText.isEmpty, !stderrText.isEmpty {
[stdoutText, stderrText].joined(separator: "\n")
} else {
stdoutText + stderrText
let request = CodexLoginRunner.makeAuthorizationRequest(
configuration: configuration,
listenerBaseURL: listenerBaseURL)

self.onPhaseChange?(.waitingBrowser)
redirectHandler.currentAuthorizationFlow = OIDAuthState
.authState(byPresenting: request) { authState, error in
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
redirectHandler.cancelHTTPListener()

if let authState {
do {
let credentials = try Self.credentials(from: authState)
if let credentialSource = self.credentialSource {
try CodexOAuthCredentialsStore.save(credentials, rawSource: credentialSource)
} else {
try CodexOAuthCredentialsStore.save(credentials)
}
self.finish(Result(outcome: .success, output: "Codex OAuth login complete."))
} catch {
self.finish(Result(
outcome: .launchFailed(error.localizedDescription),
output: error.localizedDescription))
}
return
}

let message = error?.localizedDescription ?? "Unknown OAuth login error."
self.finish(Result(outcome: .failed(status: 1), output: message))
}
}
let trimmed = merged.trimmingCharacters(in: .whitespacesAndNewlines)
let limited = trimmed.prefix(4000)
return limited.isEmpty ? "No output captured." : String(limited)
}

private static func readToEnd(_ pipe: Pipe, timeout: TimeInterval = 3.0) async -> String {
await withTaskGroup(of: String?.self) { group -> String in
group.addTask {
if #available(macOS 13.0, *) {
if let data = try? pipe.fileHandleForReading.readToEnd() { return self.decode(data) }
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return Self.decode(data)
private func finish(_ result: Result) {
self.lock.lock()
defer { self.lock.unlock() }
guard !self.hasCompleted, let continuation = self.continuation else { return }
self.hasCompleted = true
self.continuation = nil
continuation.resume(returning: result)
}

private static func credentials(from authState: OIDAuthState) throws -> CodexOAuthCredentials {
guard let tokenResponse = authState.lastTokenResponse,
let accessToken = tokenResponse.accessToken,
!accessToken.isEmpty
else {
throw CodexOAuthCredentialsError.missingTokens
}
group.addTask {
let nanos = UInt64(max(0, timeout) * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanos)
return nil

let refreshToken = tokenResponse.refreshToken ?? authState.refreshToken ?? ""
if refreshToken.isEmpty {
throw CodexOAuthCredentialsError.missingTokens
}
let result = await group.next()
group.cancelAll()
if let result, let text = result { return text }
return ""

let idToken = tokenResponse.idToken
let accountId = CodexOAuthClaimResolver.accountID(accessToken: accessToken, idToken: idToken)

return CodexOAuthCredentials(
accessToken: accessToken,
refreshToken: refreshToken,
idToken: idToken,
accountId: accountId,
lastRefresh: Date())
}
}

private static func decode(_ data: Data) -> String {
guard let text = String(data: data, encoding: .utf8) else { return "" }
return text
private static func makeAuthorizationRequest(listenerBaseURL: URL) -> OIDAuthorizationRequest {
let configuration = OIDServiceConfiguration(
authorizationEndpoint: Self.authorizationEndpoint,
tokenEndpoint: Self.tokenEndpoint)
return self.makeAuthorizationRequest(configuration: configuration, listenerBaseURL: listenerBaseURL)
}

private static func makeAuthorizationRequest(
configuration: OIDServiceConfiguration,
listenerBaseURL: URL) -> OIDAuthorizationRequest
{
_ = listenerBaseURL
let state = OIDAuthorizationRequest.generateState()
let codeVerifier = OIDAuthorizationRequest.generateCodeVerifier()
let codeChallenge = OIDAuthorizationRequest.codeChallengeS256(forVerifier: codeVerifier)
return OIDAuthorizationRequest(
configuration: configuration,
clientId: Self.clientID,
clientSecret: nil,
scope: Self.scopes.joined(separator: " "),
redirectURL: Self.redirectURL,
responseType: OIDResponseTypeCode,
state: state,
nonce: nil,
codeVerifier: codeVerifier,
codeChallenge: codeChallenge,
codeChallengeMethod: OIDOAuthorizationRequestCodeChallengeMethodS256,
additionalParameters: Self.additionalParameters)
}
}
38 changes: 38 additions & 0 deletions Sources/CodexBar/CodexOAuthAccountStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import CodexBarCore
import Foundation

enum CodexOAuthAccountStore {
static func createProfileDirectory(
label: String,
rootDirectory: URL? = nil,
fileManager: FileManager = .default) throws -> URL
{
let root = rootDirectory ?? CodexBarConfigStore.defaultURL()
.deletingLastPathComponent()
.appendingPathComponent("codex-oauth-accounts", isDirectory: true)
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)

let slug = self.slug(for: label)
let directory = root.appendingPathComponent("\(slug)-\(UUID().uuidString)", isDirectory: true)
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
return directory
}

static func removeProfileDirectoryIfPresent(_ directory: URL, fileManager: FileManager = .default) {
try? fileManager.removeItem(at: directory)
}

private static func slug(for label: String) -> String {
let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let slug = trimmed.unicodeScalars.map { scalar -> Character in
if CharacterSet.alphanumerics.contains(scalar) {
return Character(scalar)
}
return "-"
}
let collapsed = String(slug)
.replacingOccurrences(of: "--+", with: "-", options: .regularExpression)
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
return collapsed.isEmpty ? "codex-account" : collapsed
}
}
Loading
Loading