Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
37 changes: 35 additions & 2 deletions Sources/CodexBar/Providers/Copilot/CopilotLoginFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,47 @@ struct CopilotLoginFlow {

switch tokenResult {
case let .success(token):
settings.copilotAPIToken = token
// Fetch username for account label
var label: String
do {
let username = try await CopilotUsageFetcher.fetchGitHubUsername(token: token)
let planSuffix: String
do {
let fetcher = CopilotUsageFetcher(token: token)
let usage = try await fetcher.fetch()
let plan = usage.identity(for: .copilot)?.loginMethod ?? ""
planSuffix = plan.isEmpty ? "" : " (\(plan))"
} catch {
planSuffix = ""
}
label = "\(username)\(planSuffix)"
} catch {
let count = settings.tokenAccounts(for: .copilot).count
label = "Account \(count + 1)"
}

// Check for duplicate — same username means same GitHub user
let existingAccounts = settings.tokenAccounts(for: .copilot)
let usernamePrefix = label.components(separatedBy: " (").first ?? label
let wasRefresh = existingAccounts.contains(where: {
let existingPrefix = $0.label.components(separatedBy: " (").first ?? $0.label
return existingPrefix == usernamePrefix
})
if let existing = existingAccounts.first(where: {
let existingPrefix = $0.label.components(separatedBy: " (").first ?? $0.label
return existingPrefix == usernamePrefix
}) {
settings.removeTokenAccount(provider: .copilot, accountID: existing.id)
}
settings.addTokenAccount(provider: .copilot, label: label, token: token)
settings.setProviderEnabled(
provider: .copilot,
metadata: ProviderRegistry.shared.metadata[.copilot]!,
enabled: true)

let success = NSAlert()
success.messageText = "Login Successful"
success.messageText = wasRefresh ? "Token Refreshed" : "Account Added"
success.informativeText = label
success.runModal()
case let .failure(error):
guard !(error is CancellationError) else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,45 +15,36 @@ struct CopilotProviderImplementation: ProviderImplementation {

@MainActor
func observeSettings(_ settings: SettingsStore) {
_ = settings.copilotAPIToken
settings.migrateCopilotTokenToAccountIfNeeded()
}

@MainActor
func settingsSnapshot(context: ProviderSettingsSnapshotContext) -> ProviderSettingsSnapshotContribution? {
_ = context
return .copilot(context.settings.copilotSettingsSnapshot())
.copilot(context.settings.copilotSettingsSnapshot())
}

@MainActor
func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] {
[
ProviderSettingsFieldDescriptor(
id: "copilot-api-token",
id: "copilot-add-account",
title: "GitHub Login",
subtitle: "Requires authentication via GitHub Device Flow.",
kind: .secure,
placeholder: "Sign in via button below",
binding: context.stringBinding(\.copilotAPIToken),
subtitle: "Add accounts via GitHub OAuth Device Flow.",
kind: .plain,
placeholder: nil,
binding: .constant(""),
actions: [
ProviderSettingsActionDescriptor(
id: "copilot-login",
title: "Sign in with GitHub",
id: "copilot-add-account-action",
title: "Add Account",
style: .bordered,
isVisible: { context.settings.copilotAPIToken.isEmpty },
perform: {
await CopilotLoginFlow.run(settings: context.settings)
}),
ProviderSettingsActionDescriptor(
id: "copilot-relogin",
title: "Sign in again",
style: .link,
isVisible: { !context.settings.copilotAPIToken.isEmpty },
isVisible: { true },
perform: {
await CopilotLoginFlow.run(settings: context.settings)
}),
],
isVisible: nil,
onActivate: { context.settings.ensureCopilotAPITokenLoaded() }),
onActivate: nil),
]
}

Expand Down
28 changes: 27 additions & 1 deletion Sources/CodexBar/Providers/Copilot/CopilotSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,33 @@ extension SettingsStore {
}
}

func ensureCopilotAPITokenLoaded() {}
func ensureCopilotAPITokenLoaded() {
self.migrateCopilotTokenToAccountIfNeeded()
}

func migrateCopilotTokenToAccountIfNeeded() {
let token = self.copilotAPIToken
guard !token.isEmpty else { return }
let existing = self.tokenAccounts(for: .copilot)
guard existing.isEmpty else { return }

// Migration: move single config token to token accounts.
// Store with fallback label synchronously, then enrich async.
self.addTokenAccount(provider: .copilot, label: "Account 1", token: token)
self.copilotAPIToken = ""

// Best-effort async label enrichment
Task { @MainActor in
guard let account = self.tokenAccounts(for: .copilot).first else { return }
do {
let username = try await CopilotUsageFetcher.fetchGitHubUsername(token: token)
self.removeTokenAccount(provider: .copilot, accountID: account.id)
self.addTokenAccount(provider: .copilot, label: username, token: token)
} catch {
// Keep fallback label — migration still succeeded
}
}
}
}

extension SettingsStore {
Expand Down
26 changes: 26 additions & 0 deletions Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,32 @@ public struct CopilotUsageFetcher: Sendable {
identity: identity)
}

public static func fetchGitHubUsername(token: String) async throws -> String {
guard let url = URL(string: "https://api.github.com/user") else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.setValue("token \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")

let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
throw URLError(.userAuthenticationRequired)
}
guard httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}

struct GitHubUser: Decodable {
let login: String
}
let user = try JSONDecoder().decode(GitHubUser.self, from: data)
return user.login
}

private func addCommonHeaders(to request: inout URLRequest) {
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("vscode/1.96.2", forHTTPHeaderField: "Editor-Version")
Expand Down
7 changes: 7 additions & 0 deletions Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,12 @@ extension TokenAccountSupportCatalog {
injection: .cookieHeader,
requiresManualCookieSource: true,
cookieName: nil),
.copilot: TokenAccountSupport(
title: "GitHub accounts",
subtitle: "Sign in with multiple GitHub accounts via OAuth.",
placeholder: "Paste GitHub token…",
injection: .environment(key: "COPILOT_API_TOKEN"),
requiresManualCookieSource: false,
cookieName: nil),
]
}
180 changes: 180 additions & 0 deletions Tests/CodexBarTests/CopilotMultiAccountTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import CodexBarCore
import Foundation
import Testing
@testable import CodexBar

// MARK: - Catalog

@Test
func `copilot catalog entry exists`() {
let support = TokenAccountSupportCatalog.support(for: .copilot)
#expect(support != nil)
#expect(support?.requiresManualCookieSource == false)
#expect(support?.cookieName == nil)
}

@Test
func `copilot catalog entry uses environment injection`() {
let support = TokenAccountSupportCatalog.support(for: .copilot)
guard let support else {
Issue.record("Copilot catalog entry missing")
return
}
if case let .environment(key) = support.injection {
#expect(key == "COPILOT_API_TOKEN")
} else {
Issue.record("Expected .environment injection, got cookieHeader")
}
}

@Test
func `copilot env override uses correct key`() {
let override = TokenAccountSupportCatalog.envOverride(for: .copilot, token: "gh_abc")
#expect(override == ["COPILOT_API_TOKEN": "gh_abc"])
}

// MARK: - Username Fetch (parsing only)

@Test
func `GitHub user response parses login`() throws {
let json = #"{"login": "testuser", "id": 123, "name": "Test User"}"#
struct GitHubUser: Decodable { let login: String }
let user = try JSONDecoder().decode(GitHubUser.self, from: Data(json.utf8))
#expect(user.login == "testuser")
}

@Test
func `GitHub user response parses login with minimal fields`() throws {
let json = #"{"login": "minimaluser"}"#
struct GitHubUser: Decodable { let login: String }
let user = try JSONDecoder().decode(GitHubUser.self, from: Data(json.utf8))
#expect(user.login == "minimaluser")
}

// MARK: - Migration

@MainActor
struct CopilotMigrationTests {
@Test
func `migration moves config token to account`() {
let settings = Self.makeSettingsStore(suite: "copilot-migration-move")
settings.copilotAPIToken = "gh_token_123"
#expect(settings.tokenAccounts(for: .copilot).isEmpty)

settings.migrateCopilotTokenToAccountIfNeeded()

#expect(settings.copilotAPIToken.isEmpty)
let accounts = settings.tokenAccounts(for: .copilot)
#expect(accounts.count == 1)
#expect(accounts.first?.token == "gh_token_123")
#expect(accounts.first?.label == "Account 1")
}

@Test
func `migration is idempotent`() {
let settings = Self.makeSettingsStore(suite: "copilot-migration-idempotent")
settings.copilotAPIToken = "gh_token_123"

settings.migrateCopilotTokenToAccountIfNeeded()
settings.migrateCopilotTokenToAccountIfNeeded()

#expect(settings.tokenAccounts(for: .copilot).count == 1)
}

@Test
func `migration no-op when no token`() {
let settings = Self.makeSettingsStore(suite: "copilot-migration-no-token")

settings.migrateCopilotTokenToAccountIfNeeded()

#expect(settings.tokenAccounts(for: .copilot).isEmpty)
}

@Test
func `migration no-op when accounts already exist`() {
let settings = Self.makeSettingsStore(suite: "copilot-migration-existing")
settings.copilotAPIToken = "gh_token_old"
settings.addTokenAccount(provider: .copilot, label: "existing", token: "gh_token_existing")

settings.migrateCopilotTokenToAccountIfNeeded()

#expect(settings.tokenAccounts(for: .copilot).count == 1)
#expect(settings.tokenAccounts(for: .copilot).first?.label == "existing")
}

private static func makeSettingsStore(suite: String) -> SettingsStore {
SettingsStore(
configStore: testConfigStore(suiteName: suite),
zaiTokenStore: NoopZaiTokenStore(),
syntheticTokenStore: NoopSyntheticTokenStore(),
codexCookieStore: InMemoryCookieHeaderStore(),
claudeCookieStore: InMemoryCookieHeaderStore(),
cursorCookieStore: InMemoryCookieHeaderStore(),
opencodeCookieStore: InMemoryCookieHeaderStore(),
factoryCookieStore: InMemoryCookieHeaderStore(),
minimaxCookieStore: InMemoryMiniMaxCookieStore(),
minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(),
kimiTokenStore: InMemoryKimiTokenStore(),
kimiK2TokenStore: InMemoryKimiK2TokenStore(),
augmentCookieStore: InMemoryCookieHeaderStore(),
ampCookieStore: InMemoryCookieHeaderStore(),
copilotTokenStore: InMemoryCopilotTokenStore(),
tokenAccountStore: InMemoryTokenAccountStore())
}
}

// MARK: - Environment Precedence

@MainActor
struct CopilotEnvironmentPrecedenceTests {
@Test
func `token account overrides config API key`() {
let settings = Self.makeSettingsStore(suite: "copilot-env-override")
settings.copilotAPIToken = "old_config_token"
settings.addTokenAccount(provider: .copilot, label: "new", token: "new_account_token")

let account = settings.selectedTokenAccount(for: .copilot)!
let override = TokenAccountOverride(provider: .copilot, account: account)
let env = ProviderRegistry.makeEnvironment(
base: [:],
provider: .copilot,
settings: settings,
tokenOverride: override)

#expect(env["COPILOT_API_TOKEN"] == "new_account_token")
}

@Test
func `config API key used when no token accounts`() {
let settings = Self.makeSettingsStore(suite: "copilot-env-config-only")
settings.copilotAPIToken = "config_token"

let env = ProviderRegistry.makeEnvironment(
base: [:],
provider: .copilot,
settings: settings,
tokenOverride: nil)

#expect(env["COPILOT_API_TOKEN"] == "config_token")
}

private static func makeSettingsStore(suite: String) -> SettingsStore {
SettingsStore(
configStore: testConfigStore(suiteName: suite),
zaiTokenStore: NoopZaiTokenStore(),
syntheticTokenStore: NoopSyntheticTokenStore(),
codexCookieStore: InMemoryCookieHeaderStore(),
claudeCookieStore: InMemoryCookieHeaderStore(),
cursorCookieStore: InMemoryCookieHeaderStore(),
opencodeCookieStore: InMemoryCookieHeaderStore(),
factoryCookieStore: InMemoryCookieHeaderStore(),
minimaxCookieStore: InMemoryMiniMaxCookieStore(),
minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(),
kimiTokenStore: InMemoryKimiTokenStore(),
kimiK2TokenStore: InMemoryKimiK2TokenStore(),
augmentCookieStore: InMemoryCookieHeaderStore(),
ampCookieStore: InMemoryCookieHeaderStore(),
copilotTokenStore: InMemoryCopilotTokenStore(),
tokenAccountStore: InMemoryTokenAccountStore())
}
}
Loading