From f18290f9adcceebdea4ec662f3a15626da4e9e20 Mon Sep 17 00:00:00 2001 From: dangyingkai Date: Tue, 20 Oct 2026 14:12:37 +0800 Subject: [PATCH 1/3] fix: support OpenCode 2.x credential storage Read active integration credentials from the OpenCode SQLite database in read-only mode, including OAuth metadata and API keys. Respect XDG_DATA_HOME and OPENCODE_DB, and use legacy auth.json discovery only when the database is absent. Share credential loading with Gemini OAuth and show the actual credential source in provider menus and diagnostics. --- .../App/StatusBarController.swift | 2 +- .../Providers/KimiProvider.swift | 2 +- .../Providers/MiniMaxProvider.swift | 2 +- .../Providers/OpenRouterProvider.swift | 2 +- .../Providers/ZaiCodingPlanProvider.swift | 2 +- .../Services/TokenManager.swift | 155 ++++++++++++++---- .../TokenManagerTests.swift | 70 ++++++++ README.md | 24 ++- 8 files changed, 219 insertions(+), 40 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift index d1709f16..59cf0e96 100644 --- a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift +++ b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift @@ -4024,7 +4024,7 @@ final class StatusBarController: NSObject { submenu.addItem(NSMenuItem.separator()) let authItem = NSMenuItem() authItem.view = createDisabledLabelView( - text: "Token From: ~/.local/share/opencode/auth.json", + text: "Token From: \(TokenManager.shared.lastFoundAuthPath?.path ?? "OpenCode")", icon: NSImage(systemSymbolName: "key", accessibilityDescription: "Auth Source"), multiline: true ) diff --git a/CopilotMonitor/CopilotMonitor/Providers/KimiProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/KimiProvider.swift index fde8f7f4..f31de088 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/KimiProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/KimiProvider.swift @@ -151,7 +151,7 @@ final class KimiProvider: ProviderProtocol { sevenDayReset: weeklyReset, planType: planType, email: kimiResponse.user?.userId, - authSource: "~/.local/share/opencode/auth.json" + authSource: tokenManager.lastFoundAuthPath?.path ) return ProviderResult(usage: providerUsage, details: details) diff --git a/CopilotMonitor/CopilotMonitor/Providers/MiniMaxProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/MiniMaxProvider.swift index be1a584a..e2b22077 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/MiniMaxProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/MiniMaxProvider.swift @@ -185,7 +185,7 @@ final class MiniMaxProvider: ProviderProtocol { fiveHourReset: dateFromMilliseconds(primaryRow.endTime), sevenDayUsage: weeklyUsage, sevenDayReset: dateFromMilliseconds(primaryRow.weeklyEndTime), - authSource: "~/.local/share/opencode/auth.json" + authSource: tokenManager.lastFoundAuthPath?.path ) logger.info( diff --git a/CopilotMonitor/CopilotMonitor/Providers/OpenRouterProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/OpenRouterProvider.swift index 4f948b35..971ab7fe 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/OpenRouterProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/OpenRouterProvider.swift @@ -78,7 +78,7 @@ final class OpenRouterProvider: ProviderProtocol { monthlyCost: monthlyCost, creditsRemaining: remainingCredits, creditsTotal: creditsResponse.data.total_credits, - authSource: "~/.local/share/opencode/auth.json" + authSource: TokenManager.shared.lastFoundAuthPath?.path ) return ProviderResult( diff --git a/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift index 9be127bd..3c6e5f09 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift @@ -275,7 +275,7 @@ final class ZaiCodingPlanProvider: ProviderProtocol { } let details = DetailedUsage( - authSource: "~/.local/share/opencode/auth.json", + authSource: tokenManager.lastFoundAuthPath?.path, tokenUsagePercent: tokenUsagePercent, tokenUsageReset: dateFromMilliseconds((tokenLimit ?? creditSessionLimit)?.nextResetTime), tokenUsageUsed: (tokenLimit ?? creditSessionLimit)?.currentValue, diff --git a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift index f3d2ef3e..a4e0a475 100644 --- a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift @@ -8,7 +8,7 @@ private let logger = Logger(subsystem: "com.opencodeproviders", category: "Token // MARK: - Data Structures for JSON Parsing -/// OpenCode Auth structure for ~/.local/share/opencode/auth.json +/// Provider credentials decoded from OpenCode's database or legacy auth.json. struct OpenCodeAuth: Codable { struct OAuth: Codable { let type: String @@ -306,7 +306,7 @@ struct OpenCodeAuth: Codable { throw DecodingError.dataCorrupted( DecodingError.Context( codingPath: container.codingPath, - debugDescription: "No valid auth entries found in auth.json" + debugDescription: "No valid OpenCode auth entries found" ) ) } @@ -970,6 +970,86 @@ final class TokenManager: @unchecked Sendable { ) } + /// OpenCode 2.x stores credentials in its data directory, including relative DB overrides. + func getOpenCodeDatabasePath() -> URL { + let environment = ProcessInfo.processInfo.environment + let dataHome = normalizedNonEmpty(environment["XDG_DATA_HOME"]) + .map { URL(fileURLWithPath: $0) } + ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/share") + let dataDirectory = dataHome.appendingPathComponent("opencode") + let configured = normalizedNonEmpty(environment["OPENCODE_DB"]) ?? "opencode.db" + if configured.hasPrefix("/") { + return URL(fileURLWithPath: configured) + } + return dataDirectory.appendingPathComponent(configured) + } + + private func getOpenCodeAuthPaths() -> [URL] { + let databasePath = getOpenCodeDatabasePath() + if FileManager.default.fileExists(atPath: databasePath.path) { + // An existing V2 store is authoritative, including empty or unreadable stores. + return [databasePath] + } + return getAuthFilePaths() + } + + private func readOpenCodeAuthData(at path: URL) throws -> Data { + guard path == getOpenCodeDatabasePath() else { + return try Data(contentsOf: path) + } + var database: OpaquePointer? + let status = sqlite3_open_v2(path.path, &database, SQLITE_OPEN_READONLY, nil) + defer { sqlite3_close(database) } + guard status == SQLITE_OK else { + throw NSError(domain: "OpenCodeCredentials", code: Int(status)) + } + sqlite3_busy_timeout(database, 1000) + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + let query = """ + SELECT integration_id, value FROM credential + WHERE integration_id IS NOT NULL + ORDER BY integration_id, active DESC, time_updated DESC + """ + let prepared = sqlite3_prepare_v2(database, query, -1, &statement, nil) + guard prepared == SQLITE_OK else { + throw NSError(domain: "OpenCodeCredentials", code: Int(prepared)) + } + var entries: [String: Any] = [:] + var selected = Set() + var step = sqlite3_step(statement) + while step == SQLITE_ROW { + if let idText = sqlite3_column_text(statement, 0) { + let integrationID = String(cString: idText) + if selected.insert(integrationID).inserted, + let valueText = sqlite3_column_text(statement, 1), + var value = (try? JSONSerialization.jsonObject( + with: Data(String(cString: valueText).utf8) + )) as? [String: Any] { + switch value["type"] as? String { + case "oauth": + if let metadata = value["metadata"] as? [String: Any] { + value["accountId"] = metadata["accountID"] + value["enterpriseUrl"] = metadata["enterpriseUrl"] + } + entries[integrationID] = value + case "key": + value["type"] = "api" + entries[integrationID] = value + default: + break + } + } + } + step = sqlite3_step(statement) + } + guard step == SQLITE_DONE else { + throw NSError(domain: "OpenCodeCredentials", code: Int(step)) + } + logger.info("Loaded OpenCode credential database: \(entries.count) integrations") + return try JSONSerialization.data(withJSONObject: entries) + } + /// Possible opencode.json/opencode.jsonc locations in priority order. /// For each directory, opencode.jsonc is preferred over opencode.json /// (matching copilothydra behavior): @@ -1349,7 +1429,7 @@ final class TokenManager: @unchecked Sendable { return nil } - /// Returns the path where auth.json was found, or nil if not found + /// Returns the path of the loaded OpenCode credential store, or nil if not found. /// Useful for displaying in UI to help users troubleshoot private(set) var lastFoundAuthPath: URL? @@ -1364,7 +1444,7 @@ final class TokenManager: @unchecked Sendable { } let fileManager = FileManager.default - let paths = getAuthFilePaths() + let paths = getOpenCodeAuthPaths() for authPath in paths { guard fileManager.fileExists(atPath: authPath.path) else { @@ -1376,7 +1456,7 @@ final class TokenManager: @unchecked Sendable { } do { - let data = try Data(contentsOf: authPath) + let data = try readOpenCodeAuthData(at: authPath) let auth = try JSONDecoder().decode(OpenCodeAuth.self, from: data) lastFoundAuthPath = authPath cachedAuth = auth @@ -1392,7 +1472,7 @@ final class TokenManager: @unchecked Sendable { lastFoundAuthPath = nil cachedAuth = nil cacheTimestamp = nil - logger.error("No valid auth.json found in any location") + logger.error("No valid OpenCode credentials found") return nil } } @@ -1402,6 +1482,9 @@ final class TokenManager: @unchecked Sendable { cachedAuth = nil cacheTimestamp = nil lastFoundAuthPath = nil + cachedGeminiOAuthAuth = nil + geminiOAuthCacheTimestamp = nil + lastFoundGeminiOAuthPath = nil cachedOpenCodeAnthropicCodexAccounts = nil openCodeAnthropicCodexAccountsCacheTimestamp = nil lastFoundOpenCodeAnthropicCodexAccountPaths = [] @@ -3898,7 +3981,7 @@ final class TokenManager: @unchecked Sendable { return (TokenManager.geminiClientId, TokenManager.geminiClientSecret) } - /// Thread-safe read of Gemini OAuth auth stored under "google" in OpenCode auth.json (jenslys/opencode-gemini-auth) + /// Thread-safe read of Gemini OAuth auth stored under "google" in OpenCode. func readGeminiOAuthAuth() -> GeminiOAuthAuth? { return queue.sync { if let cached = cachedGeminiOAuthAuth, @@ -3908,15 +3991,19 @@ final class TokenManager: @unchecked Sendable { } let fileManager = FileManager.default - let paths = getAuthFilePaths() + let paths = getOpenCodeAuthPaths() for authPath in paths { guard fileManager.fileExists(atPath: authPath.path) else { continue } + guard fileManager.isReadableFile(atPath: authPath.path) else { + logger.warning("Auth store not readable at \(authPath.path)") + continue + } do { - let data = try Data(contentsOf: authPath) + let data = try readOpenCodeAuthData(at: authPath) let container = try JSONDecoder().decode(OpenCodeGeminiAuthContainer.self, from: data) guard let geminiAuth = container.google else { continue @@ -4713,7 +4800,7 @@ final class TokenManager: @unchecked Sendable { if let auth = readOpenCodeAuth(), let token = auth.githubCopilot?.access.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { - return " [GitHub Copilot] CONFIGURED (auth.json)" + return " [GitHub Copilot] CONFIGURED (OpenCode)" } return " [GitHub Copilot] NOT CONFIGURED" } @@ -4805,7 +4892,7 @@ final class TokenManager: @unchecked Sendable { token: openCodeAuth?.openai?.access ?? openCodeAuth?.openaiAPIKey?.key, accountId: openCodeOpenAIMetadata.accountId ?? openCodeOpenAIMetadata.overrideAccountId ) - lines.append(" OpenCode auth.json (\(shortPath(openCodePath))): \(openAIStatus)") + lines.append(" OpenCode (\(shortPath(openCodePath))): \(openAIStatus)") let openCodeMultiAuthPaths = openCodeMultiAuthPaths() let existingOpenCodeMultiAuthPaths = openCodeMultiAuthPaths.filter { fileManager.fileExists(atPath: $0.path) } @@ -4884,7 +4971,7 @@ final class TokenManager: @unchecked Sendable { lines.append("") lines.append("[Claude]") - lines.append(" OpenCode auth.json (\(shortPath(openCodePath))): \(tokenStatus(hasAuth: openCodeAuth != nil, token: openCodeAuth?.anthropic?.access, accountId: openCodeAuth?.anthropic?.accountId))") + lines.append(" OpenCode (\(shortPath(openCodePath))): \(tokenStatus(hasAuth: openCodeAuth != nil, token: openCodeAuth?.anthropic?.access, accountId: openCodeAuth?.anthropic?.accountId))") let claudeTokenKeys: Set = ["accesstoken", "access", "oauthtoken", "token"] let claudeKeychainPrimary = "Claude Code-credentials" let claudeKeychainSecondary = "Claude Code" @@ -4907,7 +4994,7 @@ final class TokenManager: @unchecked Sendable { lines.append("") lines.append("[GitHub Copilot]") - lines.append(" OpenCode auth.json (\(shortPath(openCodePath))): \(tokenStatus(hasAuth: openCodeAuth != nil, token: openCodeAuth?.githubCopilot?.access, accountId: openCodeAuth?.githubCopilot?.accountId))") + lines.append(" OpenCode (\(shortPath(openCodePath))): \(tokenStatus(hasAuth: openCodeAuth != nil, token: openCodeAuth?.githubCopilot?.access, accountId: openCodeAuth?.githubCopilot?.accountId))") // Copilot CLI Keychain status let copilotCliKeychainAccounts = readCopilotCliKeychainAccounts() @@ -4930,9 +5017,9 @@ final class TokenManager: @unchecked Sendable { let geminiAuth = readGeminiOAuthAuth() let geminiAuthPath = lastFoundGeminiOAuthPath?.path ?? "auth.json" if let geminiAuth = geminiAuth, let refresh = geminiAuth.refresh, !refresh.isEmpty { - lines.append(" OpenCode auth.json (google.oauth, jenslys/opencode-gemini-auth, \(shortPath(geminiAuthPath))): FOUND") + lines.append(" OpenCode (google.oauth, jenslys/opencode-gemini-auth, \(shortPath(geminiAuthPath))): FOUND") } else { - lines.append(" OpenCode auth.json (google.oauth, jenslys/opencode-gemini-auth, \(shortPath(geminiAuthPath))): NOT FOUND") + lines.append(" OpenCode (google.oauth, jenslys/opencode-gemini-auth, \(shortPath(geminiAuthPath))): NOT FOUND") } let geminiOAuthCreds = readGeminiOAuthCreds() let geminiOAuthCredsPath = lastFoundGeminiOAuthCredsPath?.path ?? geminiOAuthCredsPath().path @@ -4975,13 +5062,15 @@ final class TokenManager: @unchecked Sendable { // 1. Check all possible auth.json paths (fallback order) debugLines.append("") debugLines.append("Auth File Search:") - let authPaths = getAuthFilePaths() + let authPaths = getOpenCodeAuthPaths() var foundAuthPath: URL? for (index, authPath) in authPaths.enumerated() { let priority = index + 1 let pathLabel: String - if hasXdgDataHome { + if authPath == getOpenCodeDatabasePath() { + pathLabel = authPath.deletingLastPathComponent().path + } else if hasXdgDataHome { switch index { case 0: pathLabel = "$XDG_DATA_HOME/opencode" @@ -5000,22 +5089,23 @@ final class TokenManager: @unchecked Sendable { } if fileManager.fileExists(atPath: authPath.path) { - if let content = try? String(contentsOf: authPath, encoding: .utf8) { + if let data = try? readOpenCodeAuthData(at: authPath), + let content = String(data: data, encoding: .utf8) { let lineCount = content.components(separatedBy: .newlines).count let byteCount = content.utf8.count let marker = foundAuthPath == nil ? "ACTIVE" : "SHADOWED" - debugLines.append(" [\(priority)] [\(marker)] \(pathLabel)/auth.json") + debugLines.append(" [\(priority)] [\(marker)] \(pathLabel)/\(authPath.lastPathComponent)") debugLines.append(" Path: \(authPath.path)") debugLines.append(" Lines: \(lineCount), Bytes: \(byteCount)") if foundAuthPath == nil { foundAuthPath = authPath } } else { - debugLines.append(" [\(priority)] [UNREADABLE] \(pathLabel)/auth.json") + debugLines.append(" [\(priority)] [UNREADABLE] \(pathLabel)/\(authPath.lastPathComponent)") debugLines.append(" Path: \(authPath.path)") } } else { - debugLines.append(" [\(priority)] [NOT FOUND] \(pathLabel)/auth.json") + debugLines.append(" [\(priority)] [NOT FOUND] \(pathLabel)/\(authPath.lastPathComponent)") debugLines.append(" Path: \(authPath.path)") } } @@ -5023,7 +5113,7 @@ final class TokenManager: @unchecked Sendable { if let activePath = foundAuthPath { debugLines.append(" [Result] Using: \(activePath.path)") } else { - debugLines.append(" [Result] NO VALID auth.json FOUND") + debugLines.append(" [Result] NO READABLE OPENCODE CREDENTIAL STORE FOUND") } // 2. Check ~/.config/opencode directory (antigravity-accounts.json) @@ -5065,7 +5155,7 @@ final class TokenManager: @unchecked Sendable { debugLines.append(" [Z.AI Coding Plan] \(auth.zaiCodingPlan != nil ? "CONFIGURED" : "NOT CONFIGURED")") debugLines.append(" [Nano-GPT] \(auth.nanoGpt != nil ? "CONFIGURED" : "NOT CONFIGURED")") } else { - debugLines.append(" [auth.json] PARSE FAILED or NOT FOUND") + debugLines.append(" [OpenCode credentials] PARSE FAILED or NOT FOUND") } // 4. Antigravity accounts @@ -5159,13 +5249,15 @@ final class TokenManager: @unchecked Sendable { // 1. Check all possible auth.json paths (fallback order) debugLines.append("---------- Auth File Search ----------") - let authPaths = getAuthFilePaths() + let authPaths = getOpenCodeAuthPaths() var foundAuthPath: URL? for (index, authPath) in authPaths.enumerated() { let priority = index + 1 let pathLabel: String - if hasXdgDataHome { + if authPath == getOpenCodeDatabasePath() { + pathLabel = authPath.deletingLastPathComponent().path + } else if hasXdgDataHome { switch index { case 0: pathLabel = "$XDG_DATA_HOME/opencode" @@ -5184,22 +5276,23 @@ final class TokenManager: @unchecked Sendable { } if fileManager.fileExists(atPath: authPath.path) { - if let content = try? String(contentsOf: authPath, encoding: .utf8) { + if let data = try? readOpenCodeAuthData(at: authPath), + let content = String(data: data, encoding: .utf8) { let lineCount = content.components(separatedBy: .newlines).count let byteCount = content.utf8.count let marker = foundAuthPath == nil ? "ACTIVE" : "SHADOWED" - debugLines.append("[\(priority)] [\(marker)] \(pathLabel)/auth.json") + debugLines.append("[\(priority)] [\(marker)] \(pathLabel)/\(authPath.lastPathComponent)") debugLines.append(" Path: \(authPath.path)") debugLines.append(" Lines: \(lineCount), Bytes: \(byteCount)") if foundAuthPath == nil { foundAuthPath = authPath } } else { - debugLines.append("[\(priority)] [UNREADABLE] \(pathLabel)/auth.json") + debugLines.append("[\(priority)] [UNREADABLE] \(pathLabel)/\(authPath.lastPathComponent)") debugLines.append(" Path: \(authPath.path)") } } else { - debugLines.append("[\(priority)] [NOT FOUND] \(pathLabel)/auth.json") + debugLines.append("[\(priority)] [NOT FOUND] \(pathLabel)/\(authPath.lastPathComponent)") debugLines.append(" Path: \(authPath.path)") } } @@ -5207,7 +5300,7 @@ final class TokenManager: @unchecked Sendable { if let activePath = foundAuthPath { debugLines.append("[Result] Using auth from: \(activePath.path)") } else { - debugLines.append("[Result] NO VALID auth.json FOUND IN ANY LOCATION") + debugLines.append("[Result] NO READABLE OPENCODE CREDENTIAL STORE FOUND") } // 2. ~/.local/share/opencode directory contents @@ -5403,7 +5496,7 @@ final class TokenManager: @unchecked Sendable { debugLines.append("[Nano-GPT] NOT CONFIGURED") } } else { - debugLines.append("[auth.json] PARSE FAILED or NOT FOUND") + debugLines.append("[OpenCode credentials] PARSE FAILED or NOT FOUND") } // 6. Antigravity accounts diff --git a/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift b/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift index 72ea9ea9..58f7e0b3 100644 --- a/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift @@ -1,7 +1,77 @@ import XCTest +import SQLite3 @testable import OpenCode_Bar final class TokenManagerTests: XCTestCase { + func testOpenCodeDatabasePrecedenceAndCredentialSelection() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let dataDirectory = directory.appendingPathComponent("opencode") + try FileManager.default.createDirectory(at: dataDirectory, withIntermediateDirectories: true) + let oldEnvironment = ProcessInfo.processInfo.environment + defer { + for name in ["XDG_DATA_HOME", "OPENCODE_DB"] { + if let value = oldEnvironment[name] { + setenv(name, value, 1) + } else { + unsetenv(name) + } + } + TokenManager.shared.clearOpenCodeAuthCacheForTesting() + try? FileManager.default.removeItem(at: directory) + } + setenv("XDG_DATA_HOME", directory.path, 1) + unsetenv("OPENCODE_DB") + let manager = TokenManager.shared + let legacyPath = dataDirectory.appendingPathComponent("auth.json") + try Data(#"{"openai":{"type":"oauth","access":"legacy"}}"#.utf8).write(to: legacyPath) + manager.clearOpenCodeAuthCacheForTesting() + XCTAssertEqual(manager.readOpenCodeAuth()?.openai?.access, "legacy") + XCTAssertEqual(manager.lastFoundAuthPath, legacyPath) + + let databasePath = dataDirectory.appendingPathComponent("opencode.db") + XCTAssertEqual(manager.getOpenCodeDatabasePath(), databasePath) + var database: OpaquePointer? + XCTAssertEqual(sqlite3_open(databasePath.path, &database), SQLITE_OK) + defer { sqlite3_close(database) } + let sql = """ + CREATE TABLE credential (integration_id TEXT, value TEXT, active INTEGER, time_updated INTEGER); + INSERT INTO credential VALUES + ('openai', '{"type":"oauth","access":"newer","metadata":{"accountID":"other"}}', 0, 20), + ('openai', '{"type":"oauth","access":"selected","metadata":{"accountID":"account"}}', 1, 10), + ('openrouter', '{"type":"key","key":"router-key"}', 1, 10), + ('anthropic', 'invalid-json', 1, 20), + ('anthropic', '{"type":"oauth","access":"stale"}', 0, 10), + ('google', '{"type":"oauth","access":"google-token","refresh":"google-refresh"}', 1, 10); + """ + XCTAssertEqual(sqlite3_exec(database, sql, nil, nil, nil), SQLITE_OK) + manager.clearOpenCodeAuthCacheForTesting() + let auth = try XCTUnwrap(manager.readOpenCodeAuth()) + XCTAssertEqual(auth.openai?.access, "selected") + XCTAssertEqual(auth.openai?.accountId, "account") + XCTAssertEqual(auth.openrouter?.key, "router-key") + XCTAssertNil(auth.anthropic) + XCTAssertEqual(manager.lastFoundAuthPath, databasePath) + XCTAssertEqual(manager.readGeminiOAuthAuth()?.access, "google-token") + XCTAssertEqual(manager.lastFoundGeminiOAuthPath, databasePath) + + XCTAssertEqual(sqlite3_exec(database, "UPDATE credential SET active = 0", nil, nil, nil), SQLITE_OK) + manager.clearOpenCodeAuthCacheForTesting() + XCTAssertEqual(manager.readOpenCodeAuth()?.openai?.access, "newer") + + XCTAssertEqual(sqlite3_exec(database, "DELETE FROM credential", nil, nil, nil), SQLITE_OK) + manager.clearOpenCodeAuthCacheForTesting() + XCTAssertNil(manager.readOpenCodeAuth()) + XCTAssertNil(manager.readGeminiOAuthAuth()) + XCTAssertEqual(sqlite3_exec(database, "DROP TABLE credential", nil, nil, nil), SQLITE_OK) + manager.clearOpenCodeAuthCacheForTesting() + XCTAssertNil(manager.readOpenCodeAuth()) + + setenv("OPENCODE_DB", "custom.db", 1) + XCTAssertEqual(manager.getOpenCodeDatabasePath(), dataDirectory.appendingPathComponent("custom.db")) + setenv("OPENCODE_DB", databasePath.path, 1) + XCTAssertEqual(manager.getOpenCodeDatabasePath(), databasePath) + } + func testGeminiProjectPolicyPrefersConfiguredProject() { XCTAssertEqual( GeminiProjectPolicy.resolve(primary: "configured-project", fallback: "managed-project"), diff --git a/README.md b/README.md index c5fb260c..9a4bab71 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Download the latest `.dmg` file from the [**Releases**](https://github.com/opggi ### Other AI agents beyond OpenCode that supports auto-detection - **GitHub Copilot** (multi-source discovery) - - **OpenCode auth** - Auto-detected from OpenCode `auth.json` (`copilot` provider entry) + - **OpenCode auth** - Auto-detected from the OpenCode credential store (`github-copilot` integration) - **Copilot CLI** - Auto-detected through macOS Keychain (`github.com` entries) - **VS Code / Cursor** - Auto-detected from `~/.config/github-copilot/hosts.json` and `~/.config/github-copilot/apps.json` - **Browser Cookies** - Chrome, Brave, Arc, Edge session cookies @@ -90,11 +90,27 @@ Download the latest `.dmg` file from the [**Releases**](https://github.com/opggi ## Features ### Automatic Provider Detection -- **Zero Configuration**: Reads your OpenCode `auth.json` automatically +- **Zero Configuration**: Reads OpenCode 2.x credentials from `opencode.db`, or OpenCode 1.x credentials from `auth.json` - **Multi-path Support**: Searches `$XDG_DATA_HOME/opencode`, `~/.local/share/opencode`, and `~/Library/Application Support/opencode` - **Dynamic Updates**: New providers appear as you add them to OpenCode - **Smart Categorization**: Pay-as-you-go vs Quota-based providers displayed separately +### OpenCode Authentication + +OpenCode Bar reads the OpenCode 2.x `credential` table in read-only mode. It uses +the active credential for each integration, or the most recently updated one +when none is active. OAuth account metadata and API keys are supported. + +The database defaults to `$XDG_DATA_HOME/opencode/opencode.db`, or +`~/.local/share/opencode/opencode.db` when `XDG_DATA_HOME` is unset. `OPENCODE_DB` +can specify an absolute path or a path relative to the OpenCode data directory. +Environment overrides must be available to the app or CLI process. + +When the database is absent, OpenCode Bar uses the existing OpenCode 1.x +`auth.json` discovery. An existing empty, unreadable, or invalid database does +not fall back to old credentials. OpenCode remains responsible for refreshing +its credentials. Token source labels show the store that was actually loaded. + ### Kiro Authentication Kiro usage is fetched from its regional usage API with credentials read from @@ -222,13 +238,13 @@ OpenRouter Pay-as-you-go - $37.42 spent #### MiniMax Notes -- MiniMax Coding Plan is resolved from the OpenCode auth entry `minimax-coding-plan` in `auth.json`. +- MiniMax Coding Plan is resolved from the OpenCode credential entry `minimax-coding-plan`. - OpenCode Bar uses the Coding Plan remains endpoint and converts it into used percentages for the menu bar app and CLI. - MiniMax response fields `current_interval_usage_count` and `current_weekly_usage_count` behave as remaining counts despite their names, so OpenCode Bar calculates used percent as `total - remaining`. #### OpenCode Go Notes -- OpenCode Go is resolved from the OpenCode auth entry `opencode-go` in `auth.json`. +- OpenCode Go is resolved from the OpenCode credential entry `opencode-go`. - OpenCode Bar validates the API key against `https://opencode.ai/zen/go/v1/models`. - Usage windows come from the OpenCode dashboard and require `OPENCODE_GO_WORKSPACE_ID` plus `OPENCODE_GO_AUTH_COOKIE`, or `~/.config/opencode-bar/opencode-go.json`. - The monthly dashboard window is a usage cap signal; the app's subscription preset for the Go plan remains `$10.00`. From 26309a89b4d95b371c253d28a8a6fa0a90ab48d5 Mon Sep 17 00:00:00 2001 From: dangyingkai Date: Tue, 20 Oct 2026 15:02:44 +0800 Subject: [PATCH 2/3] fix: clarify OpenCode credential sources and diagnostics Use a consistent source label across providers and recognize custom credential database paths. Carry the selected storage format into readers, share credential diagnostics, and distinguish empty stores from usable credentials. Preserve account metadata when optional database metadata is absent. Isolate credential fixtures from local accounts, cover WAL reads after the writer closes, and update troubleshooting guidance for OpenCode 2.x. --- .../App/StatusBarController.swift | 6 +- .../Providers/ChutesProvider.swift | 2 +- .../Providers/DeepSeekProvider.swift | 2 +- .../Providers/KimiProvider.swift | 2 +- .../Providers/MiniMaxProvider.swift | 2 +- .../Providers/NanoGptProvider.swift | 2 +- .../Providers/OpenCodeGoProvider.swift | 2 +- .../Providers/OpenRouterProvider.swift | 2 +- .../Providers/SyntheticProvider.swift | 2 +- .../Providers/ZaiCodingPlanProvider.swift | 2 +- .../Services/TokenManager.swift | 203 ++++++------------ .../MiniMaxProviderTests.swift | 28 ++- .../TokenManagerTests.swift | 36 ++++ README.md | 16 +- 14 files changed, 153 insertions(+), 154 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift index 59cf0e96..14eb3368 100644 --- a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift +++ b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift @@ -2518,7 +2518,9 @@ final class StatusBarController: NSObject { func parseSingleSource(_ rawSource: String) -> String? { let lowercased = rawSource.lowercased() - if lowercased.contains("opencode") { + if lowercased.contains("opencode") + || rawSource == TokenManager.shared.getOpenCodeDatabasePath().path + || rawSource == TokenManager.shared.openCodeAuthSourceLabel { return "OpenCode" } @@ -4024,7 +4026,7 @@ final class StatusBarController: NSObject { submenu.addItem(NSMenuItem.separator()) let authItem = NSMenuItem() authItem.view = createDisabledLabelView( - text: "Token From: \(TokenManager.shared.lastFoundAuthPath?.path ?? "OpenCode")", + text: "Token From: \(TokenManager.shared.openCodeAuthSourceLabel)", icon: NSImage(systemSymbolName: "key", accessibilityDescription: "Auth Source"), multiline: true ) diff --git a/CopilotMonitor/CopilotMonitor/Providers/ChutesProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/ChutesProvider.swift index d9b086a0..a688abaf 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/ChutesProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/ChutesProvider.swift @@ -142,7 +142,7 @@ final class ChutesProvider: ProviderProtocol { chutesMonthlyValueCapUSD: monthlyValueCapUSD, chutesMonthlyValueUsedUSD: monthlyValueUsedUSD, chutesMonthlyValueUsedPercent: monthlyValueUsedPercent, - authSource: tokenManager.lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + authSource: tokenManager.openCodeAuthSourceLabel ) return ProviderResult(usage: providerUsage, details: details) diff --git a/CopilotMonitor/CopilotMonitor/Providers/DeepSeekProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/DeepSeekProvider.swift index 18d8dd03..1dd05b5d 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/DeepSeekProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/DeepSeekProvider.swift @@ -106,7 +106,7 @@ final class DeepSeekProvider: ProviderProtocol { balanceCurrency: currency, balanceGranted: grantedBalance, balanceToppedUp: toppedUpBalance, - authSource: tokenManager.lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + authSource: tokenManager.openCodeAuthSourceLabel ) // `cost` stays nil: it represents money already spent, while DeepSeek diff --git a/CopilotMonitor/CopilotMonitor/Providers/KimiProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/KimiProvider.swift index f31de088..0d16860d 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/KimiProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/KimiProvider.swift @@ -151,7 +151,7 @@ final class KimiProvider: ProviderProtocol { sevenDayReset: weeklyReset, planType: planType, email: kimiResponse.user?.userId, - authSource: tokenManager.lastFoundAuthPath?.path + authSource: tokenManager.openCodeAuthSourceLabel ) return ProviderResult(usage: providerUsage, details: details) diff --git a/CopilotMonitor/CopilotMonitor/Providers/MiniMaxProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/MiniMaxProvider.swift index e2b22077..09d1e877 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/MiniMaxProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/MiniMaxProvider.swift @@ -185,7 +185,7 @@ final class MiniMaxProvider: ProviderProtocol { fiveHourReset: dateFromMilliseconds(primaryRow.endTime), sevenDayUsage: weeklyUsage, sevenDayReset: dateFromMilliseconds(primaryRow.weeklyEndTime), - authSource: tokenManager.lastFoundAuthPath?.path + authSource: tokenManager.openCodeAuthSourceLabel ) logger.info( diff --git a/CopilotMonitor/CopilotMonitor/Providers/NanoGptProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/NanoGptProvider.swift index 24af0732..67c0dbac 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/NanoGptProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/NanoGptProvider.swift @@ -365,7 +365,7 @@ final class NanoGptProvider: ProviderProtocol { sevenDayUsage: weeklyInputTokenPercentUsed, sevenDayReset: dateFromMilliseconds(usageResponse.weeklyInputTokens?.resetAt), creditsBalance: parseDouble(balanceResponse?.usdBalance), - authSource: tokenManager.lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + authSource: tokenManager.openCodeAuthSourceLabel ) logger.info( diff --git a/CopilotMonitor/CopilotMonitor/Providers/OpenCodeGoProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/OpenCodeGoProvider.swift index dafa953f..27847038 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/OpenCodeGoProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/OpenCodeGoProvider.swift @@ -89,7 +89,7 @@ final class OpenCodeGoProvider: ProviderProtocol { overagePermitted: false ) - let authPath = tokenManager.lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + let authPath = tokenManager.openCodeAuthSourceLabel let details = DetailedUsage( fiveHourUsage: dashboardUsage.rolling?.usagePercent, fiveHourReset: dashboardUsage.rolling?.resetDate, diff --git a/CopilotMonitor/CopilotMonitor/Providers/OpenRouterProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/OpenRouterProvider.swift index 971ab7fe..a0badb9d 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/OpenRouterProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/OpenRouterProvider.swift @@ -78,7 +78,7 @@ final class OpenRouterProvider: ProviderProtocol { monthlyCost: monthlyCost, creditsRemaining: remainingCredits, creditsTotal: creditsResponse.data.total_credits, - authSource: TokenManager.shared.lastFoundAuthPath?.path + authSource: TokenManager.shared.openCodeAuthSourceLabel ) return ProviderResult( diff --git a/CopilotMonitor/CopilotMonitor/Providers/SyntheticProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/SyntheticProvider.swift index fffe7cb6..d4cebdb0 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/SyntheticProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/SyntheticProvider.swift @@ -92,7 +92,7 @@ final class SyntheticProvider: ProviderProtocol { overagePermitted: false ) - let authSource = tokenManager.lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + let authSource = tokenManager.openCodeAuthSourceLabel let details = DetailedUsage( limit: Double(limit), limitRemaining: Double(remaining), diff --git a/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift index 3c6e5f09..ffc1ce30 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift @@ -275,7 +275,7 @@ final class ZaiCodingPlanProvider: ProviderProtocol { } let details = DetailedUsage( - authSource: tokenManager.lastFoundAuthPath?.path, + authSource: tokenManager.openCodeAuthSourceLabel, tokenUsagePercent: tokenUsagePercent, tokenUsageReset: dateFromMilliseconds((tokenLimit ?? creditSessionLimit)?.nextResetTime), tokenUsageUsed: (tokenLimit ?? creditSessionLimit)?.currentValue, diff --git a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift index a4e0a475..c72e24b5 100644 --- a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift @@ -600,7 +600,7 @@ struct AntigravityAccounts: Codable { enum GeminiAuthSource: Equatable { /// NoeFabris/opencode-antigravity-auth (~/.config/opencode/antigravity-accounts.json) case antigravity - /// jenslys/opencode-gemini-auth (OpenCode auth.json google.oauth) + /// jenslys/opencode-gemini-auth (OpenCode google OAuth credentials) case opencodeAuth /// Gemini CLI OAuth credentials (~/.gemini/oauth_creds.json) case oauthCreds @@ -696,7 +696,7 @@ struct OpenCodeGeminiAuthContainer: Decodable { let google: GeminiOAuthAuth? } -/// Gemini OAuth payload as stored in OpenCode auth.json +/// Gemini OAuth payload from the OpenCode credential store. struct GeminiOAuthAuth: Decodable { let type: String? let refresh: String? @@ -864,11 +864,11 @@ final class TokenManager: @unchecked Sendable { private var cachedAntigravityAccounts: AntigravityAccounts? private var antigravityCacheTimestamp: Date? - /// Cached Gemini OAuth auth payload (OpenCode auth.json) + /// Cached Gemini OAuth auth payload from OpenCode. private var cachedGeminiOAuthAuth: GeminiOAuthAuth? private var geminiOAuthCacheTimestamp: Date? - /// Path where Gemini OAuth auth was found (OpenCode auth.json) + /// Path of the OpenCode store where Gemini OAuth auth was found. private(set) var lastFoundGeminiOAuthPath: URL? /// Cached Gemini OAuth creds payload (~/.gemini/oauth_creds.json) @@ -970,7 +970,7 @@ final class TokenManager: @unchecked Sendable { ) } - /// OpenCode 2.x stores credentials in its data directory, including relative DB overrides. + /// Resolves OPENCODE_DB relative to the OpenCode data directory unless it is absolute. func getOpenCodeDatabasePath() -> URL { let environment = ProcessInfo.processInfo.environment let dataHome = normalizedNonEmpty(environment["XDG_DATA_HOME"]) @@ -984,17 +984,17 @@ final class TokenManager: @unchecked Sendable { return dataDirectory.appendingPathComponent(configured) } - private func getOpenCodeAuthPaths() -> [URL] { + private func getOpenCodeAuthStores() -> [(url: URL, isDatabase: Bool)] { let databasePath = getOpenCodeDatabasePath() if FileManager.default.fileExists(atPath: databasePath.path) { // An existing V2 store is authoritative, including empty or unreadable stores. - return [databasePath] + return [(databasePath, true)] } - return getAuthFilePaths() + return getAuthFilePaths().map { ($0, false) } } - private func readOpenCodeAuthData(at path: URL) throws -> Data { - guard path == getOpenCodeDatabasePath() else { + private func readOpenCodeAuthData(at path: URL, isDatabase: Bool) throws -> Data { + guard isDatabase else { return try Data(contentsOf: path) } var database: OpaquePointer? @@ -1029,8 +1029,12 @@ final class TokenManager: @unchecked Sendable { switch value["type"] as? String { case "oauth": if let metadata = value["metadata"] as? [String: Any] { - value["accountId"] = metadata["accountID"] - value["enterpriseUrl"] = metadata["enterpriseUrl"] + if let accountID = metadata["accountID"] as? String, !accountID.isEmpty { + value["accountId"] = accountID + } + if let enterpriseURL = metadata["enterpriseUrl"] as? String, !enterpriseURL.isEmpty { + value["enterpriseUrl"] = enterpriseURL + } } entries[integrationID] = value case "key": @@ -1050,6 +1054,39 @@ final class TokenManager: @unchecked Sendable { return try JSONSerialization.data(withJSONObject: entries) } + func openCodeAuthStoreDiagnostics() -> [String] { + var lines: [String] = [] + var selectedPath: URL? + for store in getOpenCodeAuthStores() { + let path = store.url + guard FileManager.default.fileExists(atPath: path.path) else { + lines.append("[NOT FOUND] \(path.path)") + continue + } + guard FileManager.default.isReadableFile(atPath: path.path), + let data = try? readOpenCodeAuthData(at: path, isDatabase: store.isDatabase) else { + lines.append("[UNREADABLE] \(path.path)") + continue + } + let auth = try? JSONDecoder().decode(OpenCodeAuth.self, from: data) + let google = (try? JSONDecoder().decode(OpenCodeGeminiAuthContainer.self, from: data))?.google + let hasGoogle = google?.type?.lowercased() == "oauth" + && normalizedNonEmpty(google?.refresh) != nil + guard auth != nil || hasGoogle else { + lines.append("[NO USABLE CREDENTIALS] \(path.path)") + continue + } + lines.append("[\(selectedPath == nil ? "ACTIVE" : "SHADOWED")] \(path.path)") + if selectedPath == nil { selectedPath = path } + } + if let selectedPath { + lines.append("[Result] Using: \(selectedPath.path)") + } else { + lines.append("[Result] NO USABLE OPENCODE CREDENTIALS FOUND") + } + return lines + } + /// Possible opencode.json/opencode.jsonc locations in priority order. /// For each directory, opencode.jsonc is preferred over opencode.json /// (matching copilothydra behavior): @@ -1433,6 +1470,10 @@ final class TokenManager: @unchecked Sendable { /// Useful for displaying in UI to help users troubleshoot private(set) var lastFoundAuthPath: URL? + var openCodeAuthSourceLabel: String { + queue.sync { lastFoundAuthPath?.path ?? "OpenCode" } + } + /// Thread-safe read of OpenCode auth tokens with caching func readOpenCodeAuth() -> OpenCodeAuth? { return queue.sync { @@ -1444,9 +1485,10 @@ final class TokenManager: @unchecked Sendable { } let fileManager = FileManager.default - let paths = getOpenCodeAuthPaths() + let stores = getOpenCodeAuthStores() - for authPath in paths { + for store in stores { + let authPath = store.url guard fileManager.fileExists(atPath: authPath.path) else { continue } @@ -1456,7 +1498,7 @@ final class TokenManager: @unchecked Sendable { } do { - let data = try readOpenCodeAuthData(at: authPath) + let data = try readOpenCodeAuthData(at: authPath, isDatabase: store.isDatabase) let auth = try JSONDecoder().decode(OpenCodeAuth.self, from: data) lastFoundAuthPath = authPath cachedAuth = auth @@ -3512,7 +3554,7 @@ final class TokenManager: @unchecked Sendable { if let auth = readOpenCodeAuth(), let access = auth.anthropic?.access, !access.isEmpty { - let authSource = lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + let authSource = openCodeAuthSourceLabel accounts.append( ClaudeAuthAccount( accessToken: access, @@ -3777,7 +3819,7 @@ final class TokenManager: @unchecked Sendable { if let auth = readOpenCodeAuth(), let access = auth.githubCopilot?.access, !access.isEmpty { - let authSource = lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + let authSource = openCodeAuthSourceLabel accounts.append( CopilotAuthAccount( accessToken: access, @@ -3848,7 +3890,7 @@ final class TokenManager: @unchecked Sendable { if let auth = readOpenCodeAuth(), let access = auth.openai?.access, !access.isEmpty { - let authSource = lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + let authSource = openCodeAuthSourceLabel let metadata = resolvedOpenAIAuthMetadata(from: auth.openai) accounts.append( OpenAIAuthAccount( @@ -3867,7 +3909,7 @@ final class TokenManager: @unchecked Sendable { if let auth = readOpenCodeAuth(), let apiKey = auth.openaiAPIKey, !apiKey.key.isEmpty { - let authSource = lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + let authSource = openCodeAuthSourceLabel accounts.append( OpenAIAuthAccount( accessToken: apiKey.key, @@ -3991,9 +4033,10 @@ final class TokenManager: @unchecked Sendable { } let fileManager = FileManager.default - let paths = getOpenCodeAuthPaths() + let stores = getOpenCodeAuthStores() - for authPath in paths { + for store in stores { + let authPath = store.url guard fileManager.fileExists(atPath: authPath.path) else { continue } @@ -4003,7 +4046,7 @@ final class TokenManager: @unchecked Sendable { continue } do { - let data = try readOpenCodeAuthData(at: authPath) + let data = try readOpenCodeAuthData(at: authPath, isDatabase: store.isDatabase) let container = try JSONDecoder().decode(OpenCodeGeminiAuthContainer.self, from: data) guard let geminiAuth = container.google else { continue @@ -4822,7 +4865,7 @@ final class TokenManager: @unchecked Sendable { let fileManager = FileManager.default let homeDir = fileManager.homeDirectoryForCurrentUser let openCodeAuth = readOpenCodeAuth() - let openCodePath = lastFoundAuthPath?.path ?? "~/.local/share/opencode/auth.json" + let openCodePath = openCodeAuthSourceLabel var lines: [String] = [] lines.append("Auth Discovery Summary:") @@ -5052,69 +5095,16 @@ final class TokenManager: @unchecked Sendable { debugLines.append(String(repeating: "─", count: 40)) // 0. XDG_DATA_HOME environment variable - let hasXdgDataHome = ProcessInfo.processInfo.environment["XDG_DATA_HOME"]?.isEmpty == false if let xdgDataHome = ProcessInfo.processInfo.environment["XDG_DATA_HOME"], !xdgDataHome.isEmpty { debugLines.append("[XDG_DATA_HOME] SET: \(xdgDataHome)") } else { debugLines.append("[XDG_DATA_HOME] NOT SET (using default ~/.local/share)") } - // 1. Check all possible auth.json paths (fallback order) + // 1. Report the selected OpenCode credential stores. debugLines.append("") debugLines.append("Auth File Search:") - let authPaths = getOpenCodeAuthPaths() - var foundAuthPath: URL? - - for (index, authPath) in authPaths.enumerated() { - let priority = index + 1 - let pathLabel: String - if authPath == getOpenCodeDatabasePath() { - pathLabel = authPath.deletingLastPathComponent().path - } else if hasXdgDataHome { - switch index { - case 0: - pathLabel = "$XDG_DATA_HOME/opencode" - case 1: - pathLabel = "~/.local/share/opencode" - default: - pathLabel = "~/Library/Application Support/opencode" - } - } else { - switch index { - case 0: - pathLabel = "~/.local/share/opencode" - default: - pathLabel = "~/Library/Application Support/opencode" - } - } - - if fileManager.fileExists(atPath: authPath.path) { - if let data = try? readOpenCodeAuthData(at: authPath), - let content = String(data: data, encoding: .utf8) { - let lineCount = content.components(separatedBy: .newlines).count - let byteCount = content.utf8.count - let marker = foundAuthPath == nil ? "ACTIVE" : "SHADOWED" - debugLines.append(" [\(priority)] [\(marker)] \(pathLabel)/\(authPath.lastPathComponent)") - debugLines.append(" Path: \(authPath.path)") - debugLines.append(" Lines: \(lineCount), Bytes: \(byteCount)") - if foundAuthPath == nil { - foundAuthPath = authPath - } - } else { - debugLines.append(" [\(priority)] [UNREADABLE] \(pathLabel)/\(authPath.lastPathComponent)") - debugLines.append(" Path: \(authPath.path)") - } - } else { - debugLines.append(" [\(priority)] [NOT FOUND] \(pathLabel)/\(authPath.lastPathComponent)") - debugLines.append(" Path: \(authPath.path)") - } - } - - if let activePath = foundAuthPath { - debugLines.append(" [Result] Using: \(activePath.path)") - } else { - debugLines.append(" [Result] NO READABLE OPENCODE CREDENTIAL STORE FOUND") - } + debugLines.append(contentsOf: openCodeAuthStoreDiagnostics().map { " " + $0 }) // 2. Check ~/.config/opencode directory (antigravity-accounts.json) debugLines.append("") @@ -5240,68 +5230,15 @@ final class TokenManager: @unchecked Sendable { debugLines.append("========== Environment Debug Info ==========") // 0. XDG_DATA_HOME environment variable - let hasXdgDataHome = ProcessInfo.processInfo.environment["XDG_DATA_HOME"]?.isEmpty == false if let xdgDataHome = ProcessInfo.processInfo.environment["XDG_DATA_HOME"], !xdgDataHome.isEmpty { debugLines.append("[XDG_DATA_HOME] SET: \(xdgDataHome)") } else { debugLines.append("[XDG_DATA_HOME] NOT SET (using default ~/.local/share)") } - // 1. Check all possible auth.json paths (fallback order) + // 1. Report the selected OpenCode credential stores. debugLines.append("---------- Auth File Search ----------") - let authPaths = getOpenCodeAuthPaths() - var foundAuthPath: URL? - - for (index, authPath) in authPaths.enumerated() { - let priority = index + 1 - let pathLabel: String - if authPath == getOpenCodeDatabasePath() { - pathLabel = authPath.deletingLastPathComponent().path - } else if hasXdgDataHome { - switch index { - case 0: - pathLabel = "$XDG_DATA_HOME/opencode" - case 1: - pathLabel = "~/.local/share/opencode" - default: - pathLabel = "~/Library/Application Support/opencode" - } - } else { - switch index { - case 0: - pathLabel = "~/.local/share/opencode" - default: - pathLabel = "~/Library/Application Support/opencode" - } - } - - if fileManager.fileExists(atPath: authPath.path) { - if let data = try? readOpenCodeAuthData(at: authPath), - let content = String(data: data, encoding: .utf8) { - let lineCount = content.components(separatedBy: .newlines).count - let byteCount = content.utf8.count - let marker = foundAuthPath == nil ? "ACTIVE" : "SHADOWED" - debugLines.append("[\(priority)] [\(marker)] \(pathLabel)/\(authPath.lastPathComponent)") - debugLines.append(" Path: \(authPath.path)") - debugLines.append(" Lines: \(lineCount), Bytes: \(byteCount)") - if foundAuthPath == nil { - foundAuthPath = authPath - } - } else { - debugLines.append("[\(priority)] [UNREADABLE] \(pathLabel)/\(authPath.lastPathComponent)") - debugLines.append(" Path: \(authPath.path)") - } - } else { - debugLines.append("[\(priority)] [NOT FOUND] \(pathLabel)/\(authPath.lastPathComponent)") - debugLines.append(" Path: \(authPath.path)") - } - } - - if let activePath = foundAuthPath { - debugLines.append("[Result] Using auth from: \(activePath.path)") - } else { - debugLines.append("[Result] NO READABLE OPENCODE CREDENTIAL STORE FOUND") - } + debugLines.append(contentsOf: openCodeAuthStoreDiagnostics()) // 2. ~/.local/share/opencode directory contents debugLines.append("---------- Directory Contents ----------") diff --git a/CopilotMonitor/CopilotMonitorTests/MiniMaxProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/MiniMaxProviderTests.swift index 8459b270..7a163eb0 100644 --- a/CopilotMonitor/CopilotMonitorTests/MiniMaxProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/MiniMaxProviderTests.swift @@ -54,10 +54,30 @@ final class MiniMaxProviderTests: XCTestCase { } func testFetchSuccessCreatesProviderResult() async throws { - guard TokenManager.shared.getMiniMaxCodingPlanAPIKey() != nil else { - throw XCTSkip("MiniMax Coding Plan API key not available; skipping fetch test.") + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let authDirectory = directory.appendingPathComponent("opencode") + try FileManager.default.createDirectory(at: authDirectory, withIntermediateDirectories: true) + let oldEnvironment = ProcessInfo.processInfo.environment + defer { + for name in ["XDG_DATA_HOME", "OPENCODE_DB"] { + if let value = oldEnvironment[name] { + setenv(name, value, 1) + } else { + unsetenv(name) + } + } + TokenManager.shared.clearOpenCodeAuthCacheForTesting() + try? FileManager.default.removeItem(at: directory) } - + setenv("XDG_DATA_HOME", directory.path, 1) + unsetenv("OPENCODE_DB") + let authPath = authDirectory.appendingPathComponent("auth.json") + try Data(#"{"minimax-coding-plan":{"type":"api","key":"fixture-minimax-key"}}"#.utf8).write(to: authPath) + TokenManager.shared.clearOpenCodeAuthCacheForTesting() + XCTAssertEqual(TokenManager.shared.getMiniMaxCodingPlanAPIKey(), "fixture-minimax-key") + + let expectedAuthSource = TokenManager.shared.openCodeAuthSourceLabel + XCTAssertEqual(expectedAuthSource, authPath.path) let session = makeSession() let provider = MiniMaxProvider(tokenManager: .shared, session: session) let responseJSON = """ @@ -120,7 +140,7 @@ final class MiniMaxProviderTests: XCTestCase { XCTAssertEqual(result.details?.sevenDayUsage ?? -1, 60.0, accuracy: 0.001) XCTAssertNotNil(result.details?.fiveHourReset) XCTAssertNotNil(result.details?.sevenDayReset) - XCTAssertEqual(result.details?.authSource, "~/.local/share/opencode/auth.json") + XCTAssertEqual(result.details?.authSource, expectedAuthSource) } func testFetchReturnsAuthenticationErrorOn401() async throws { diff --git a/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift b/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift index 58f7e0b3..367cf4e6 100644 --- a/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift @@ -27,6 +27,7 @@ final class TokenManagerTests: XCTestCase { manager.clearOpenCodeAuthCacheForTesting() XCTAssertEqual(manager.readOpenCodeAuth()?.openai?.access, "legacy") XCTAssertEqual(manager.lastFoundAuthPath, legacyPath) + XCTAssertEqual(manager.openCodeAuthSourceLabel, legacyPath.path) let databasePath = dataDirectory.appendingPathComponent("opencode.db") XCTAssertEqual(manager.getOpenCodeDatabasePath(), databasePath) @@ -34,6 +35,7 @@ final class TokenManagerTests: XCTestCase { XCTAssertEqual(sqlite3_open(databasePath.path, &database), SQLITE_OK) defer { sqlite3_close(database) } let sql = """ + PRAGMA journal_mode=WAL; CREATE TABLE credential (integration_id TEXT, value TEXT, active INTEGER, time_updated INTEGER); INSERT INTO credential VALUES ('openai', '{"type":"oauth","access":"newer","metadata":{"accountID":"other"}}', 0, 20), @@ -53,18 +55,45 @@ final class TokenManagerTests: XCTestCase { XCTAssertEqual(manager.lastFoundAuthPath, databasePath) XCTAssertEqual(manager.readGeminiOAuthAuth()?.access, "google-token") XCTAssertEqual(manager.lastFoundGeminiOAuthPath, databasePath) + XCTAssertTrue(manager.openCodeAuthStoreDiagnostics().contains("[ACTIVE] \(databasePath.path)")) + + let metadataSQL = """ + UPDATE credential SET value = '{"type":"oauth","access":"selected","accountId":"top-level","metadata":{}}' + WHERE integration_id = 'openai' AND active = 1; + """ + XCTAssertEqual(sqlite3_exec(database, metadataSQL, nil, nil, nil), SQLITE_OK) + manager.clearOpenCodeAuthCacheForTesting() + XCTAssertEqual(manager.readOpenCodeAuth()?.openai?.accountId, "top-level") XCTAssertEqual(sqlite3_exec(database, "UPDATE credential SET active = 0", nil, nil, nil), SQLITE_OK) manager.clearOpenCodeAuthCacheForTesting() XCTAssertEqual(manager.readOpenCodeAuth()?.openai?.access, "newer") + // Simulate a stopped writer with no shared-memory sidecar. SQLite may + // retain that file on close, so remove it after all connections close. + XCTAssertEqual(sqlite3_wal_checkpoint_v2(database, nil, SQLITE_CHECKPOINT_TRUNCATE, nil, nil), SQLITE_OK) + XCTAssertEqual(sqlite3_close(database), SQLITE_OK) + database = nil + let sharedMemoryPath = databasePath.path + "-shm" + if FileManager.default.fileExists(atPath: sharedMemoryPath) { + try FileManager.default.removeItem(atPath: sharedMemoryPath) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: databasePath.path + "-shm")) + manager.clearOpenCodeAuthCacheForTesting() + XCTAssertEqual(manager.readOpenCodeAuth()?.openai?.access, "newer") + XCTAssertEqual(sqlite3_open(databasePath.path, &database), SQLITE_OK) + XCTAssertEqual(sqlite3_exec(database, "DELETE FROM credential", nil, nil, nil), SQLITE_OK) manager.clearOpenCodeAuthCacheForTesting() XCTAssertNil(manager.readOpenCodeAuth()) XCTAssertNil(manager.readGeminiOAuthAuth()) + XCTAssertEqual(manager.openCodeAuthSourceLabel, "OpenCode") + XCTAssertTrue(manager.openCodeAuthStoreDiagnostics().contains("[NO USABLE CREDENTIALS] \(databasePath.path)")) + XCTAssertFalse(manager.openCodeAuthStoreDiagnostics().contains { $0.contains("[ACTIVE]") }) XCTAssertEqual(sqlite3_exec(database, "DROP TABLE credential", nil, nil, nil), SQLITE_OK) manager.clearOpenCodeAuthCacheForTesting() XCTAssertNil(manager.readOpenCodeAuth()) + XCTAssertTrue(manager.openCodeAuthStoreDiagnostics().contains("[UNREADABLE] \(databasePath.path)")) setenv("OPENCODE_DB", "custom.db", 1) XCTAssertEqual(manager.getOpenCodeDatabasePath(), dataDirectory.appendingPathComponent("custom.db")) @@ -285,12 +314,19 @@ final class TokenManagerTests: XCTestCase { defer { try? fileManager.removeItem(at: tempDirectory) } let originalXDGDataHome = ProcessInfo.processInfo.environment["XDG_DATA_HOME"] + let originalDatabase = ProcessInfo.processInfo.environment["OPENCODE_DB"] + unsetenv("OPENCODE_DB") if let originalXDGDataHome { setenv("XDG_DATA_HOME", originalXDGDataHome, 1) } else { unsetenv("XDG_DATA_HOME") } defer { + if let originalDatabase { + setenv("OPENCODE_DB", originalDatabase, 1) + } else { + unsetenv("OPENCODE_DB") + } if let originalXDGDataHome { setenv("XDG_DATA_HOME", originalXDGDataHome, 1) } else { diff --git a/README.md b/README.md index 9a4bab71..5f92ed4b 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Download the latest `.dmg` file from the [**Releases**](https://github.com/opggi ### Automatic Provider Detection - **Zero Configuration**: Reads OpenCode 2.x credentials from `opencode.db`, or OpenCode 1.x credentials from `auth.json` -- **Multi-path Support**: Searches `$XDG_DATA_HOME/opencode`, `~/.local/share/opencode`, and `~/Library/Application Support/opencode` +- **Custom Locations**: Supports `XDG_DATA_HOME` and `OPENCODE_DB`; legacy JSON discovery also checks the macOS Application Support directory - **Dynamic Updates**: New providers appear as you add them to OpenCode - **Smart Categorization**: Pay-as-you-go vs Quota-based providers displayed separately @@ -407,7 +407,7 @@ Quit (⌘Q) ## How It Works -1. **Token Discovery**: Reads authentication tokens from OpenCode's `auth.json` (with multi-path fallback), including plugin-managed OpenAI metadata +1. **Token Discovery**: Reads the OpenCode 2.x credential database, or legacy `auth.json` when the database is absent, including OpenAI account metadata 2. **Multi-Source Account Discovery**: For providers like ChatGPT and GitHub Copilot, discovers accounts from multiple sources (OpenCode auth, OpenCode plugin files, CLI/Keychain/config stores, browser cookies) and deduplicates them by stable account metadata 3. **Parallel Fetching**: Queries all provider APIs simultaneously using TaskGroup 4. **Smart Caching**: Falls back to cached data on network errors @@ -428,11 +428,15 @@ Grok reads identity from `~/.grok/auth.json`, uses the email address as the subs ## Troubleshooting -### "No providers found" or auth.json not detected -The app searches for `auth.json` in these locations (in order): +### "No providers found" or credentials not detected +For OpenCode 2.x, check the database location described in [OpenCode Authentication](#opencode-authentication). +`OPENCODE_DB` overrides the default `opencode.db` path. The app must inherit any environment overrides used by OpenCode. +The diagnostic dialog distinguishes unreadable stores from stores without usable credentials. Reconnect the provider in OpenCode if its credentials are missing or expired. + +Only when that database is absent does the app search for legacy `auth.json` (in order): 1. `$XDG_DATA_HOME/opencode/auth.json` (if XDG_DATA_HOME is set) 2. `~/.local/share/opencode/auth.json` (default) -3. `~/Library/Application Support/opencode/auth.json` (macOS fallback) +3. `~/Library/Application Support/opencode/auth.json` (legacy JSON-only macOS fallback) For ChatGPT/Codex multi-account setups, the app also searches: 1. `~/.opencode/auth/openai.json` @@ -445,7 +449,7 @@ OpenCode Bar also reads `provider.openai.options.apiKey` from the OpenCode confi ### GitHub Copilot not showing GitHub Copilot accounts are discovered from multiple sources (in priority order): -1. **OpenCode auth** — `copilot` entry in OpenCode `auth.json` +1. **OpenCode auth** — `github-copilot` integration in the OpenCode credential store 2. **Copilot CLI Keychain** — macOS Keychain entries for `github.com` 3. **VS Code / Cursor** — `~/.config/github-copilot/hosts.json` and `apps.json` 4. **Browser Cookies** — Chrome, Brave, Arc, Edge session cookies From 548e815567d09ac47ba643111ebd94d495a2a301 Mon Sep 17 00:00:00 2001 From: dangyingkai Date: Tue, 20 Oct 2026 15:27:40 +0800 Subject: [PATCH 3/3] fix: distinguish legacy OpenCode databases from credential stores Probe for the credential table before choosing the SQLite auth store. Preserve legacy auth.json discovery for V1 databases that only contain session data while keeping database errors and invalid credential schemas authoritative. Report the sources selected by each auth reader, include actionable SQLite errors and rejected-row diagnostics, remove unused metadata mapping and redundant menu synchronization, and clarify V1/V2 discovery in the README. --- .../App/StatusBarController.swift | 3 +- .../Services/TokenManager.swift | 95 ++++++++++--------- .../TokenManagerTests.swift | 22 ++++- README.md | 16 ++-- 4 files changed, 83 insertions(+), 53 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift index 14eb3368..a62cdc8c 100644 --- a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift +++ b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift @@ -2519,8 +2519,7 @@ final class StatusBarController: NSObject { let lowercased = rawSource.lowercased() if lowercased.contains("opencode") - || rawSource == TokenManager.shared.getOpenCodeDatabasePath().path - || rawSource == TokenManager.shared.openCodeAuthSourceLabel { + || rawSource == TokenManager.shared.getOpenCodeDatabasePath().path { return "OpenCode" } diff --git a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift index c72e24b5..bf9bc700 100644 --- a/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift +++ b/CopilotMonitor/CopilotMonitor/Services/TokenManager.swift @@ -987,7 +987,22 @@ final class TokenManager: @unchecked Sendable { private func getOpenCodeAuthStores() -> [(url: URL, isDatabase: Bool)] { let databasePath = getOpenCodeDatabasePath() if FileManager.default.fileExists(atPath: databasePath.path) { - // An existing V2 store is authoritative, including empty or unreadable stores. + // V1 also has opencode.db, but keeps credentials in auth.json. + // Only a successful schema probe may enable the legacy fallback. + var database: OpaquePointer? + defer { sqlite3_close(database) } + var statement: OpaquePointer? + defer { sqlite3_finalize(statement) } + if FileManager.default.isReadableFile(atPath: databasePath.path), + sqlite3_open_v2(databasePath.path, &database, SQLITE_OPEN_READONLY, nil) == SQLITE_OK { + sqlite3_busy_timeout(database, 1000) + let query = "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'credential'" + if sqlite3_prepare_v2(database, query, -1, &statement, nil) == SQLITE_OK, + sqlite3_step(statement) == SQLITE_DONE { + logger.info("OpenCode database has no credential table; using legacy auth discovery") + return getAuthFilePaths().map { ($0, false) } + } + } return [(databasePath, true)] } return getAuthFilePaths().map { ($0, false) } @@ -1001,7 +1016,9 @@ final class TokenManager: @unchecked Sendable { let status = sqlite3_open_v2(path.path, &database, SQLITE_OPEN_READONLY, nil) defer { sqlite3_close(database) } guard status == SQLITE_OK else { - throw NSError(domain: "OpenCodeCredentials", code: Int(status)) + throw NSError(domain: "OpenCodeCredentials", code: Int(status), userInfo: [ + NSLocalizedDescriptionKey: String(cString: sqlite3_errmsg(database)) + ]) } sqlite3_busy_timeout(database, 1000) var statement: OpaquePointer? @@ -1013,7 +1030,9 @@ final class TokenManager: @unchecked Sendable { """ let prepared = sqlite3_prepare_v2(database, query, -1, &statement, nil) guard prepared == SQLITE_OK else { - throw NSError(domain: "OpenCodeCredentials", code: Int(prepared)) + throw NSError(domain: "OpenCodeCredentials", code: Int(prepared), userInfo: [ + NSLocalizedDescriptionKey: String(cString: sqlite3_errmsg(database)) + ]) } var entries: [String: Any] = [:] var selected = Set() @@ -1021,42 +1040,54 @@ final class TokenManager: @unchecked Sendable { while step == SQLITE_ROW { if let idText = sqlite3_column_text(statement, 0) { let integrationID = String(cString: idText) - if selected.insert(integrationID).inserted, - let valueText = sqlite3_column_text(statement, 1), - var value = (try? JSONSerialization.jsonObject( - with: Data(String(cString: valueText).utf8) - )) as? [String: Any] { + if selected.insert(integrationID).inserted { + guard let valueText = sqlite3_column_text(statement, 1), + var value = (try? JSONSerialization.jsonObject( + with: Data(String(cString: valueText).utf8) + )) as? [String: Any] else { + logger.warning("Ignoring OpenCode credential for \(integrationID): invalid JSON object") + step = sqlite3_step(statement) + continue + } switch value["type"] as? String { case "oauth": if let metadata = value["metadata"] as? [String: Any] { if let accountID = metadata["accountID"] as? String, !accountID.isEmpty { value["accountId"] = accountID } - if let enterpriseURL = metadata["enterpriseUrl"] as? String, !enterpriseURL.isEmpty { - value["enterpriseUrl"] = enterpriseURL - } } entries[integrationID] = value case "key": value["type"] = "api" entries[integrationID] = value default: - break + logger.warning("Ignoring OpenCode credential for \(integrationID): unsupported credential type") } } } step = sqlite3_step(statement) } guard step == SQLITE_DONE else { - throw NSError(domain: "OpenCodeCredentials", code: Int(step)) + throw NSError(domain: "OpenCodeCredentials", code: Int(step), userInfo: [ + NSLocalizedDescriptionKey: String(cString: sqlite3_errmsg(database)) + ]) } logger.info("Loaded OpenCode credential database: \(entries.count) integrations") return try JSONSerialization.data(withJSONObject: entries) } func openCodeAuthStoreDiagnostics() -> [String] { - var lines: [String] = [] - var selectedPath: URL? + let hasAuth = readOpenCodeAuth() != nil + let hasGoogle = readGeminiOAuthAuth() != nil + let (authPath, googlePath) = queue.sync { + (hasAuth ? lastFoundAuthPath : nil, hasGoogle ? lastFoundGeminiOAuthPath : nil) + } + let environment = ProcessInfo.processInfo.environment + var lines = [ + "[XDG_DATA_HOME] \(environment["XDG_DATA_HOME"] ?? "NOT SET")", + "[OPENCODE_DB] \(environment["OPENCODE_DB"] ?? "NOT SET")", + "[Database path] \(getOpenCodeDatabasePath().path)" + ] for store in getOpenCodeAuthStores() { let path = store.url guard FileManager.default.fileExists(atPath: path.path) else { @@ -1064,24 +1095,16 @@ final class TokenManager: @unchecked Sendable { continue } guard FileManager.default.isReadableFile(atPath: path.path), - let data = try? readOpenCodeAuthData(at: path, isDatabase: store.isDatabase) else { + (try? readOpenCodeAuthData(at: path, isDatabase: store.isDatabase)) != nil else { lines.append("[UNREADABLE] \(path.path)") continue } - let auth = try? JSONDecoder().decode(OpenCodeAuth.self, from: data) - let google = (try? JSONDecoder().decode(OpenCodeGeminiAuthContainer.self, from: data))?.google - let hasGoogle = google?.type?.lowercased() == "oauth" - && normalizedNonEmpty(google?.refresh) != nil - guard auth != nil || hasGoogle else { - lines.append("[NO USABLE CREDENTIALS] \(path.path)") - continue - } - lines.append("[\(selectedPath == nil ? "ACTIVE" : "SHADOWED")] \(path.path)") - if selectedPath == nil { selectedPath = path } + let isActive = path == authPath || path == googlePath + lines.append("[\(isActive ? "ACTIVE" : "READABLE")] \(path.path)") } - if let selectedPath { - lines.append("[Result] Using: \(selectedPath.path)") - } else { + lines.append("[OpenCode providers] \(authPath.map { "Using: " + $0.path } ?? "NO USABLE CREDENTIALS")") + lines.append("[Gemini OAuth] \(googlePath.map { "Using: " + $0.path } ?? "NO USABLE CREDENTIALS")") + if authPath == nil && googlePath == nil { lines.append("[Result] NO USABLE OPENCODE CREDENTIALS FOUND") } return lines @@ -5094,13 +5117,6 @@ final class TokenManager: @unchecked Sendable { debugLines.append("Environment Info:") debugLines.append(String(repeating: "─", count: 40)) - // 0. XDG_DATA_HOME environment variable - if let xdgDataHome = ProcessInfo.processInfo.environment["XDG_DATA_HOME"], !xdgDataHome.isEmpty { - debugLines.append("[XDG_DATA_HOME] SET: \(xdgDataHome)") - } else { - debugLines.append("[XDG_DATA_HOME] NOT SET (using default ~/.local/share)") - } - // 1. Report the selected OpenCode credential stores. debugLines.append("") debugLines.append("Auth File Search:") @@ -5229,13 +5245,6 @@ final class TokenManager: @unchecked Sendable { var debugLines: [String] = [] debugLines.append("========== Environment Debug Info ==========") - // 0. XDG_DATA_HOME environment variable - if let xdgDataHome = ProcessInfo.processInfo.environment["XDG_DATA_HOME"], !xdgDataHome.isEmpty { - debugLines.append("[XDG_DATA_HOME] SET: \(xdgDataHome)") - } else { - debugLines.append("[XDG_DATA_HOME] NOT SET (using default ~/.local/share)") - } - // 1. Report the selected OpenCode credential stores. debugLines.append("---------- Auth File Search ----------") debugLines.append(contentsOf: openCodeAuthStoreDiagnostics()) diff --git a/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift b/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift index 367cf4e6..e163f49f 100644 --- a/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/TokenManagerTests.swift @@ -34,6 +34,11 @@ final class TokenManagerTests: XCTestCase { var database: OpaquePointer? XCTAssertEqual(sqlite3_open(databasePath.path, &database), SQLITE_OK) defer { sqlite3_close(database) } + // OpenCode V1 stores sessions in SQLite but credentials in auth.json. + XCTAssertEqual(sqlite3_exec(database, "CREATE TABLE session (id TEXT)", nil, nil, nil), SQLITE_OK) + manager.clearOpenCodeAuthCacheForTesting() + XCTAssertEqual(manager.readOpenCodeAuth()?.openai?.access, "legacy") + XCTAssertEqual(manager.lastFoundAuthPath, legacyPath) let sql = """ PRAGMA journal_mode=WAL; CREATE TABLE credential (integration_id TEXT, value TEXT, active INTEGER, time_updated INTEGER); @@ -56,6 +61,8 @@ final class TokenManagerTests: XCTestCase { XCTAssertEqual(manager.readGeminiOAuthAuth()?.access, "google-token") XCTAssertEqual(manager.lastFoundGeminiOAuthPath, databasePath) XCTAssertTrue(manager.openCodeAuthStoreDiagnostics().contains("[ACTIVE] \(databasePath.path)")) + XCTAssertTrue(manager.openCodeAuthStoreDiagnostics().contains("[OpenCode providers] Using: \(databasePath.path)")) + XCTAssertTrue(manager.openCodeAuthStoreDiagnostics().contains("[Gemini OAuth] Using: \(databasePath.path)")) let metadataSQL = """ UPDATE credential SET value = '{"type":"oauth","access":"selected","accountId":"top-level","metadata":{}}' @@ -88,10 +95,16 @@ final class TokenManagerTests: XCTestCase { XCTAssertNil(manager.readOpenCodeAuth()) XCTAssertNil(manager.readGeminiOAuthAuth()) XCTAssertEqual(manager.openCodeAuthSourceLabel, "OpenCode") - XCTAssertTrue(manager.openCodeAuthStoreDiagnostics().contains("[NO USABLE CREDENTIALS] \(databasePath.path)")) + XCTAssertTrue(manager.openCodeAuthStoreDiagnostics().contains("[Result] NO USABLE OPENCODE CREDENTIALS FOUND")) XCTAssertFalse(manager.openCodeAuthStoreDiagnostics().contains { $0.contains("[ACTIVE]") }) XCTAssertEqual(sqlite3_exec(database, "DROP TABLE credential", nil, nil, nil), SQLITE_OK) manager.clearOpenCodeAuthCacheForTesting() + XCTAssertEqual(manager.readOpenCodeAuth()?.openai?.access, "legacy") + XCTAssertEqual(manager.lastFoundAuthPath, legacyPath) + + // A credential table with an invalid schema is not a V1 database. + XCTAssertEqual(sqlite3_exec(database, "CREATE TABLE credential (invalid TEXT)", nil, nil, nil), SQLITE_OK) + manager.clearOpenCodeAuthCacheForTesting() XCTAssertNil(manager.readOpenCodeAuth()) XCTAssertTrue(manager.openCodeAuthStoreDiagnostics().contains("[UNREADABLE] \(databasePath.path)")) @@ -99,6 +112,13 @@ final class TokenManagerTests: XCTestCase { XCTAssertEqual(manager.getOpenCodeDatabasePath(), dataDirectory.appendingPathComponent("custom.db")) setenv("OPENCODE_DB", databasePath.path, 1) XCTAssertEqual(manager.getOpenCodeDatabasePath(), databasePath) + + let corruptPath = directory.appendingPathComponent("credentials-without-extension") + try Data("not a SQLite database".utf8).write(to: corruptPath) + setenv("OPENCODE_DB", corruptPath.path, 1) + manager.clearOpenCodeAuthCacheForTesting() + XCTAssertNil(manager.readOpenCodeAuth()) + XCTAssertTrue(manager.openCodeAuthStoreDiagnostics().contains("[UNREADABLE] \(corruptPath.path)")) } func testGeminiProjectPolicyPrefersConfiguredProject() { diff --git a/README.md b/README.md index 5f92ed4b..a27036d4 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Download the latest `.dmg` file from the [**Releases**](https://github.com/opggi - Also understands plugin-managed OpenCode `auth.json` fields such as `idToken`, `accountIdOverride`, and `organizationIdOverride` - **Antigravity/Gemini** - `NoeFabris/opencode-antigravity-auth` (writes `~/.config/opencode/antigravity-accounts.json`) - - `jenslys/opencode-gemini-auth` (writes `google.oauth` in OpenCode `auth.json`) + - `jenslys/opencode-gemini-auth` (`google` OAuth credentials in the OpenCode credential store) - Gemini CLI OAuth creds (writes `~/.gemini/oauth_creds.json` for email/account ID metadata; overlaps are merged with Antigravity accounts) - **Claude**: `anomalyco/opencode-anthropic-auth` @@ -81,7 +81,7 @@ Download the latest `.dmg` file from the [**Releases**](https://github.com/opggi - **Browser Cookies** - Chrome, Brave, Arc, Edge session cookies - Multiple accounts from different sources are automatically deduplicated and merged - **Codex** - - **OpenCode + oc-chatgpt-multi-auth** - Auto-detected from OpenCode `auth.json` plus `~/.opencode/.../openai-codex-accounts.json` + - **OpenCode + oc-chatgpt-multi-auth** - Auto-detected from the OpenCode credential store plus `~/.opencode/.../openai-codex-accounts.json` - **Codex for Mac** - Auto-detected through `~/.codex/auth.json` - **Codex CLI** - Auto-detected through `~/.codex/auth.json` - **codex-lb** - Auto-detected through `~/.codex-lb/` @@ -106,9 +106,11 @@ The database defaults to `$XDG_DATA_HOME/opencode/opencode.db`, or can specify an absolute path or a path relative to the OpenCode data directory. Environment overrides must be available to the app or CLI process. -When the database is absent, OpenCode Bar uses the existing OpenCode 1.x -`auth.json` discovery. An existing empty, unreadable, or invalid database does -not fall back to old credentials. OpenCode remains responsible for refreshing +When the database is absent, or a successful schema query confirms it has no +`credential` table, OpenCode Bar uses OpenCode 1.x `auth.json` discovery. V1 also +uses SQLite for sessions, so a database file alone does not identify a V2 +credential store. An empty `credential` table, invalid credential schema, or +unreadable database does not fall back to old credentials. OpenCode remains responsible for refreshing its credentials. Token source labels show the store that was actually loaded. ### Kiro Authentication @@ -407,7 +409,7 @@ Quit (⌘Q) ## How It Works -1. **Token Discovery**: Reads the OpenCode 2.x credential database, or legacy `auth.json` when the database is absent, including OpenAI account metadata +1. **Token Discovery**: Reads the OpenCode credential store using the V1/V2 rules in [OpenCode Authentication](#opencode-authentication), including OpenAI account metadata 2. **Multi-Source Account Discovery**: For providers like ChatGPT and GitHub Copilot, discovers accounts from multiple sources (OpenCode auth, OpenCode plugin files, CLI/Keychain/config stores, browser cookies) and deduplicates them by stable account metadata 3. **Parallel Fetching**: Queries all provider APIs simultaneously using TaskGroup 4. **Smart Caching**: Falls back to cached data on network errors @@ -433,7 +435,7 @@ For OpenCode 2.x, check the database location described in [OpenCode Authenticat `OPENCODE_DB` overrides the default `opencode.db` path. The app must inherit any environment overrides used by OpenCode. The diagnostic dialog distinguishes unreadable stores from stores without usable credentials. Reconnect the provider in OpenCode if its credentials are missing or expired. -Only when that database is absent does the app search for legacy `auth.json` (in order): +When the database is absent or has no `credential` table, the app searches for legacy `auth.json` (in order): 1. `$XDG_DATA_HOME/opencode/auth.json` (if XDG_DATA_HOME is set) 2. `~/.local/share/opencode/auth.json` (default) 3. `~/Library/Application Support/opencode/auth.json` (legacy JSON-only macOS fallback)