mirror of
https://github.com/adnaan-worker/adnify.git
synced 2026-09-01 04:25:33 +08:00
feat: refactor OpenAI authentication to use provider credential store
- Removed OpenAI OAuth handlers and replaced them with provider credential handlers. - Introduced ProviderCredentialStore to manage API keys and OAuth credentials. - Migrated legacy OAuth credentials to the new provider credential structure. - Updated IPC communication to reflect changes in credential management. - Adjusted frontend components to utilize new credential API methods. - Added tests for provider credential store functionality and migration logic.
This commit is contained in:
@@ -21,7 +21,7 @@ import { registerDebugHandlers } from './debug' // 调试
|
||||
import { registerHealthCheckHandlers } from './healthCheck' // 健康检查
|
||||
import { registerRemoteShellHandlers } from './remoteShell' // 远程 Shell / SFTP
|
||||
import { registerSkillsHandlers } from './skills' // Skills
|
||||
import { registerOpenAIAuthHandlers } from './openaiAuth' // OpenAI OAuth
|
||||
import { registerProviderCredentialHandlers } from './providerCredentials'
|
||||
import { registerSessionStorageHandlers } from './sessionStorage'
|
||||
import { registerFormatterHandlers } from './formatter'
|
||||
import { registerSystemPrivilegeHandlers } from './systemPrivilege'
|
||||
@@ -44,7 +44,6 @@ export interface IPCContext {
|
||||
createWindow: (isEmpty?: boolean) => BrowserWindow
|
||||
/** 根据 key 路由到正确的 store */
|
||||
resolveStore: (key: string) => Store
|
||||
credentialsStore: Store
|
||||
preferencesStore: Store
|
||||
workspaceMetaStore: Store
|
||||
bootstrapStore: Store
|
||||
@@ -133,8 +132,7 @@ export function registerAllHandlers(context: IPCContext) {
|
||||
// Skills
|
||||
registerSkillsHandlers()
|
||||
|
||||
// OpenAI OAuth
|
||||
registerOpenAIAuthHandlers()
|
||||
registerProviderCredentialHandlers()
|
||||
|
||||
registerSessionStorageHandlers({
|
||||
getWindowWorkspace: context.getWindowWorkspace,
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { safeIpcHandle } from './safeHandle'
|
||||
import { ProviderCredentialStore } from '../services/credentials/ProviderCredentialStore'
|
||||
import { OpenAIAuthService } from '../services/openai/OpenAIAuthService'
|
||||
import { OpenAIUsageStore } from '../services/openai/OpenAIUsageStore'
|
||||
import { logger } from '@shared/utils/Logger'
|
||||
|
||||
const CHATGPT_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses'
|
||||
|
||||
/**
|
||||
* Refresh the usage snapshot by issuing a deliberately invalid request.
|
||||
*
|
||||
* The ChatGPT backend has no quota endpoint, but it attaches `x-codex-*` usage
|
||||
* headers to *every* response — including 4xx ones. Sending an empty input is
|
||||
* rejected before any tokens are billed, which makes it a cheap probe.
|
||||
*/
|
||||
async function refreshUsage(): Promise<boolean> {
|
||||
const token = await OpenAIAuthService.getValidToken()
|
||||
if (!token) return false
|
||||
@@ -34,7 +28,7 @@ async function refreshUsage(): Promise<boolean> {
|
||||
})
|
||||
return OpenAIUsageStore.captureFromHeaders(response.headers)
|
||||
} catch (error) {
|
||||
logger.ipc.warn('[OpenAIAuth] Usage refresh failed', {
|
||||
logger.ipc.warn('[Credentials] OAuth usage refresh failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return false
|
||||
@@ -43,33 +37,31 @@ async function refreshUsage(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
export function registerOpenAIAuthHandlers(): void {
|
||||
safeIpcHandle('openai:auth:login', async () => {
|
||||
export function registerProviderCredentialHandlers(): void {
|
||||
safeIpcHandle('credentials:api-keys:get', async () => ProviderCredentialStore.getApiKeys())
|
||||
safeIpcHandle('credentials:api-keys:replace', async (_event, apiKeys: Record<string, string>) => {
|
||||
ProviderCredentialStore.replaceApiKeys(apiKeys)
|
||||
return true
|
||||
})
|
||||
|
||||
safeIpcHandle('credentials:oauth:login', async () => {
|
||||
const tokens = await OpenAIAuthService.login()
|
||||
return { success: true, accountID: tokens.accountID }
|
||||
})
|
||||
|
||||
safeIpcHandle('openai:auth:logout', async () => {
|
||||
safeIpcHandle('credentials:oauth:logout', async () => {
|
||||
await OpenAIAuthService.logout()
|
||||
OpenAIUsageStore.clear()
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
safeIpcHandle('openai:auth:usage', async (_event, options?: { refresh?: boolean }) => {
|
||||
// Serve the cached snapshot unless the caller explicitly wants a probe;
|
||||
// every real request keeps it current for free.
|
||||
if (options?.refresh || !OpenAIUsageStore.get()) {
|
||||
await refreshUsage()
|
||||
}
|
||||
safeIpcHandle('credentials:oauth:usage', async (_event, options?: { refresh?: boolean }) => {
|
||||
if (options?.refresh || !OpenAIUsageStore.get()) await refreshUsage()
|
||||
return { usage: OpenAIUsageStore.get() }
|
||||
})
|
||||
|
||||
safeIpcHandle('openai:auth:status', async () => {
|
||||
return OpenAIAuthService.getStatus()
|
||||
})
|
||||
|
||||
safeIpcHandle('openai:auth:token', async () => {
|
||||
const token = await OpenAIAuthService.getValidToken()
|
||||
return { token }
|
||||
})
|
||||
safeIpcHandle('credentials:oauth:status', async () => OpenAIAuthService.getStatus())
|
||||
safeIpcHandle('credentials:oauth:token', async () => ({
|
||||
token: await OpenAIAuthService.getValidToken(),
|
||||
}))
|
||||
}
|
||||
+2
-1
@@ -24,6 +24,7 @@ import { registerWebviewGuards } from './security/webviewGuard'
|
||||
import { cleanupFileWatcher } from './security/fileWatcher'
|
||||
import { collectLaunchFiles, flushLaunchFilesToWindow, queueLaunchFiles } from './services/fileAssociation'
|
||||
import { createScopedStore, getBootstrapStore, getUserConfigDir } from './services/configPath'
|
||||
import { ProviderCredentialStore } from './services/credentials/ProviderCredentialStore'
|
||||
import { createFileLogWriter } from './services/fileLogWriter'
|
||||
import {
|
||||
shutdownWindowController,
|
||||
@@ -88,6 +89,7 @@ function resolveStore(_key: string): Store<Record<string, unknown>> {
|
||||
async function initStores() {
|
||||
bootstrapStore = getBootstrapStore()
|
||||
configStore = createScopedStore('config', bootstrapStore)
|
||||
await ProviderCredentialStore.initialize(configStore)
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
@@ -739,7 +741,6 @@ async function initializeModules(firstWin: BrowserWindow) {
|
||||
getMainWindow,
|
||||
createWindow,
|
||||
resolveStore,
|
||||
credentialsStore: configStore,
|
||||
preferencesStore: configStore,
|
||||
workspaceMetaStore: configStore,
|
||||
bootstrapStore,
|
||||
|
||||
+18
-12
@@ -519,11 +519,14 @@ export interface ElectronAPI {
|
||||
}>
|
||||
mcpRefreshCapabilities: (serverId: string) => Promise<{ success: boolean; error?: string }>
|
||||
|
||||
// OpenAI OAuth
|
||||
openaiAuthLogin: () => Promise<{ success: boolean; accountID?: string; error?: string }>
|
||||
openaiAuthLogout: () => Promise<{ success: boolean; error?: string }>
|
||||
openaiAuthStatus: () => Promise<{ loggedIn: boolean; accountID?: string }>
|
||||
openaiAuthToken: () => Promise<{ token: string | null }>
|
||||
// Provider credentials
|
||||
credentialsGetApiKeys: () => Promise<Record<string, string>>
|
||||
credentialsReplaceApiKeys: (apiKeys: Record<string, string>) => Promise<boolean>
|
||||
credentialsOAuthLogin: () => Promise<{ success: boolean; accountID?: string; error?: string }>
|
||||
credentialsOAuthLogout: () => Promise<{ success: boolean; error?: string }>
|
||||
credentialsOAuthStatus: () => Promise<{ loggedIn: boolean; accountID?: string }>
|
||||
credentialsOAuthToken: () => Promise<{ token: string | null }>
|
||||
credentialsOAuthUsage: (options?: { refresh?: boolean }) => Promise<{ usage: unknown }>
|
||||
mcpGetConfigPaths: () => Promise<{ success: boolean; paths?: { user: string; workspace: string[] }; error?: string }>
|
||||
mcpReloadConfig: () => Promise<{ success: boolean; error?: string }>
|
||||
mcpDiscoverExternalConfigs: () => Promise<{ success: boolean; configs?: any[]; error?: string }>
|
||||
@@ -901,13 +904,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('mcp:getPrompt', request),
|
||||
mcpRefreshCapabilities: (serverId: string) => ipcRenderer.invoke('mcp:refreshCapabilities', serverId),
|
||||
|
||||
// OpenAI OAuth
|
||||
openaiAuthLogin: () => ipcRenderer.invoke('openai:auth:login'),
|
||||
openaiAuthLogout: () => ipcRenderer.invoke('openai:auth:logout'),
|
||||
openaiAuthStatus: () => ipcRenderer.invoke('openai:auth:status'),
|
||||
openaiAuthToken: () => ipcRenderer.invoke('openai:auth:token'),
|
||||
openaiAuthUsage: (options?: { refresh?: boolean }) =>
|
||||
ipcRenderer.invoke('openai:auth:usage', options),
|
||||
// Provider credentials
|
||||
credentialsGetApiKeys: () => ipcRenderer.invoke('credentials:api-keys:get'),
|
||||
credentialsReplaceApiKeys: (apiKeys: Record<string, string>) =>
|
||||
ipcRenderer.invoke('credentials:api-keys:replace', apiKeys),
|
||||
credentialsOAuthLogin: () => ipcRenderer.invoke('credentials:oauth:login'),
|
||||
credentialsOAuthLogout: () => ipcRenderer.invoke('credentials:oauth:logout'),
|
||||
credentialsOAuthStatus: () => ipcRenderer.invoke('credentials:oauth:status'),
|
||||
credentialsOAuthToken: () => ipcRenderer.invoke('credentials:oauth:token'),
|
||||
credentialsOAuthUsage: (options?: { refresh?: boolean }) =>
|
||||
ipcRenderer.invoke('credentials:oauth:usage', options),
|
||||
mcpGetConfigPaths: () => ipcRenderer.invoke('mcp:getConfigPaths'),
|
||||
mcpReloadConfig: () => ipcRenderer.invoke('mcp:reloadConfig'),
|
||||
mcpDiscoverExternalConfigs: () => ipcRenderer.invoke('mcp:discoverExternalConfigs'),
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { app } from 'electron'
|
||||
import type Store from 'electron-store'
|
||||
import { logger } from '@shared/utils/Logger'
|
||||
|
||||
export interface OAuthCredential {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresAt: number
|
||||
accountID?: string
|
||||
email?: string
|
||||
planType?: string
|
||||
}
|
||||
|
||||
type ProviderCredential =
|
||||
| { type: 'api-key'; apiKey: string }
|
||||
| ({ type: 'oauth' } & OAuthCredential)
|
||||
|
||||
type ProviderCredentials = Record<string, ProviderCredential>
|
||||
|
||||
const STORE_KEY = 'providerCredentials'
|
||||
const OPENAI_OAUTH_PROVIDER = 'openai-oauth'
|
||||
|
||||
let credentialStore: Store<Record<string, unknown>> | null = null
|
||||
let credentials: ProviderCredentials = {}
|
||||
|
||||
function requireStore(): Store<Record<string, unknown>> {
|
||||
if (!credentialStore) throw new Error('Provider credential store is not initialized')
|
||||
return credentialStore
|
||||
}
|
||||
|
||||
function persist(): void {
|
||||
requireStore().set(STORE_KEY, credentials)
|
||||
}
|
||||
|
||||
function isOAuthCredential(value: unknown): value is ProviderCredential & { type: 'oauth' } {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const record = value as Record<string, unknown>
|
||||
return record.type === 'oauth'
|
||||
&& typeof record.accessToken === 'string'
|
||||
&& typeof record.refreshToken === 'string'
|
||||
&& typeof record.expiresAt === 'number'
|
||||
}
|
||||
|
||||
function isApiKeyCredential(value: unknown): value is ProviderCredential & { type: 'api-key' } {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const record = value as Record<string, unknown>
|
||||
return record.type === 'api-key' && typeof record.apiKey === 'string' && record.apiKey.length > 0
|
||||
}
|
||||
|
||||
function readStoredCredentials(store: Store<Record<string, unknown>>): ProviderCredentials {
|
||||
const stored = store.get(STORE_KEY)
|
||||
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return {}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(stored).filter(([, credential]) =>
|
||||
isApiKeyCredential(credential) || isOAuthCredential(credential)
|
||||
)
|
||||
) as ProviderCredentials
|
||||
}
|
||||
|
||||
async function migrateLegacyCredentials(store: Store<Record<string, unknown>>): Promise<void> {
|
||||
let changed = false
|
||||
const appSettings = store.get('app-settings') as Record<string, unknown> | undefined
|
||||
const providerConfigs = appSettings?.providerConfigs as Record<string, Record<string, unknown>> | undefined
|
||||
|
||||
if (providerConfigs) {
|
||||
const nextProviderConfigs: Record<string, Record<string, unknown>> = {}
|
||||
for (const [providerId, config] of Object.entries(providerConfigs)) {
|
||||
const { apiKey, ...rest } = config
|
||||
nextProviderConfigs[providerId] = rest
|
||||
if (typeof apiKey === 'string' && apiKey && !credentials[providerId]) {
|
||||
credentials[providerId] = { type: 'api-key', apiKey }
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (Object.values(providerConfigs).some(config => typeof config.apiKey === 'string')) {
|
||||
store.set('app-settings', { ...appSettings, providerConfigs: nextProviderConfigs })
|
||||
}
|
||||
}
|
||||
|
||||
const legacyOAuthPath = path.join(app.getPath('userData'), 'openai-auth.json')
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.promises.readFile(legacyOAuthPath, 'utf8'))
|
||||
const legacyCredential = { ...parsed, type: 'oauth' }
|
||||
if (!credentials[OPENAI_OAUTH_PROVIDER] && isOAuthCredential(legacyCredential)) {
|
||||
credentials[OPENAI_OAUTH_PROVIDER] = legacyCredential
|
||||
changed = true
|
||||
}
|
||||
await fs.promises.unlink(legacyOAuthPath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.store.warn('[Credentials] Legacy OAuth migration failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) persist()
|
||||
}
|
||||
|
||||
export const ProviderCredentialStore = {
|
||||
async initialize(store: Store<Record<string, unknown>>): Promise<void> {
|
||||
credentialStore = store
|
||||
credentials = readStoredCredentials(store)
|
||||
await migrateLegacyCredentials(store)
|
||||
},
|
||||
|
||||
getApiKeys(): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
for (const [providerId, credential] of Object.entries(credentials)) {
|
||||
if (credential.type === 'api-key') result[providerId] = credential.apiKey
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
replaceApiKeys(apiKeys: Record<string, string>): void {
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(credentials).filter(([, credential]) => credential.type !== 'api-key')
|
||||
) as ProviderCredentials
|
||||
for (const [providerId, apiKey] of Object.entries(apiKeys)) {
|
||||
if (apiKey) next[providerId] = { type: 'api-key', apiKey }
|
||||
}
|
||||
credentials = next
|
||||
persist()
|
||||
},
|
||||
|
||||
getOAuth(providerId: string): OAuthCredential | null {
|
||||
const credential = credentials[providerId]
|
||||
if (!credential || credential.type !== 'oauth') return null
|
||||
const { type: _type, ...oauth } = credential
|
||||
return { ...oauth }
|
||||
},
|
||||
|
||||
setOAuth(providerId: string, credential: OAuthCredential): void {
|
||||
credentials = { ...credentials, [providerId]: { type: 'oauth', ...credential } }
|
||||
persist()
|
||||
},
|
||||
|
||||
clear(providerId: string): void {
|
||||
if (!credentials[providerId]) return
|
||||
const { [providerId]: _removed, ...rest } = credentials
|
||||
credentials = rest
|
||||
persist()
|
||||
},
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createServer } from 'node:http'
|
||||
import { shell } from 'electron'
|
||||
import { OpenAIAuthStore, type OpenAITokens } from './OpenAIAuthStore'
|
||||
import {
|
||||
ProviderCredentialStore,
|
||||
type OAuthCredential,
|
||||
} from '../credentials/ProviderCredentialStore'
|
||||
import { logger } from '@shared/utils/Logger'
|
||||
|
||||
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
|
||||
@@ -129,7 +132,7 @@ async function refreshTokens(refreshToken: string): Promise<TokenResponse> {
|
||||
return (await res.json()) as TokenResponse
|
||||
}
|
||||
|
||||
function tokensFromResponse(res: TokenResponse): OpenAITokens {
|
||||
function tokensFromResponse(res: TokenResponse): OAuthCredential {
|
||||
const info = extractAccountInfo(res)
|
||||
return {
|
||||
accessToken: res.access_token,
|
||||
@@ -146,7 +149,7 @@ export const OpenAIAuthService = {
|
||||
* Start the browser-based PKCE OAuth flow.
|
||||
* Opens the user's browser, waits for the callback, exchanges the code, and persists tokens.
|
||||
*/
|
||||
async login(): Promise<OpenAITokens> {
|
||||
async login(): Promise<OAuthCredential> {
|
||||
const pkce = await generatePKCE()
|
||||
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url')
|
||||
|
||||
@@ -230,25 +233,25 @@ export const OpenAIAuthService = {
|
||||
|
||||
const tokenRes = await exchangeCode(code, pkce)
|
||||
const tokens = tokensFromResponse(tokenRes)
|
||||
await OpenAIAuthStore.set(tokens)
|
||||
ProviderCredentialStore.setOAuth('openai-oauth', tokens)
|
||||
logger.security.info('[OpenAIAuth] Login successful', { accountID: tokens.accountID })
|
||||
return tokens
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await OpenAIAuthStore.clear()
|
||||
ProviderCredentialStore.clear('openai-oauth')
|
||||
},
|
||||
|
||||
async getValidToken(): Promise<string | null> {
|
||||
const tokens = await OpenAIAuthStore.get()
|
||||
const tokens = ProviderCredentialStore.getOAuth('openai-oauth')
|
||||
if (!tokens) return null
|
||||
|
||||
if (await OpenAIAuthStore.isExpired()) {
|
||||
if (tokens.expiresAt < Date.now() + 60_000) {
|
||||
try {
|
||||
const refreshed = await refreshTokens(tokens.refreshToken)
|
||||
const next = tokensFromResponse(refreshed)
|
||||
// Refresh responses may omit id_token — keep the profile claims we already have.
|
||||
await OpenAIAuthStore.set({
|
||||
ProviderCredentialStore.setOAuth('openai-oauth', {
|
||||
...next,
|
||||
accountID: next.accountID ?? tokens.accountID,
|
||||
email: next.email ?? tokens.email,
|
||||
@@ -257,7 +260,7 @@ export const OpenAIAuthService = {
|
||||
return next.accessToken
|
||||
} catch (err) {
|
||||
logger.security.warn('[OpenAIAuth] Token refresh failed, clearing tokens', err)
|
||||
await OpenAIAuthStore.clear()
|
||||
ProviderCredentialStore.clear('openai-oauth')
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -272,7 +275,7 @@ export const OpenAIAuthService = {
|
||||
planType?: string
|
||||
expiresAt?: number
|
||||
}> {
|
||||
const tokens = await OpenAIAuthStore.get()
|
||||
const tokens = ProviderCredentialStore.getOAuth('openai-oauth')
|
||||
if (!tokens) return { loggedIn: false }
|
||||
// Re-derive from the access token so sessions stored by an earlier version
|
||||
// (which missed the namespaced claims) report their plan without re-login.
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { app } from 'electron'
|
||||
import { logger } from '@shared/utils/Logger'
|
||||
|
||||
export interface OpenAITokens {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresAt: number
|
||||
accountID?: string
|
||||
email?: string
|
||||
/** ChatGPT subscription tier, e.g. `plus` / `pro` / `team`. */
|
||||
planType?: string
|
||||
}
|
||||
|
||||
const getFilePath = () => path.join(app.getPath('userData'), 'openai-auth.json')
|
||||
|
||||
let writeLock: Promise<void> = Promise.resolve()
|
||||
function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const prev = writeLock
|
||||
let resolve!: () => void
|
||||
writeLock = new Promise(r => { resolve = r })
|
||||
return prev.then(fn).finally(() => resolve())
|
||||
}
|
||||
|
||||
async function atomicWrite(filepath: string, data: string): Promise<void> {
|
||||
const tmp = `${filepath}.${process.pid}.tmp`
|
||||
await fs.promises.writeFile(tmp, data, { mode: 0o600 })
|
||||
await fs.promises.rename(tmp, filepath)
|
||||
}
|
||||
|
||||
async function read(): Promise<OpenAITokens | null> {
|
||||
try {
|
||||
const content = await fs.promises.readFile(getFilePath(), 'utf-8')
|
||||
return JSON.parse(content)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const OpenAIAuthStore = {
|
||||
get: read,
|
||||
|
||||
set: (tokens: OpenAITokens) =>
|
||||
withLock(async () => {
|
||||
try {
|
||||
await atomicWrite(getFilePath(), JSON.stringify(tokens, null, 2))
|
||||
} catch (err) {
|
||||
logger.store.error('[OpenAIAuthStore] Failed to save:', err)
|
||||
}
|
||||
}),
|
||||
|
||||
clear: () =>
|
||||
withLock(async () => {
|
||||
try {
|
||||
await fs.promises.unlink(getFilePath())
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}),
|
||||
|
||||
isExpired: async (): Promise<boolean> => {
|
||||
const tokens = await read()
|
||||
if (!tokens) return true
|
||||
return tokens.expiresAt < Date.now() + 60_000
|
||||
},
|
||||
}
|
||||
@@ -83,6 +83,7 @@ export class AgentClass {
|
||||
threadId?: string
|
||||
requestId?: string
|
||||
planTaskId?: string
|
||||
contextItems?: import('../types').ContextItem[]
|
||||
/** 该次执行是否为子代理(隐藏线程)。会剔除 task/ask_user 等工具。 */
|
||||
isSubAgent?: boolean
|
||||
}
|
||||
@@ -98,33 +99,23 @@ export class AgentClass {
|
||||
throw new Error(`Thread ${threadId} is already running`)
|
||||
}
|
||||
|
||||
// 验证凭证:OAuth provider 没有 API Key,其 access token 由主进程
|
||||
// resolveAuthForConfig() 解析,这里只校验是否已登录。
|
||||
if (getBuiltinProvider(config.provider)?.auth.type === 'oauth') {
|
||||
const status = await window.electronAPI?.openaiAuthStatus?.().catch(() => null)
|
||||
if (!status?.loggedIn) {
|
||||
this.showError(translateAgentText('oauthSignInWarning'))
|
||||
throw new Error('Not signed in to ChatGPT')
|
||||
}
|
||||
} else if (!config.apiKey) {
|
||||
this.showError(translateAgentText('apiKeyWarning'))
|
||||
throw new Error('Missing API key')
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
const requestId = executionOptions?.requestId || crypto.randomUUID()
|
||||
const contextItems = threadId
|
||||
const contextItems = executionOptions?.contextItems ?? (threadId
|
||||
? (store.threads[threadId]?.contextItems || [])
|
||||
: (store.getCurrentThread()?.contextItems || [])
|
||||
: (store.getCurrentThread()?.contextItems || []))
|
||||
|
||||
let persistSuspended = false
|
||||
let taskRegistered = false
|
||||
let assistantId = ''
|
||||
|
||||
try {
|
||||
suspendAgentStorageWrites()
|
||||
persistSuspended = true
|
||||
// 1. 【性能关键】批量初始化消息环境(合并用户消息、助手气泡、上下文清理)
|
||||
const { assistantId, threadId: preparedThreadId } = store.prepareExecution(userMessage, contextItems, executionOptions?.threadId)
|
||||
const prepared = store.prepareExecution(userMessage, contextItems, executionOptions?.threadId)
|
||||
assistantId = prepared.assistantId
|
||||
const preparedThreadId = prepared.threadId
|
||||
|
||||
threadId = preparedThreadId
|
||||
if (!threadId) {
|
||||
@@ -161,6 +152,12 @@ export class AgentClass {
|
||||
// 【核心优化】立即让出主线程,确保用户消息和助手气泡瞬间在 UI 渲染
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
// Local validation happens after the optimistic commit. OAuth resolution
|
||||
// stays centralized in the main-process credential service.
|
||||
if (getBuiltinProvider(config.provider)?.auth.type !== 'oauth' && !config.apiKey) {
|
||||
throw new Error(translateAgentText('apiKeyWarning'))
|
||||
}
|
||||
|
||||
// 3. 提取提到的 Skills
|
||||
const mentionedSkills = contextItems
|
||||
.filter(item => item.type === 'Skill')
|
||||
@@ -235,7 +232,7 @@ export class AgentClass {
|
||||
// 统一错误处理
|
||||
const appError = AppError.fromError(error)
|
||||
logger.agent.error('[Agent] Error:', appError.toJSON())
|
||||
this.showError(formatErrorMessage(appError))
|
||||
this.showError(formatErrorMessage(appError), assistantId, threadId || undefined)
|
||||
throw error
|
||||
} finally {
|
||||
if (persistSuspended) {
|
||||
@@ -491,15 +488,15 @@ export class AgentClass {
|
||||
/**
|
||||
* 显示错误消息给用户
|
||||
*/
|
||||
private showError(message: string): void {
|
||||
private showError(message: string, assistantId?: string, threadId?: string): void {
|
||||
const store = useAgentStore.getState()
|
||||
const id = store.addAssistantMessage()
|
||||
const id = assistantId || store.addAssistantMessage('', threadId)
|
||||
store.addSystemAlertPart(id, {
|
||||
alertType: 'error',
|
||||
title: translateAgentText('error'),
|
||||
message,
|
||||
})
|
||||
store.finalizeAssistant(id)
|
||||
}, threadId)
|
||||
store.finalizeAssistant(id, threadId)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1518,11 +1518,11 @@ export const createMessageSlice: StateCreator<
|
||||
},
|
||||
|
||||
// 添加上下文项
|
||||
addContextItem: (item) => {
|
||||
let threadId = get().currentThreadId
|
||||
addContextItem: (item, targetThreadId) => {
|
||||
let threadId = targetThreadId || get().currentThreadId
|
||||
|
||||
if (!threadId || !get().threads[threadId]) {
|
||||
threadId = get().createThread()
|
||||
threadId = get().createThread({ activate: !targetThreadId })
|
||||
}
|
||||
|
||||
if (!threadId) return
|
||||
@@ -1557,8 +1557,8 @@ export const createMessageSlice: StateCreator<
|
||||
},
|
||||
|
||||
// 移除上下文项
|
||||
removeContextItem: (index) => {
|
||||
const threadId = get().currentThreadId
|
||||
removeContextItem: (index, targetThreadId) => {
|
||||
const threadId = targetThreadId || get().currentThreadId
|
||||
if (!threadId) return
|
||||
|
||||
set(state => {
|
||||
@@ -1578,8 +1578,8 @@ export const createMessageSlice: StateCreator<
|
||||
},
|
||||
|
||||
// 清空上下文项
|
||||
clearContextItems: () => {
|
||||
const threadId = get().currentThreadId
|
||||
clearContextItems: (targetThreadId) => {
|
||||
const threadId = targetThreadId || get().currentThreadId
|
||||
if (!threadId) return
|
||||
|
||||
set(state => {
|
||||
|
||||
@@ -931,14 +931,6 @@ export default function ChatPanel() {
|
||||
}
|
||||
|
||||
if (targetThreadId) {
|
||||
const currentContextItems = useAgentStore.getState().threads[targetThreadId]?.contextItems || []
|
||||
const hasDifferentContext =
|
||||
currentContextItems.length !== contextItemsForSend.length ||
|
||||
currentContextItems.some((item, index) => item !== contextItemsForSend[index])
|
||||
if (hasDifferentContext) {
|
||||
useAgentStore.getState().clearContextItems(targetThreadId)
|
||||
contextItemsForSend.forEach((item) => useAgentStore.getState().addContextItem(item, targetThreadId))
|
||||
}
|
||||
if (explicitServer.lastActiveServer) {
|
||||
useAgentStore.getState().setLastActiveServer(explicitServer.lastActiveServer, targetThreadId)
|
||||
}
|
||||
@@ -957,10 +949,13 @@ export default function ChatPanel() {
|
||||
return
|
||||
}
|
||||
|
||||
// 发送消息后主动滚到底部,确保用户消息和即将出现的 AI 回复可见
|
||||
// 不依赖 followOutput 的时序,因为发送瞬间 isStreaming 还是 false
|
||||
scrollToBottom('smooth')
|
||||
await sendMessage(userMessage, { mode: effectiveMode, threadId: targetThreadId })
|
||||
const sendPromise = sendMessage(userMessage, {
|
||||
mode: effectiveMode,
|
||||
threadId: targetThreadId,
|
||||
contextItems: contextItemsForSend,
|
||||
})
|
||||
requestAnimationFrame(() => scrollToBottom('auto'))
|
||||
await sendPromise
|
||||
}, [input, images, isStreaming, sendMessage, contextFilePath, selectedCode, workspacePath, setChatMode, scrollToBottom, visibleContextItems, chatMode, toast, language, currentThreadId, createThread])
|
||||
|
||||
// 编辑消息
|
||||
@@ -1057,7 +1052,7 @@ export default function ChatPanel() {
|
||||
|
||||
const [oauthSignedIn, setOauthSignedIn] = useState(false)
|
||||
useEffect(() => {
|
||||
window.electronAPI?.openaiAuthStatus?.()
|
||||
window.electronAPI?.credentialsOAuthStatus?.()
|
||||
.then(s => setOauthSignedIn(s?.loggedIn ?? false))
|
||||
.catch(() => setOauthSignedIn(false))
|
||||
}, [])
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function ModelSelector({ className = '', alignLeft = false }: Mod
|
||||
// OAuth providers have no API key — availability depends on sign-in state.
|
||||
const [oauthSignedIn, setOauthSignedIn] = useState(false)
|
||||
useEffect(() => {
|
||||
window.electronAPI.openaiAuthStatus()
|
||||
window.electronAPI.credentialsOAuthStatus()
|
||||
.then(s => setOauthSignedIn(s.loggedIn))
|
||||
.catch(() => setOauthSignedIn(false))
|
||||
}, [isOpen])
|
||||
|
||||
@@ -212,7 +212,7 @@ function reconcileCustomHeaderDrafts(
|
||||
}
|
||||
|
||||
type ChatGPTUsage = Awaited<
|
||||
ReturnType<typeof window.electronAPI.openaiAuthUsage>
|
||||
ReturnType<typeof window.electronAPI.credentialsOAuthUsage>
|
||||
>['usage']
|
||||
|
||||
/** Human-readable label for a rolling quota window, e.g. 43800 min -> "30 天". */
|
||||
@@ -370,14 +370,14 @@ const OAuthSignInPanel = memo(function OAuthSignInPanel({
|
||||
} | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [usage, setUsage] = useState<
|
||||
Awaited<ReturnType<typeof window.electronAPI.openaiAuthUsage>>['usage']
|
||||
Awaited<ReturnType<typeof window.electronAPI.credentialsOAuthUsage>>['usage']
|
||||
>(null)
|
||||
const [usageBusy, setUsageBusy] = useState(false)
|
||||
|
||||
const loadUsage = useCallback(async (refreshFromServer = false) => {
|
||||
setUsageBusy(true)
|
||||
try {
|
||||
const result = await window.electronAPI.openaiAuthUsage({ refresh: refreshFromServer })
|
||||
const result = await window.electronAPI.credentialsOAuthUsage({ refresh: refreshFromServer })
|
||||
setUsage(result?.usage ?? null)
|
||||
} catch {
|
||||
setUsage(null)
|
||||
@@ -388,7 +388,7 @@ const OAuthSignInPanel = memo(function OAuthSignInPanel({
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const next = await window.electronAPI.openaiAuthStatus()
|
||||
const next = await window.electronAPI.credentialsOAuthStatus()
|
||||
setStatus(next)
|
||||
if (next?.loggedIn) void loadUsage()
|
||||
else setUsage(null)
|
||||
@@ -404,7 +404,7 @@ const OAuthSignInPanel = memo(function OAuthSignInPanel({
|
||||
const handleLogin = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const result = await window.electronAPI.openaiAuthLogin()
|
||||
const result = await window.electronAPI.credentialsOAuthLogin()
|
||||
if (result.success) {
|
||||
toast.success(language === 'zh' ? 'ChatGPT 登录成功' : 'Signed in to ChatGPT')
|
||||
await refresh()
|
||||
@@ -421,7 +421,7 @@ const OAuthSignInPanel = memo(function OAuthSignInPanel({
|
||||
const handleLogout = async () => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await window.electronAPI.openaiAuthLogout()
|
||||
await window.electronAPI.credentialsOAuthLogout()
|
||||
toast.success(language === 'zh' ? '已退出登录' : 'Signed out')
|
||||
await refresh()
|
||||
} finally {
|
||||
@@ -1029,7 +1029,7 @@ export function ProviderSettings({
|
||||
const [oauthSignedIn, setOauthSignedIn] = useState(false)
|
||||
const refreshOAuthStatus = useCallback(async () => {
|
||||
try {
|
||||
const status = await window.electronAPI.openaiAuthStatus()
|
||||
const status = await window.electronAPI.credentialsOAuthStatus()
|
||||
setOauthSignedIn(status.loggedIn)
|
||||
} catch {
|
||||
setOauthSignedIn(false)
|
||||
|
||||
@@ -94,7 +94,11 @@ export function useAgentCommands() {
|
||||
planPhase,
|
||||
}
|
||||
|
||||
const sendMessage = useCallback(async (content: MessageContent, options?: { mode?: WorkMode, threadId?: string }) => {
|
||||
const sendMessage = useCallback(async (content: MessageContent, options?: {
|
||||
mode?: WorkMode
|
||||
threadId?: string
|
||||
contextItems?: import('@/renderer/agent/types').ContextItem[]
|
||||
}) => {
|
||||
const {
|
||||
llmConfig: config,
|
||||
workspacePath: currentWorkspacePath,
|
||||
@@ -124,7 +128,7 @@ export function useAgentCommands() {
|
||||
promptTemplateId: currentPromptTemplateId,
|
||||
planPhase: targetMode === 'plan' ? currentPlanPhase : undefined,
|
||||
},
|
||||
{ threadId: options?.threadId }
|
||||
{ threadId: options?.threadId, contextItems: options?.contextItems }
|
||||
)
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -52,7 +52,11 @@ export function useMessageQueueConsumer() {
|
||||
setMode(next.chatMode)
|
||||
// 发送消息
|
||||
try {
|
||||
await sendMessage(next.content, { mode: next.chatMode, threadId: next.targetThreadId })
|
||||
await sendMessage(next.content, {
|
||||
mode: next.chatMode,
|
||||
threadId: next.targetThreadId,
|
||||
contextItems: next.contextItems,
|
||||
})
|
||||
} catch {
|
||||
// 发送失败不阻塞,错误已由 Agent 内部处理
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
*
|
||||
* Architecture:
|
||||
* - `llmConfig` persistence only stores active model selection + generation behavior
|
||||
* - provider/network fields (apiKey/baseUrl/timeout/headers/protocol) live in `providerConfigs`
|
||||
* - runtime `LLMConfig` is reconstructed from persisted llmConfig + providerConfigs + defaults
|
||||
* - provider/network fields live in `providerConfigs`
|
||||
* - credentials are persisted by the main-process provider credential store
|
||||
* - runtime `LLMConfig` is reconstructed from persisted settings + credentials + defaults
|
||||
*/
|
||||
|
||||
import { api } from '@/renderer/services/electronAPI'
|
||||
@@ -89,8 +90,6 @@ function cleanProviderConfig(
|
||||
const resolvedProtocol = config.protocol ?? builtinDef?.protocol
|
||||
const defaultOpenAIProfile = getDefaultOpenAICompatibilityProfile(providerId, resolvedProtocol)
|
||||
|
||||
if (config.apiKey) cleaned.apiKey = config.apiKey
|
||||
|
||||
if (config.baseUrl && config.baseUrl !== builtinDef?.baseUrl) {
|
||||
cleaned.baseUrl = config.baseUrl
|
||||
}
|
||||
@@ -128,13 +127,12 @@ function cleanProviderConfig(
|
||||
|
||||
function mergeProviderConfigs(
|
||||
saved: Record<string, ProviderConfig> | undefined,
|
||||
apiKeys: Record<string, string> = {},
|
||||
): Record<string, ProviderModelConfig> {
|
||||
const defaults = SETTINGS.providerConfigs.default
|
||||
if (!saved) return { ...defaults }
|
||||
|
||||
const merged: Record<string, ProviderModelConfig> = { ...defaults }
|
||||
|
||||
for (const [id, config] of Object.entries(saved)) {
|
||||
for (const [id, config] of Object.entries(saved || {})) {
|
||||
if (isBuiltinProvider(id)) {
|
||||
const resolved = { ...defaults[id], ...config }
|
||||
merged[id] = {
|
||||
@@ -158,6 +156,10 @@ function mergeProviderConfigs(
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, apiKey] of Object.entries(apiKeys)) {
|
||||
merged[id] = { ...(merged[id] || {}), apiKey }
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -191,17 +193,18 @@ class SettingsService {
|
||||
await this.migrateLegacyCacheOnce()
|
||||
|
||||
try {
|
||||
const [appSettings, editorConfig, securitySettings] = await Promise.all([
|
||||
const [appSettings, editorConfig, securitySettings, apiKeys] = await Promise.all([
|
||||
api.settings.get(STORAGE_KEYS.APP),
|
||||
api.settings.get(STORAGE_KEYS.EDITOR),
|
||||
api.settings.get(STORAGE_KEYS.SECURITY),
|
||||
window.electronAPI.credentialsGetApiKeys(),
|
||||
])
|
||||
|
||||
const merged = this.merge({
|
||||
...(appSettings as object || {}),
|
||||
editorConfig,
|
||||
securitySettings,
|
||||
})
|
||||
}, apiKeys)
|
||||
|
||||
this.cache = merged
|
||||
return merged
|
||||
@@ -266,8 +269,10 @@ class SettingsService {
|
||||
async save(settings: SettingsState): Promise<void> {
|
||||
try {
|
||||
const cleanedProviderConfigs: Record<string, ProviderConfig> = {}
|
||||
const apiKeys: Record<string, string> = {}
|
||||
|
||||
for (const [id, config] of Object.entries(settings.providerConfigs)) {
|
||||
if (config.apiKey) apiKeys[id] = config.apiKey
|
||||
const cleaned = cleanProviderConfig(id, config, id === settings.llmConfig.provider)
|
||||
if (cleaned) cleanedProviderConfigs[id] = cleaned as ProviderConfig
|
||||
}
|
||||
@@ -277,6 +282,7 @@ class SettingsService {
|
||||
this.cache = settings
|
||||
|
||||
await Promise.all([
|
||||
window.electronAPI.credentialsReplaceApiKeys(apiKeys),
|
||||
api.settings.set(STORAGE_KEYS.APP, appSettings),
|
||||
api.settings.set(STORAGE_KEYS.EDITOR, settings.editorConfig),
|
||||
api.settings.set(STORAGE_KEYS.SECURITY, settings.securitySettings),
|
||||
@@ -314,10 +320,11 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
private merge(saved: Record<string, unknown>): SettingsState {
|
||||
private merge(saved: Record<string, unknown>, apiKeys: Record<string, string> = {}): SettingsState {
|
||||
const defaults = getAllDefaults()
|
||||
const providerConfigs = mergeProviderConfigs(
|
||||
saved.providerConfigs as Record<string, ProviderConfig> | undefined,
|
||||
apiKeys,
|
||||
)
|
||||
const llmConfig = resolveRuntimeLLMConfig(
|
||||
saved.llmConfig as Partial<PersistedLLMConfig> | undefined,
|
||||
|
||||
Vendored
+8
-6
@@ -695,12 +695,14 @@ export interface ElectronAPI {
|
||||
mcpGetPrompt: (request: McpPromptGetRequest) => Promise<McpPromptGetResult>
|
||||
mcpRefreshCapabilities: (serverId: string) => Promise<{ success: boolean; error?: string }>
|
||||
|
||||
// OpenAI OAuth
|
||||
openaiAuthLogin: () => Promise<{ success: boolean; accountID?: string; error?: string }>
|
||||
openaiAuthLogout: () => Promise<{ success: boolean; error?: string }>
|
||||
openaiAuthStatus: () => Promise<{ loggedIn: boolean; accountID?: string; email?: string; planType?: string; expiresAt?: number }>
|
||||
openaiAuthToken: () => Promise<{ token: string | null }>
|
||||
openaiAuthUsage: (options?: { refresh?: boolean }) => Promise<{
|
||||
// Provider credentials
|
||||
credentialsGetApiKeys: () => Promise<Record<string, string>>
|
||||
credentialsReplaceApiKeys: (apiKeys: Record<string, string>) => Promise<boolean>
|
||||
credentialsOAuthLogin: () => Promise<{ success: boolean; accountID?: string; error?: string }>
|
||||
credentialsOAuthLogout: () => Promise<{ success: boolean; error?: string }>
|
||||
credentialsOAuthStatus: () => Promise<{ loggedIn: boolean; accountID?: string; email?: string; planType?: string; expiresAt?: number }>
|
||||
credentialsOAuthToken: () => Promise<{ token: string | null }>
|
||||
credentialsOAuthUsage: (options?: { refresh?: boolean }) => Promise<{
|
||||
usage: {
|
||||
planType?: string
|
||||
activeLimit?: string
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@services/agentSessionRepository', () => ({
|
||||
agentSessionRepository: {
|
||||
deleteThread: vi.fn(() => Promise.resolve()),
|
||||
stageSnapshot: vi.fn(),
|
||||
flush: vi.fn(() => Promise.resolve()),
|
||||
clear: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
}))
|
||||
|
||||
import { Agent } from '@renderer/agent/core/Agent'
|
||||
import { useAgentStore } from '@renderer/agent/store/AgentStore'
|
||||
|
||||
describe('Agent optimistic send', () => {
|
||||
beforeEach(() => {
|
||||
useAgentStore.setState({
|
||||
threads: {},
|
||||
currentThreadId: null,
|
||||
threadMessageVersions: {},
|
||||
branches: {},
|
||||
activeBranchId: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('commits the user message before asynchronous validation finishes', async () => {
|
||||
const promise = Agent.send(
|
||||
'visible immediately',
|
||||
{ provider: 'openai', model: 'gpt-5', apiKey: '' } as never,
|
||||
null,
|
||||
'agent',
|
||||
)
|
||||
|
||||
const thread = useAgentStore.getState().getCurrentThread()
|
||||
expect(thread?.messages.map(message => message.role)).toEqual(['user', 'assistant'])
|
||||
expect(thread?.messages[0].role === 'user' ? thread.messages[0].content : undefined)
|
||||
.toBe('visible immediately')
|
||||
|
||||
await expect(promise).rejects.toThrow()
|
||||
|
||||
const assistant = useAgentStore.getState().getCurrentThread()?.messages[1]
|
||||
expect(assistant?.role).toBe('assistant')
|
||||
expect(assistant && 'isStreaming' in assistant ? assistant.isStreaming : true).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -118,4 +118,17 @@ describe('stored message history cap', () => {
|
||||
// 2 turns => 4 messages, far below the cap.
|
||||
expect(useAgentStore.getState().threads[threadId].messages).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('updates context on the explicitly targeted thread', () => {
|
||||
const store = useAgentStore.getState()
|
||||
const activeId = store.createThread({ activate: true, mode: 'agent', origin: 'user' })
|
||||
const targetId = store.createThread({ activate: false, mode: 'plan', origin: 'user' })
|
||||
|
||||
store.addContextItem({ type: 'Web' }, targetId)
|
||||
expect(useAgentStore.getState().threads[activeId].contextItems).toEqual([])
|
||||
expect(useAgentStore.getState().threads[targetId].contextItems).toEqual([{ type: 'Web' }])
|
||||
|
||||
store.clearContextItems(targetId)
|
||||
expect(useAgentStore.getState().threads[targetId].contextItems).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as fs from 'fs'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const electronState = vi.hoisted(() => ({ userDataPath: '' }))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => electronState.userDataPath },
|
||||
}))
|
||||
|
||||
import { ProviderCredentialStore } from '@main/services/credentials/ProviderCredentialStore'
|
||||
|
||||
class MemoryStore {
|
||||
constructor(private readonly values: Record<string, unknown> = {}) {}
|
||||
get(key: string): unknown { return this.values[key] }
|
||||
set(key: string, value: unknown): void { this.values[key] = value }
|
||||
}
|
||||
|
||||
describe('ProviderCredentialStore', () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'adnify-credentials-'))
|
||||
electronState.userDataPath = tempDir
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('migrates API keys and OAuth into one provider credential map and removes old storage', async () => {
|
||||
const store = new MemoryStore({
|
||||
'app-settings': {
|
||||
providerConfigs: {
|
||||
openai: { apiKey: 'sk-test', model: 'gpt-5' },
|
||||
},
|
||||
},
|
||||
})
|
||||
const oauth = {
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
accountID: 'account',
|
||||
}
|
||||
const legacyPath = path.join(tempDir, 'openai-auth.json')
|
||||
fs.writeFileSync(legacyPath, JSON.stringify(oauth))
|
||||
|
||||
await ProviderCredentialStore.initialize(store as never)
|
||||
|
||||
expect(ProviderCredentialStore.getApiKeys()).toEqual({ openai: 'sk-test' })
|
||||
expect(ProviderCredentialStore.getOAuth('openai-oauth')).toEqual(oauth)
|
||||
expect(fs.existsSync(legacyPath)).toBe(false)
|
||||
expect(store.get('app-settings')).toEqual({
|
||||
providerConfigs: { openai: { model: 'gpt-5' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces API keys without touching OAuth credentials', async () => {
|
||||
const store = new MemoryStore({
|
||||
providerCredentials: {
|
||||
anthropic: { type: 'api-key', apiKey: 'old' },
|
||||
'openai-oauth': {
|
||||
type: 'oauth',
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: 123,
|
||||
},
|
||||
},
|
||||
})
|
||||
await ProviderCredentialStore.initialize(store as never)
|
||||
|
||||
ProviderCredentialStore.replaceApiKeys({ openai: 'new' })
|
||||
|
||||
expect(ProviderCredentialStore.getApiKeys()).toEqual({ openai: 'new' })
|
||||
expect(ProviderCredentialStore.getOAuth('openai-oauth')).toEqual({
|
||||
accessToken: 'access',
|
||||
refreshToken: 'refresh',
|
||||
expiresAt: 123,
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user