diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index 4b0c0a0723..69a2898794 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -58,6 +58,27 @@ struct CodexProviderImplementation: ProviderImplementation { CodexProviderRuntime() } + @MainActor + func settingsActions(context: ProviderSettingsContext) -> [ProviderSettingsActionsDescriptor] { + [ + ProviderSettingsActionsDescriptor( + id: "codex-oauth", + title: L("codex_reauthenticate_title"), + subtitle: L("codex_reauthenticate_subtitle"), + actions: [ + ProviderSettingsActionDescriptor( + id: "codex-oauth-reauthenticate", + title: L("Re-authenticate"), + style: .bordered, + isVisible: nil, + perform: { + await context.runLoginFlow() + }), + ], + isVisible: nil), + ] + } + @MainActor func settingsToggles(context: ProviderSettingsContext) -> [ProviderSettingsToggleDescriptor] { let extrasBinding = Binding( diff --git a/Sources/CodexBar/Providers/Kiro/KiroLoginAlertPresentation.swift b/Sources/CodexBar/Providers/Kiro/KiroLoginAlertPresentation.swift new file mode 100644 index 0000000000..edbda15400 --- /dev/null +++ b/Sources/CodexBar/Providers/Kiro/KiroLoginAlertPresentation.swift @@ -0,0 +1,33 @@ +import Foundation + +enum KiroLoginAlertPresentation { + static func alertInfo(for result: KiroLoginRunner.Result) -> CodexLoginAlertInfo? { + switch result.outcome { + case .success: + return nil + case .missingBinary: + return CodexLoginAlertInfo( + title: L("Kiro CLI not found"), + message: L("Install kiro-cli and try again.")) + case let .launchFailed(message): + return CodexLoginAlertInfo(title: L("Could not start kiro-cli login"), message: message) + case .timedOut: + return CodexLoginAlertInfo( + title: L("Kiro login timed out"), + message: self.trimmedOutput(result.output)) + case let .failed(status): + let statusLine = String(format: L("kiro-cli login exited with status %d."), status) + let message = self.trimmedOutput(result.output.isEmpty ? statusLine : result.output) + return CodexLoginAlertInfo(title: L("Kiro login failed"), message: message) + } + } + + private static func trimmedOutput(_ text: String) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + let limit = 600 + if trimmed.isEmpty { return L("No output captured.") } + if trimmed.count <= limit { return trimmed } + let idx = trimmed.index(trimmed.startIndex, offsetBy: limit) + return "\(trimmed[.. Bool { + self.loginPhase = .requesting + defer { self.loginPhase = .idle } + + let result = await KiroLoginRunner.run(timeout: 120) { [weak self] progressOutput in + Task { @MainActor in + self?.presentLoginAlert( + title: L("Complete Kiro login in your browser"), + message: progressOutput) + } + } + guard !Task.isCancelled else { return false } + if let info = KiroLoginAlertPresentation.alertInfo(for: result) { + self.presentLoginAlert(title: info.title, message: info.message) + } + let length = result.output.count + self.loginLogger.info("Kiro login", metadata: ["outcome": "\(result.outcome)", "length": "\(length)"]) + guard case .success = result.outcome else { return false } + self.postLoginNotification(for: .kiro) + return true + } +} diff --git a/Sources/CodexBar/Providers/Kiro/KiroLoginRunner.swift b/Sources/CodexBar/Providers/Kiro/KiroLoginRunner.swift new file mode 100644 index 0000000000..f67ad7b6ed --- /dev/null +++ b/Sources/CodexBar/Providers/Kiro/KiroLoginRunner.swift @@ -0,0 +1,226 @@ +import CodexBarCore +import Darwin +import Foundation + +/// Spawns `kiro-cli login`, which opens a browser OAuth flow and blocks until it completes. +/// Mirrors ``CodexLoginRunner`` — same subprocess-lifecycle shape as `codex login`. +struct KiroLoginRunner { + struct Result: Equatable { + enum Outcome: Equatable { + case success + case timedOut + case failed(status: Int32) + case missingBinary + case launchFailed(String) + } + + let outcome: Outcome + let output: String + } + + /// Polling cadence while the login subprocess is still running, used to surface a + /// device-flow URL/code before the process exits (`kiro-cli login` prints them, then blocks + /// while polling for the browser approval). + private static let progressPollInterval: TimeInterval = 0.5 + + static func run( + timeout: TimeInterval = 120, + outputDrainTimeout: TimeInterval = 3, + environment: [String: String] = ProcessInfo.processInfo.environment, + loginPATH: [String]? = LoginShellPathCache.shared.current, + onProgress: (@Sendable (String) -> Void)? = nil) async -> Result + { + await Task(priority: .userInitiated) { + var env = environment + env["PATH"] = PathBuilder.effectivePATH( + purposes: [.rpc, .tty, .nodeTooling], + env: env, + loginPATH: loginPATH) + + guard let executable = BinaryLocator.resolveKiroCLIBinary(env: env, loginPATH: loginPATH) 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 + let stdoutCapture = ProcessPipeCapture(pipe: stdout) + let stderrCapture = ProcessPipeCapture(pipe: stderr) + + let termination = ProcessTermination() + process.terminationHandler = { _ in + termination.resolve(timedOut: false) + } + + var processGroup: pid_t? + do { + try process.run() + processGroup = self.attachProcessGroup(process) + } catch { + return Result(outcome: .launchFailed(error.localizedDescription), output: "") + } + stdoutCapture.start() + stderrCapture.start() + + let progressTask = onProgress.map { onProgress in + Task.detached(priority: .userInitiated) { + await Self.pollProgress( + stdout: stdoutCapture, + stderr: stderrCapture, + interval: self.progressPollInterval, + onProgress: onProgress) + } + } + + let timedOut = await self.wait(timeout: timeout, termination: termination) + progressTask?.cancel() + if timedOut { + self.terminate(process, processGroup: processGroup) + } + + let output = await self.combinedOutput( + stdout: stdoutCapture, + stderr: stderrCapture, + timeout: outputDrainTimeout) + 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 final class ProcessTermination: @unchecked Sendable { + private let lock = NSLock() + private var timedOut: Bool? + private var continuation: CheckedContinuation? + + func resolve(timedOut: Bool) { + let continuation: CheckedContinuation? + self.lock.lock() + guard self.timedOut == nil else { + self.lock.unlock() + return + } + self.timedOut = timedOut + continuation = self.continuation + self.continuation = nil + self.lock.unlock() + continuation?.resume(returning: timedOut) + } + + func wait() async -> Bool { + await withCheckedContinuation { continuation in + let timedOut: Bool? + self.lock.lock() + timedOut = self.timedOut + if timedOut == nil { + self.continuation = continuation + } + self.lock.unlock() + + if let timedOut { + continuation.resume(returning: timedOut) + } + } + } + } + + private static func wait(timeout: TimeInterval, termination: ProcessTermination) async -> Bool { + let timeoutTask = Task.detached(priority: .userInitiated) { + try? await Task.sleep(nanoseconds: self.timeoutNanoseconds(timeout)) + if Task.isCancelled == false { + termination.resolve(timedOut: true) + } + } + let timedOut = await termination.wait() + timeoutTask.cancel() + return timedOut + } + + private static func timeoutNanoseconds(_ timeout: TimeInterval) -> UInt64 { + guard timeout.isFinite else { return UInt64.max } + let seconds = max(0, min(timeout, Double(UInt64.max) / 1_000_000_000)) + return UInt64(seconds * 1_000_000_000) + } + + private static func terminate(_ process: Process, processGroup: pid_t?) { + if let pgid = processGroup { + kill(-pgid, SIGTERM) + } + if process.isRunning { + process.terminate() + } + + let deadline = Date().addingTimeInterval(2.0) + while process.isRunning, Date() < deadline { + usleep(100_000) + } + + if process.isRunning { + if let pgid = processGroup { + kill(-pgid, SIGKILL) + } + kill(process.processIdentifier, SIGKILL) + } + } + + private static func attachProcessGroup(_ process: Process) -> pid_t? { + let pid = process.processIdentifier + return setpgid(pid, pid) == 0 ? pid : nil + } + + private static func combinedOutput( + stdout: ProcessPipeCapture, + stderr: ProcessPipeCapture, + timeout: TimeInterval) async -> String + { + let drainTimeout = Duration.seconds(max(0, timeout)) + async let outData = stdout.finish(timeout: drainTimeout) + async let errData = stderr.finish(timeout: drainTimeout) + let out = await self.decode(outData) + let err = await self.decode(errData) + + let merged: String = if !out.isEmpty, !err.isEmpty { + [out, err].joined(separator: "\n") + } else { + out + err + } + let trimmed = merged.trimmingCharacters(in: .whitespacesAndNewlines) + let limited = trimmed.prefix(4000) + return limited.isEmpty ? L("No output captured.") : String(limited) + } + + private static func decode(_ data: Data) -> String { + ProcessPipeCapture.decodeUTF8(data) + } + + /// Polls the still-running subprocess's pipes for a device-flow URL/code and reports it once, + /// so the UI can show it before the timeout kills a login that's waiting on browser approval. + private static func pollProgress( + stdout: ProcessPipeCapture, + stderr: ProcessPipeCapture, + interval: TimeInterval, + onProgress: @escaping @Sendable (String) -> Void) async + { + while !Task.isCancelled { + let combined = self.decode(stdout.currentSnapshot()) + self.decode(stderr.currentSnapshot()) + let trimmed = combined.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.contains("http://") || trimmed.contains("https://") { + onProgress(trimmed) + return + } + try? await Task.sleep(nanoseconds: UInt64(max(0, interval) * 1_000_000_000)) + } + } +} diff --git a/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift b/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift index 383c4b1a59..f3ed598b0e 100644 --- a/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Kiro/KiroProviderImplementation.swift @@ -4,6 +4,33 @@ import SwiftUI struct KiroProviderImplementation: ProviderImplementation { let id: UsageProvider = .kiro + let supportsLoginFlow: Bool = true + + @MainActor + func runLoginFlow(context: ProviderLoginContext) async -> Bool { + await context.controller.runKiroLoginFlow() + } + + @MainActor + func settingsActions(context: ProviderSettingsContext) -> [ProviderSettingsActionsDescriptor] { + [ + ProviderSettingsActionsDescriptor( + id: "kiro-cli-login", + title: L("kiro_reauthenticate_title"), + subtitle: L("kiro_reauthenticate_subtitle"), + actions: [ + ProviderSettingsActionDescriptor( + id: "kiro-cli-login-reauthenticate", + title: L("Re-authenticate"), + style: .bordered, + isVisible: nil, + perform: { + await context.runLoginFlow() + }), + ], + isVisible: nil), + ] + } func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { [ diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index a97e5d5e4e..39bc9651b5 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -644,6 +644,17 @@ "keychain_access_caption" = "Disable all Keychain reads and writes. Use this if macOS keeps prompting for 'Chrome/Brave/Edge Safe Storage' even after clicking Always Allow. Browser cookie import is unavailable while enabled; paste Cookie headers manually in Providers. Claude/Codex OAuth via the CLI still works."; "disable_keychain_access_title" = "Disable Keychain access"; "disable_keychain_access_subtitle" = "Prevents any Keychain access while enabled."; +"Re-authenticate" = "Re-authenticate"; +"codex_reauthenticate_title" = "Codex OAuth"; +"codex_reauthenticate_subtitle" = "Runs 'codex login' to refresh your session when the OAuth token has expired."; +"kiro_reauthenticate_title" = "Kiro CLI login"; +"kiro_reauthenticate_subtitle" = "Runs 'kiro-cli login' to refresh your session when it has expired."; +"Kiro CLI not found" = "Kiro CLI not found"; +"Install kiro-cli and try again." = "Install kiro-cli and try again."; +"Could not start kiro-cli login" = "Could not start kiro-cli login"; +"Kiro login timed out" = "Kiro login timed out"; +"kiro-cli login exited with status %d." = "kiro-cli login exited with status %d."; +"Kiro login failed" = "Kiro login failed"; /* About Pane */ "about_tagline" = "May your tokens never run out—keep agent limits in view."; diff --git a/Sources/CodexBarCore/Host/Process/ProcessPipeCapture.swift b/Sources/CodexBarCore/Host/Process/ProcessPipeCapture.swift index 76a292ffe2..0493e3ff7d 100644 --- a/Sources/CodexBarCore/Host/Process/ProcessPipeCapture.swift +++ b/Sources/CodexBarCore/Host/Process/ProcessPipeCapture.swift @@ -113,6 +113,14 @@ package final class ProcessPipeCapture: @unchecked Sendable { return self.didReachEOF } + /// Snapshot of the bytes captured so far, without stopping the capture. Lets a caller + /// poll for interactive output (e.g. a device-flow URL/code) while the process is still running. + package func currentSnapshot() -> Data { + self.condition.lock() + defer { self.condition.unlock() } + return self.data + } + package static func decodeUTF8(_ data: Data) -> String { // A byte cap can split the final scalar; lossy decoding preserves the valid captured prefix. // swiftlint:disable:next optional_data_string_conversion diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift index 05c7a6b081..3a99d2cfff 100644 --- a/Sources/CodexBarCore/PathEnvironment.swift +++ b/Sources/CodexBarCore/PathEnvironment.swift @@ -323,6 +323,31 @@ public enum BinaryLocator { home: home) } + public static func resolveKiroCLIBinary( + 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: "kiro-cli", + overrideKey: "KIRO_CLI_PATH", + env: env, + loginPATH: loginPATH, + commandV: commandV, + aliasResolver: aliasResolver, + wellKnownPaths: [ + "\(home)/.local/bin/kiro-cli", + "/opt/homebrew/bin/kiro-cli", + "/usr/local/bin/kiro-cli", + ], + fileManager: fileManager, + home: home) + } + // swiftlint:disable function_parameter_count private static func resolveBinary( name: String, diff --git a/Tests/CodexBarTests/KiroLoginRunnerTests.swift b/Tests/CodexBarTests/KiroLoginRunnerTests.swift new file mode 100644 index 0000000000..f3e5f3734f --- /dev/null +++ b/Tests/CodexBarTests/KiroLoginRunnerTests.swift @@ -0,0 +1,127 @@ +import Darwin +import Foundation +import Testing +@testable import CodexBar + +struct KiroLoginRunnerTests { + @Test + func `login runner returns timeout before hung kiro-cli exits`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-login-runner-\(UUID().uuidString)", isDirectory: true) + let binDir = root.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let kiroCLI = binDir.appendingPathComponent("kiro-cli") + let script = """ + #!/usr/bin/python3 + import time + + print("login-started", flush=True) + time.sleep(5) + print("login-finished", flush=True) + """ + try script.write(to: kiroCLI, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: kiroCLI.path) + + let start = Date() + let result = await KiroLoginRunner.run( + timeout: 0.2, + environment: ["PATH": binDir.path], + loginPATH: nil) + let elapsed = Date().timeIntervalSince(start) + + #expect(result.outcome == .timedOut) + #expect(result.output.contains("login-finished") == false) + #expect(elapsed < 2.0, "Timeout should return promptly, took \(elapsed)s") + } + + @Test + func `login runner bounds output drain when detached child keeps pipes open`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-login-drain-\(UUID().uuidString)", isDirectory: true) + let binDir = root.appendingPathComponent("bin", isDirectory: true) + let childPIDFile = root.appendingPathComponent("child.pid") + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + defer { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(childPID, SIGKILL) + } + } + + let kiroCLI = binDir.appendingPathComponent("kiro-cli") + let script = """ + #!/bin/sh + /bin/sh -c 'trap "" TERM; /bin/sleep 20' & + child_pid=$! + printf '%s\\n' "$child_pid" > "$CODEXBAR_TEST_CHILD_PID_FILE" + printf 'login-started\\n' + /bin/sleep 20 + """ + try script.write(to: kiroCLI, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: kiroCLI.path) + + let start = Date() + let result = await KiroLoginRunner.run( + timeout: 5, + outputDrainTimeout: 0.5, + environment: [ + "CODEXBAR_TEST_CHILD_PID_FILE": childPIDFile.path, + "PATH": binDir.path, + ], + loginPATH: nil) + let elapsed = Date().timeIntervalSince(start) + + #expect(result.outcome == .timedOut) + #expect(result.output.contains("login-started")) + #expect(elapsed < 8.0, "Output drain should stay bounded, took \(elapsed)s") + } + + @Test + func `login runner reports progress once a device-flow URL appears`() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-login-progress-\(UUID().uuidString)", isDirectory: true) + let binDir = root.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let kiroCLI = binDir.appendingPathComponent("kiro-cli") + let script = """ + #!/bin/sh + printf 'Open https://example.com/device?code=ABCD to continue\\n' + /bin/sleep 0.4 + """ + try script.write(to: kiroCLI, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: kiroCLI.path) + + let progress = ProgressBox() + let result = await KiroLoginRunner.run( + timeout: 5, + environment: ["PATH": binDir.path], + loginPATH: nil, + onProgress: { text in progress.record(text) }) + + #expect(result.outcome == .success) + #expect(progress.value?.contains("https://example.com/device?code=ABCD") == true) + } + + private final class ProgressBox: @unchecked Sendable { + private let lock = NSLock() + private var text: String? + + var value: String? { + self.lock.lock() + defer { self.lock.unlock() } + return self.text + } + + func record(_ text: String) { + self.lock.lock() + self.text = text + self.lock.unlock() + } + } +}