Add Codex OAuth provider flow for ChatGPT account sign-in (#503)
* feat: add Codex OAuth provider flow * fix: harden Codex OAuth storage, session activation, and UI
This commit is contained in:
committed by
GitHub
parent
252808bbd0
commit
fc7dc9ca0d
607
src/utils/codexCredentials.test.ts
Normal file
607
src/utils/codexCredentials.test.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* These tests avoid static imports so Bun can mock secureStorage before
|
||||
* codexCredentials is first loaded.
|
||||
*/
|
||||
import { afterEach, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' }))
|
||||
.toString('base64url')
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
return `${header}.${body}.signature`
|
||||
}
|
||||
|
||||
describe('codexCredentials', () => {
|
||||
const originalSimple = process.env.CLAUDE_CODE_SIMPLE
|
||||
const originalCodeKey = process.env.CODEX_API_KEY
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
globalThis.fetch = originalFetch
|
||||
|
||||
if (originalSimple === undefined) {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_SIMPLE = originalSimple
|
||||
}
|
||||
|
||||
if (originalCodeKey === undefined) {
|
||||
delete process.env.CODEX_API_KEY
|
||||
} else {
|
||||
process.env.CODEX_API_KEY = originalCodeKey
|
||||
}
|
||||
})
|
||||
|
||||
test('save returns failure in bare mode', async () => {
|
||||
process.env.CLAUDE_CODE_SIMPLE = '1'
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { saveCodexCredentials } = await import(
|
||||
'./codexCredentials.js?save-bare-mode'
|
||||
)
|
||||
|
||||
const result = saveCodexCredentials({
|
||||
accessToken: 'token',
|
||||
accountId: 'acct_123',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.warning).toContain('Bare mode')
|
||||
})
|
||||
|
||||
test('saveCodexCredentials refuses plaintext fallback when native secure storage is unavailable', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: (options?: { allowPlainTextFallback?: boolean }) => {
|
||||
expect(options?.allowPlainTextFallback).toBe(false)
|
||||
return {
|
||||
read: () => null,
|
||||
readAsync: async () => null,
|
||||
update: () => ({
|
||||
success: false,
|
||||
warning:
|
||||
'Secure storage is unavailable on this platform without plaintext fallback.',
|
||||
}),
|
||||
delete: () => true,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { saveCodexCredentials } = await import(
|
||||
'./codexCredentials.js?save-no-plaintext-fallback'
|
||||
)
|
||||
|
||||
const result = saveCodexCredentials({
|
||||
accessToken: 'token',
|
||||
accountId: 'acct_123',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.warning).toContain('without plaintext fallback')
|
||||
})
|
||||
|
||||
test('refreshCodexAccessTokenIfNeeded refreshes expired stored credentials', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CODEX_API_KEY
|
||||
|
||||
const expiredToken = makeJwt({
|
||||
exp: Math.floor((Date.now() - 60_000) / 1000),
|
||||
chatgpt_account_id: 'acct_old',
|
||||
})
|
||||
const freshAccessToken = makeJwt({
|
||||
exp: Math.floor((Date.now() + 3_600_000) / 1000),
|
||||
chatgpt_account_id: 'acct_new',
|
||||
})
|
||||
const freshIdToken = makeJwt({
|
||||
exp: Math.floor((Date.now() + 3_600_000) / 1000),
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'acct_new',
|
||||
},
|
||||
})
|
||||
|
||||
let storageState: Record<string, unknown> = {
|
||||
codex: {
|
||||
accessToken: expiredToken,
|
||||
refreshToken: 'refresh-old',
|
||||
accountId: 'acct_old',
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: () => ({
|
||||
read: () => storageState,
|
||||
readAsync: async () => storageState,
|
||||
update: (next: Record<string, unknown>) => {
|
||||
storageState = next
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
globalThis.fetch = mock(
|
||||
async (_input, init) => {
|
||||
const bodyText =
|
||||
typeof init?.body === 'string'
|
||||
? init.body
|
||||
: init?.body instanceof URLSearchParams
|
||||
? init.body.toString()
|
||||
: ''
|
||||
|
||||
if (
|
||||
bodyText.includes('grant_type=refresh_token') ||
|
||||
bodyText.includes('"grant_type":"refresh_token"')
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: freshAccessToken,
|
||||
refresh_token: 'refresh-new',
|
||||
id_token: freshIdToken,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'codex-api-key-token',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { refreshCodexAccessTokenIfNeeded, readCodexCredentials } =
|
||||
await import('./codexCredentials.js?refresh-success')
|
||||
|
||||
const result = await refreshCodexAccessTokenIfNeeded()
|
||||
expect(result.refreshed).toBe(true)
|
||||
|
||||
const stored = readCodexCredentials()
|
||||
expect(stored?.accessToken).toBe(freshAccessToken)
|
||||
expect(stored?.apiKey).toBe('codex-api-key-token')
|
||||
expect(stored?.refreshToken).toBe('refresh-new')
|
||||
expect(stored?.accountId).toBe('acct_new')
|
||||
})
|
||||
|
||||
test('refreshCodexAccessTokenIfNeeded backs off after a failed refresh attempt', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CODEX_API_KEY
|
||||
|
||||
const expiredToken = makeJwt({
|
||||
exp: Math.floor((Date.now() - 60_000) / 1000),
|
||||
chatgpt_account_id: 'acct_old',
|
||||
})
|
||||
|
||||
let storageState: Record<string, unknown> = {
|
||||
codex: {
|
||||
accessToken: expiredToken,
|
||||
refreshToken: 'refresh-old',
|
||||
accountId: 'acct_old',
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: () => ({
|
||||
read: () => storageState,
|
||||
readAsync: async () => storageState,
|
||||
update: (next: Record<string, unknown>) => {
|
||||
storageState = next
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
let refreshAttempts = 0
|
||||
globalThis.fetch = mock(async () => {
|
||||
refreshAttempts += 1
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 'invalid_grant',
|
||||
message: 'refresh token expired',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
)
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { refreshCodexAccessTokenIfNeeded, readCodexCredentials } =
|
||||
await import('./codexCredentials.js?refresh-cooldown')
|
||||
|
||||
await expect(refreshCodexAccessTokenIfNeeded()).rejects.toThrow(
|
||||
'Codex token refresh failed (invalid_grant): refresh token expired',
|
||||
)
|
||||
|
||||
const afterFailure = readCodexCredentials()
|
||||
expect(typeof afterFailure?.lastRefreshFailureAt).toBe('number')
|
||||
|
||||
const secondAttempt = await refreshCodexAccessTokenIfNeeded()
|
||||
expect(secondAttempt.refreshed).toBe(false)
|
||||
expect(secondAttempt.credentials?.accessToken).toBe(expiredToken)
|
||||
expect(refreshAttempts).toBe(1)
|
||||
})
|
||||
|
||||
test('refreshCodexAccessTokenIfNeeded drops a stale api key when id-token exchange fails', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CODEX_API_KEY
|
||||
|
||||
const expiredToken = makeJwt({
|
||||
exp: Math.floor((Date.now() - 60_000) / 1000),
|
||||
chatgpt_account_id: 'acct_old',
|
||||
})
|
||||
const freshAccessToken = makeJwt({
|
||||
exp: Math.floor((Date.now() + 3_600_000) / 1000),
|
||||
chatgpt_account_id: 'acct_new',
|
||||
})
|
||||
const freshIdToken = makeJwt({
|
||||
exp: Math.floor((Date.now() + 3_600_000) / 1000),
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'acct_new',
|
||||
},
|
||||
})
|
||||
|
||||
let storageState: Record<string, unknown> = {
|
||||
codex: {
|
||||
apiKey: 'stale-api-key',
|
||||
accessToken: expiredToken,
|
||||
refreshToken: 'refresh-old',
|
||||
accountId: 'acct_old',
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: () => ({
|
||||
read: () => storageState,
|
||||
readAsync: async () => storageState,
|
||||
update: (next: Record<string, unknown>) => {
|
||||
storageState = next
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
globalThis.fetch = mock(
|
||||
async (_input, init) => {
|
||||
const bodyText =
|
||||
typeof init?.body === 'string'
|
||||
? init.body
|
||||
: init?.body instanceof URLSearchParams
|
||||
? init.body.toString()
|
||||
: ''
|
||||
|
||||
if (bodyText.includes('grant_type=refresh_token')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: freshAccessToken,
|
||||
refresh_token: 'refresh-new',
|
||||
id_token: freshIdToken,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return new Response('exchange failed', {
|
||||
status: 500,
|
||||
})
|
||||
},
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { refreshCodexAccessTokenIfNeeded, readCodexCredentials } =
|
||||
await import('./codexCredentials.js?refresh-drop-stale-api-key')
|
||||
|
||||
const result = await refreshCodexAccessTokenIfNeeded()
|
||||
expect(result.refreshed).toBe(true)
|
||||
|
||||
const stored = readCodexCredentials()
|
||||
expect(stored?.accessToken).toBe(freshAccessToken)
|
||||
expect(stored?.apiKey).toBeUndefined()
|
||||
expect(stored?.refreshToken).toBe('refresh-new')
|
||||
expect(stored?.accountId).toBe('acct_new')
|
||||
})
|
||||
|
||||
test('refreshCodexAccessTokenIfNeeded deduplicates concurrent refresh attempts', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CODEX_API_KEY
|
||||
|
||||
const expiredToken = makeJwt({
|
||||
exp: Math.floor((Date.now() - 60_000) / 1000),
|
||||
chatgpt_account_id: 'acct_old',
|
||||
})
|
||||
const freshAccessToken = makeJwt({
|
||||
exp: Math.floor((Date.now() + 3_600_000) / 1000),
|
||||
chatgpt_account_id: 'acct_new',
|
||||
})
|
||||
const freshIdToken = makeJwt({
|
||||
exp: Math.floor((Date.now() + 3_600_000) / 1000),
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'acct_new',
|
||||
},
|
||||
})
|
||||
|
||||
let storageState: Record<string, unknown> = {
|
||||
codex: {
|
||||
accessToken: expiredToken,
|
||||
refreshToken: 'refresh-old',
|
||||
accountId: 'acct_old',
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: () => ({
|
||||
read: () => storageState,
|
||||
readAsync: async () => storageState,
|
||||
update: (next: Record<string, unknown>) => {
|
||||
storageState = next
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
let refreshAttempts = 0
|
||||
let releaseRefresh: (() => void) | undefined
|
||||
const refreshGate = new Promise<void>(resolve => {
|
||||
releaseRefresh = resolve
|
||||
})
|
||||
|
||||
globalThis.fetch = mock(async (_input, init) => {
|
||||
const bodyText =
|
||||
typeof init?.body === 'string'
|
||||
? init.body
|
||||
: init?.body instanceof URLSearchParams
|
||||
? init.body.toString()
|
||||
: ''
|
||||
|
||||
if (bodyText.includes('grant_type=refresh_token')) {
|
||||
refreshAttempts += 1
|
||||
await refreshGate
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: freshAccessToken,
|
||||
refresh_token: 'refresh-new',
|
||||
id_token: freshIdToken,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'codex-api-key-token',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
)
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { refreshCodexAccessTokenIfNeeded } = await import(
|
||||
'./codexCredentials.js?refresh-dedupe'
|
||||
)
|
||||
|
||||
const firstRefresh = refreshCodexAccessTokenIfNeeded()
|
||||
const secondRefresh = refreshCodexAccessTokenIfNeeded()
|
||||
releaseRefresh?.()
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([
|
||||
firstRefresh,
|
||||
secondRefresh,
|
||||
])
|
||||
|
||||
expect(refreshAttempts).toBe(1)
|
||||
expect(firstResult).toEqual(secondResult)
|
||||
expect(firstResult.refreshed).toBe(true)
|
||||
expect(firstResult.credentials?.accessToken).toBe(freshAccessToken)
|
||||
})
|
||||
|
||||
test('saveCodexCredentials preserves an existing linked profile id', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
|
||||
let storageState: Record<string, unknown> = {
|
||||
codex: {
|
||||
accessToken: 'access-old',
|
||||
refreshToken: 'refresh-old',
|
||||
accountId: 'acct_old',
|
||||
profileId: 'profile_codex_oauth',
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: () => ({
|
||||
read: () => storageState,
|
||||
readAsync: async () => storageState,
|
||||
update: (next: Record<string, unknown>) => {
|
||||
storageState = next
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { readCodexCredentials, saveCodexCredentials } = await import(
|
||||
'./codexCredentials.js?preserve-profile-id'
|
||||
)
|
||||
|
||||
const saved = saveCodexCredentials({
|
||||
accessToken: 'access-new',
|
||||
refreshToken: 'refresh-new',
|
||||
accountId: 'acct_new',
|
||||
})
|
||||
|
||||
expect(saved.success).toBe(true)
|
||||
expect(readCodexCredentials()?.profileId).toBe('profile_codex_oauth')
|
||||
})
|
||||
|
||||
test('attachCodexProfileIdToStoredCredentials links the saved profile id', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
|
||||
let storageState: Record<string, unknown> = {
|
||||
codex: {
|
||||
accessToken: 'access-old',
|
||||
refreshToken: 'refresh-old',
|
||||
accountId: 'acct_old',
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: () => ({
|
||||
read: () => storageState,
|
||||
readAsync: async () => storageState,
|
||||
update: (next: Record<string, unknown>) => {
|
||||
storageState = next
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const {
|
||||
attachCodexProfileIdToStoredCredentials,
|
||||
readCodexCredentials,
|
||||
} = await import('./codexCredentials.js?attach-profile-id')
|
||||
|
||||
const result =
|
||||
attachCodexProfileIdToStoredCredentials('profile_codex_oauth')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(readCodexCredentials()?.profileId).toBe('profile_codex_oauth')
|
||||
})
|
||||
|
||||
test('refreshCodexAccessTokenIfNeeded uses async secure-storage reads in its request path', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CODEX_API_KEY
|
||||
|
||||
const freshToken = makeJwt({
|
||||
exp: Math.floor((Date.now() + 3_600_000) / 1000),
|
||||
chatgpt_account_id: 'acct_async',
|
||||
})
|
||||
|
||||
let storageState: Record<string, unknown> = {
|
||||
codex: {
|
||||
accessToken: freshToken,
|
||||
refreshToken: 'refresh-async',
|
||||
accountId: 'acct_async',
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: () => ({
|
||||
read: () => {
|
||||
throw new Error(
|
||||
'sync storage read should not run during refresh checks',
|
||||
)
|
||||
},
|
||||
readAsync: async () => storageState,
|
||||
update: (next: Record<string, unknown>) => {
|
||||
storageState = next
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { refreshCodexAccessTokenIfNeeded } = await import(
|
||||
'./codexCredentials.js?refresh-async-read'
|
||||
)
|
||||
|
||||
const result = await refreshCodexAccessTokenIfNeeded()
|
||||
expect(result.refreshed).toBe(false)
|
||||
expect(result.credentials?.accessToken).toBe(freshToken)
|
||||
})
|
||||
|
||||
test('refreshCodexAccessTokenIfNeeded keeps a cooldown in memory when secure storage cannot persist it', async () => {
|
||||
delete process.env.CLAUDE_CODE_SIMPLE
|
||||
delete process.env.CODEX_API_KEY
|
||||
|
||||
const expiredToken = makeJwt({
|
||||
exp: Math.floor((Date.now() - 60_000) / 1000),
|
||||
chatgpt_account_id: 'acct_old',
|
||||
})
|
||||
|
||||
const storageState: Record<string, unknown> = {
|
||||
codex: {
|
||||
accessToken: expiredToken,
|
||||
refreshToken: 'refresh-old',
|
||||
accountId: 'acct_old',
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('./secureStorage/index.js', () => ({
|
||||
getSecureStorage: () => ({
|
||||
read: () => storageState,
|
||||
readAsync: async () => storageState,
|
||||
update: () => ({
|
||||
success: false,
|
||||
warning: 'secure storage unavailable',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
let refreshAttempts = 0
|
||||
globalThis.fetch = mock(async () => {
|
||||
refreshAttempts += 1
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
code: 'invalid_grant',
|
||||
message: 'refresh token expired',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
)
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
// @ts-expect-error cache-busting query string for Bun module mocks
|
||||
const { refreshCodexAccessTokenIfNeeded } = await import(
|
||||
'./codexCredentials.js?refresh-memory-cooldown'
|
||||
)
|
||||
|
||||
await expect(refreshCodexAccessTokenIfNeeded()).rejects.toThrow(
|
||||
'Codex token refresh failed (invalid_grant): refresh token expired',
|
||||
)
|
||||
|
||||
const secondAttempt = await refreshCodexAccessTokenIfNeeded()
|
||||
expect(secondAttempt.refreshed).toBe(false)
|
||||
expect(secondAttempt.credentials?.accessToken).toBe(expiredToken)
|
||||
expect(refreshAttempts).toBe(1)
|
||||
})
|
||||
})
|
||||
375
src/utils/codexCredentials.ts
Normal file
375
src/utils/codexCredentials.ts
Normal file
@@ -0,0 +1,375 @@
|
||||
import { isBareMode } from './envUtils.js'
|
||||
import { getSecureStorage } from './secureStorage/index.js'
|
||||
import {
|
||||
asTrimmedString,
|
||||
CODEX_REFRESH_URL,
|
||||
exchangeCodexIdTokenForApiKey,
|
||||
getCodexOAuthClientId,
|
||||
parseChatgptAccountId,
|
||||
decodeJwtPayload,
|
||||
} from '../services/api/codexOAuthShared.js'
|
||||
|
||||
export const CODEX_STORAGE_KEY = 'codex' as const
|
||||
const CODEX_TOKEN_REFRESH_SKEW_MS = 60_000
|
||||
const CODEX_TOKEN_REFRESH_RETRY_COOLDOWN_MS = 60_000
|
||||
|
||||
export type CodexCredentialBlob = {
|
||||
apiKey?: string
|
||||
accessToken: string
|
||||
refreshToken?: string
|
||||
idToken?: string
|
||||
accountId?: string
|
||||
profileId?: string
|
||||
lastRefreshAt?: number
|
||||
lastRefreshFailureAt?: number
|
||||
}
|
||||
|
||||
type CodexTokenRefreshResponse = {
|
||||
access_token?: string
|
||||
refresh_token?: string
|
||||
id_token?: string
|
||||
}
|
||||
|
||||
let inFlightCodexRefresh:
|
||||
| Promise<{
|
||||
refreshed: boolean
|
||||
credentials?: CodexCredentialBlob
|
||||
}>
|
||||
| null = null
|
||||
let inMemoryLastRefreshFailureAt: number | null = null
|
||||
|
||||
function getCodexSecureStorage() {
|
||||
return getSecureStorage({ allowPlainTextFallback: false })
|
||||
}
|
||||
|
||||
function parseJwtExpiryMs(token: string | undefined): number | undefined {
|
||||
if (!token) return undefined
|
||||
const payload = decodeJwtPayload(token)
|
||||
const exp = payload?.exp
|
||||
if (typeof exp === 'number' && Number.isFinite(exp)) {
|
||||
return exp * 1000
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function normalizeCodexCredentialBlob(
|
||||
value: unknown,
|
||||
): CodexCredentialBlob | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
const apiKey = asTrimmedString(record.apiKey)
|
||||
const accessToken = asTrimmedString(record.accessToken)
|
||||
if (!accessToken) return undefined
|
||||
|
||||
const refreshToken = asTrimmedString(record.refreshToken)
|
||||
const idToken = asTrimmedString(record.idToken)
|
||||
const accountId =
|
||||
asTrimmedString(record.accountId) ??
|
||||
parseChatgptAccountId(idToken) ??
|
||||
parseChatgptAccountId(accessToken)
|
||||
const profileId = asTrimmedString(record.profileId)
|
||||
|
||||
const lastRefreshAt =
|
||||
typeof record.lastRefreshAt === 'number' &&
|
||||
Number.isFinite(record.lastRefreshAt)
|
||||
? record.lastRefreshAt
|
||||
: undefined
|
||||
const lastRefreshFailureAt =
|
||||
typeof record.lastRefreshFailureAt === 'number' &&
|
||||
Number.isFinite(record.lastRefreshFailureAt)
|
||||
? record.lastRefreshFailureAt
|
||||
: undefined
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
idToken,
|
||||
accountId,
|
||||
profileId,
|
||||
lastRefreshAt,
|
||||
lastRefreshFailureAt,
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRefreshCodexToken(blob: CodexCredentialBlob): boolean {
|
||||
const expiresAt =
|
||||
parseJwtExpiryMs(blob.accessToken) ?? parseJwtExpiryMs(blob.idToken)
|
||||
if (expiresAt === undefined) {
|
||||
return false
|
||||
}
|
||||
return expiresAt <= Date.now() + CODEX_TOKEN_REFRESH_SKEW_MS
|
||||
}
|
||||
|
||||
function isWithinRefreshFailureCooldown(
|
||||
blob: CodexCredentialBlob,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
const lastRefreshFailureAt = Math.max(
|
||||
blob.lastRefreshFailureAt ?? 0,
|
||||
inMemoryLastRefreshFailureAt ?? 0,
|
||||
)
|
||||
|
||||
if (!lastRefreshFailureAt) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
now - lastRefreshFailureAt < CODEX_TOKEN_REFRESH_RETRY_COOLDOWN_MS
|
||||
)
|
||||
}
|
||||
|
||||
function getRefreshErrorMessage(
|
||||
status: number,
|
||||
bodyText: string,
|
||||
): string {
|
||||
if (!bodyText.trim()) {
|
||||
return `Codex token refresh failed with status ${status}.`
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(bodyText) as Record<string, unknown>
|
||||
const nestedError =
|
||||
parsed.error && typeof parsed.error === 'object'
|
||||
? (parsed.error as Record<string, unknown>)
|
||||
: undefined
|
||||
const code = asTrimmedString(nestedError?.code ?? parsed.code)
|
||||
const message =
|
||||
asTrimmedString(nestedError?.message ?? parsed.error_description) ??
|
||||
bodyText.trim()
|
||||
return code
|
||||
? `Codex token refresh failed (${code}): ${message}`
|
||||
: `Codex token refresh failed with status ${status}: ${message}`
|
||||
} catch {
|
||||
return `Codex token refresh failed with status ${status}: ${bodyText.trim()}`
|
||||
}
|
||||
}
|
||||
|
||||
export function readCodexCredentials(): CodexCredentialBlob | undefined {
|
||||
if (isBareMode()) return undefined
|
||||
|
||||
try {
|
||||
const data = getCodexSecureStorage().read()
|
||||
return normalizeCodexCredentialBlob(data?.codex)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function readCodexCredentialsAsync(): Promise<
|
||||
CodexCredentialBlob | undefined
|
||||
> {
|
||||
if (isBareMode()) return undefined
|
||||
|
||||
try {
|
||||
const data = await getCodexSecureStorage().readAsync()
|
||||
return normalizeCodexCredentialBlob(data?.codex)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function isCodexRefreshFailureCoolingDown(
|
||||
blob: Pick<CodexCredentialBlob, 'lastRefreshFailureAt'>,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
return isWithinRefreshFailureCooldown(
|
||||
blob as CodexCredentialBlob,
|
||||
now,
|
||||
)
|
||||
}
|
||||
|
||||
export function saveCodexCredentials(
|
||||
credentials: CodexCredentialBlob,
|
||||
): { success: boolean; warning?: string } {
|
||||
if (isBareMode()) {
|
||||
return { success: false, warning: 'Bare mode: secure storage is disabled.' }
|
||||
}
|
||||
|
||||
const normalized = normalizeCodexCredentialBlob(credentials)
|
||||
if (!normalized) {
|
||||
return { success: false, warning: 'Codex credentials are incomplete.' }
|
||||
}
|
||||
|
||||
const secureStorage = getCodexSecureStorage()
|
||||
const previous = secureStorage.read() || {}
|
||||
const previousCodex = normalizeCodexCredentialBlob(previous[CODEX_STORAGE_KEY])
|
||||
const next = {
|
||||
...(previous as Record<string, unknown>),
|
||||
[CODEX_STORAGE_KEY]: {
|
||||
...normalized,
|
||||
profileId: normalized.profileId ?? previousCodex?.profileId,
|
||||
lastRefreshAt: normalized.lastRefreshAt ?? Date.now(),
|
||||
},
|
||||
}
|
||||
const result = secureStorage.update(next as typeof previous)
|
||||
if (result.success) {
|
||||
const storedCodex = normalizeCodexCredentialBlob(next[CODEX_STORAGE_KEY])
|
||||
inMemoryLastRefreshFailureAt = storedCodex?.lastRefreshFailureAt ?? null
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function attachCodexProfileIdToStoredCredentials(profileId: string): {
|
||||
success: boolean
|
||||
warning?: string
|
||||
} {
|
||||
if (isBareMode()) {
|
||||
return { success: false, warning: 'Bare mode: secure storage is disabled.' }
|
||||
}
|
||||
|
||||
const current = readCodexCredentials()
|
||||
if (!current) {
|
||||
return {
|
||||
success: false,
|
||||
warning: 'Codex credentials are not stored securely yet.',
|
||||
}
|
||||
}
|
||||
|
||||
return saveCodexCredentials({
|
||||
...current,
|
||||
profileId,
|
||||
})
|
||||
}
|
||||
|
||||
function persistCodexRefreshFailure(
|
||||
credentials: CodexCredentialBlob,
|
||||
occurredAt: number,
|
||||
): void {
|
||||
const result = saveCodexCredentials({
|
||||
...credentials,
|
||||
lastRefreshFailureAt: occurredAt,
|
||||
})
|
||||
if (!result.success) {
|
||||
inMemoryLastRefreshFailureAt = occurredAt
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCodexCredentials(): {
|
||||
success: boolean
|
||||
warning?: string
|
||||
} {
|
||||
if (isBareMode()) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const secureStorage = getCodexSecureStorage()
|
||||
const previous = secureStorage.read() || {}
|
||||
const next = { ...(previous as Record<string, unknown>) }
|
||||
delete next[CODEX_STORAGE_KEY]
|
||||
const result = secureStorage.update(next as typeof previous)
|
||||
if (result.success) {
|
||||
inMemoryLastRefreshFailureAt = null
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function refreshCodexAccessTokenIfNeeded(options?: {
|
||||
force?: boolean
|
||||
}): Promise<{
|
||||
refreshed: boolean
|
||||
credentials?: CodexCredentialBlob
|
||||
}> {
|
||||
if (isBareMode()) {
|
||||
return { refreshed: false }
|
||||
}
|
||||
|
||||
if (process.env.CODEX_API_KEY?.trim()) {
|
||||
return { refreshed: false }
|
||||
}
|
||||
|
||||
const current = await readCodexCredentialsAsync()
|
||||
if (!current) {
|
||||
return { refreshed: false }
|
||||
}
|
||||
|
||||
if (!current.refreshToken) {
|
||||
return { refreshed: false, credentials: current }
|
||||
}
|
||||
|
||||
if (!options?.force && !shouldRefreshCodexToken(current)) {
|
||||
return { refreshed: false, credentials: current }
|
||||
}
|
||||
|
||||
if (!options?.force && isWithinRefreshFailureCooldown(current)) {
|
||||
return { refreshed: false, credentials: current }
|
||||
}
|
||||
|
||||
if (inFlightCodexRefresh) {
|
||||
return inFlightCodexRefresh
|
||||
}
|
||||
|
||||
inFlightCodexRefresh = (async () => {
|
||||
const refreshAttemptedAt = Date.now()
|
||||
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
client_id: getCodexOAuthClientId(),
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: current.refreshToken,
|
||||
})
|
||||
|
||||
const response = await fetch(CODEX_REFRESH_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const bodyText = await response.text().catch(() => '')
|
||||
throw new Error(getRefreshErrorMessage(response.status, bodyText))
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as CodexTokenRefreshResponse
|
||||
const accessToken = asTrimmedString(payload.access_token)
|
||||
if (!accessToken) {
|
||||
throw new Error(
|
||||
'Codex token refresh succeeded without a new access token.',
|
||||
)
|
||||
}
|
||||
|
||||
const next: CodexCredentialBlob = {
|
||||
accessToken,
|
||||
refreshToken:
|
||||
asTrimmedString(payload.refresh_token) ?? current.refreshToken,
|
||||
idToken: asTrimmedString(payload.id_token) ?? current.idToken,
|
||||
accountId:
|
||||
parseChatgptAccountId(payload.id_token) ??
|
||||
parseChatgptAccountId(payload.access_token) ??
|
||||
current.accountId,
|
||||
lastRefreshAt: Date.now(),
|
||||
}
|
||||
|
||||
const idTokenForExchange = next.idToken ?? current.idToken
|
||||
if (idTokenForExchange) {
|
||||
next.apiKey = await exchangeCodexIdTokenForApiKey(
|
||||
idTokenForExchange,
|
||||
).catch(() => undefined)
|
||||
}
|
||||
|
||||
const saveResult = saveCodexCredentials(next)
|
||||
if (!saveResult.success) {
|
||||
throw new Error(
|
||||
saveResult.warning ??
|
||||
'Codex token refresh succeeded but credentials could not be saved.',
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
refreshed: true,
|
||||
credentials: next,
|
||||
}
|
||||
} catch (error) {
|
||||
persistCodexRefreshFailure(current, refreshAttemptedAt)
|
||||
throw error
|
||||
} finally {
|
||||
inFlightCodexRefresh = null
|
||||
}
|
||||
})()
|
||||
|
||||
return inFlightCodexRefresh
|
||||
}
|
||||
65
src/utils/effort.codex.test.ts
Normal file
65
src/utils/effort.codex.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { afterEach, expect, mock, test } from 'bun:test'
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
async function importFreshEffortModule(options: {
|
||||
provider: 'codex' | 'openai'
|
||||
supportsCodexReasoningEffort: boolean
|
||||
}) {
|
||||
mock.module('./model/providers.js', () => ({
|
||||
getAPIProvider: () => options.provider,
|
||||
}))
|
||||
mock.module('./model/modelSupportOverrides.js', () => ({
|
||||
get3PModelCapabilityOverride: () => undefined,
|
||||
}))
|
||||
mock.module('../services/api/providerConfig.js', () => ({
|
||||
supportsCodexReasoningEffort: () => options.supportsCodexReasoningEffort,
|
||||
}))
|
||||
|
||||
return import(`./effort.js?ts=${Date.now()}-${Math.random()}`)
|
||||
}
|
||||
|
||||
test('gpt-5.4 on the ChatGPT Codex backend supports effort selection', async () => {
|
||||
const { getAvailableEffortLevels, modelSupportsEffort } =
|
||||
await importFreshEffortModule({
|
||||
provider: 'codex',
|
||||
supportsCodexReasoningEffort: true,
|
||||
})
|
||||
|
||||
expect(modelSupportsEffort('gpt-5.4')).toBe(true)
|
||||
expect(getAvailableEffortLevels('gpt-5.4')).toEqual([
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
])
|
||||
})
|
||||
|
||||
test('gpt-5.4 on the OpenAI provider still supports effort selection', async () => {
|
||||
const { getAvailableEffortLevels, modelSupportsEffort } =
|
||||
await importFreshEffortModule({
|
||||
provider: 'openai',
|
||||
supportsCodexReasoningEffort: true,
|
||||
})
|
||||
|
||||
expect(modelSupportsEffort('gpt-5.4')).toBe(true)
|
||||
expect(getAvailableEffortLevels('gpt-5.4')).toEqual([
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
])
|
||||
})
|
||||
|
||||
test('gpt-5.3-codex-spark stays without effort controls', async () => {
|
||||
const { getAvailableEffortLevels, modelSupportsEffort } =
|
||||
await importFreshEffortModule({
|
||||
provider: 'codex',
|
||||
supportsCodexReasoningEffort: false,
|
||||
})
|
||||
|
||||
expect(modelSupportsEffort('gpt-5.3-codex-spark')).toBe(false)
|
||||
expect(getAvailableEffortLevels('gpt-5.3-codex-spark')).toEqual([])
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { isProSubscriber, isMaxSubscriber, isTeamSubscriber } from './auth.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
|
||||
import { getAPIProvider } from './model/providers.js'
|
||||
import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js'
|
||||
import { supportsCodexReasoningEffort } from '../services/api/providerConfig.js'
|
||||
import { isEnvTruthy } from './envUtils.js'
|
||||
import type { EffortLevel } from 'src/entrypoints/sdk/runtimeTypes.js'
|
||||
|
||||
@@ -37,6 +38,9 @@ export function modelSupportsEffort(model: string): boolean {
|
||||
if (supported3P !== undefined) {
|
||||
return supported3P
|
||||
}
|
||||
if (modelUsesOpenAIEffort(model) && supportsCodexReasoningEffort(model)) {
|
||||
return true
|
||||
}
|
||||
// Supported by a subset of Claude 4 models
|
||||
if (m.includes('opus-4-6') || m.includes('sonnet-4-6')) {
|
||||
return true
|
||||
@@ -86,6 +90,9 @@ export function modelUsesOpenAIEffort(model: string): boolean {
|
||||
}
|
||||
|
||||
export function getAvailableEffortLevels(model: string): EffortLevel[] | OpenAIEffortLevel[] {
|
||||
if (!modelSupportsEffort(model)) {
|
||||
return []
|
||||
}
|
||||
if (modelUsesOpenAIEffort(model)) {
|
||||
return [...OPENAI_EFFORT_LEVELS] as OpenAIEffortLevel[]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { DEFAULT_CODEX_BASE_URL } from '../services/api/providerConfig.ts'
|
||||
import {
|
||||
buildStartupEnvFromProfile,
|
||||
buildAtomicChatProfileEnv,
|
||||
@@ -12,7 +13,9 @@ import {
|
||||
buildLaunchEnv,
|
||||
buildOllamaProfileEnv,
|
||||
buildOpenAIProfileEnv,
|
||||
clearPersistedCodexOAuthProfile,
|
||||
createProfileFile,
|
||||
isPersistedCodexOAuthProfile,
|
||||
maskSecretForDisplay,
|
||||
loadProfileFile,
|
||||
PROFILE_FILE_NAME,
|
||||
@@ -23,6 +26,13 @@ import {
|
||||
type ProfileFile,
|
||||
} from './providerProfile.ts'
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' }))
|
||||
.toString('base64url')
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
return `${header}.${body}.signature`
|
||||
}
|
||||
|
||||
function profile(profile: ProfileFile['profile'], env: ProfileFile['env']): ProfileFile {
|
||||
return {
|
||||
profile,
|
||||
@@ -330,6 +340,7 @@ test('codex profiles accept explicit codex credentials', () => {
|
||||
assert.deepEqual(env, {
|
||||
OPENAI_BASE_URL: 'https://chatgpt.com/backend-api/codex',
|
||||
OPENAI_MODEL: 'codexspark',
|
||||
CODEX_CREDENTIAL_SOURCE: 'existing',
|
||||
CODEX_API_KEY: 'codex-live',
|
||||
CHATGPT_ACCOUNT_ID: 'acct_123',
|
||||
})
|
||||
@@ -417,6 +428,77 @@ test('saveProfileFile writes a profile that loadProfileFile can read back', () =
|
||||
}
|
||||
})
|
||||
|
||||
test('buildCodexProfileEnv tags OAuth-saved profiles so logout can remove them safely', () => {
|
||||
const env = buildCodexProfileEnv({
|
||||
model: 'codexplan',
|
||||
apiKey: makeJwt({
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'acct_oauth',
|
||||
},
|
||||
}),
|
||||
credentialSource: 'oauth',
|
||||
processEnv: {},
|
||||
})
|
||||
|
||||
assert.deepEqual(env, {
|
||||
OPENAI_BASE_URL: DEFAULT_CODEX_BASE_URL,
|
||||
OPENAI_MODEL: 'codexplan',
|
||||
CODEX_CREDENTIAL_SOURCE: 'oauth',
|
||||
CODEX_API_KEY: makeJwt({
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_account_id: 'acct_oauth',
|
||||
},
|
||||
}),
|
||||
CHATGPT_ACCOUNT_ID: 'acct_oauth',
|
||||
})
|
||||
})
|
||||
|
||||
test('clearPersistedCodexOAuthProfile removes only persisted Codex OAuth profiles', async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'openclaude-codex-oauth-profile-'))
|
||||
|
||||
try {
|
||||
const providerProfileModule = await import(
|
||||
`./providerProfile.ts?ts=${Date.now()}-${Math.random()}`
|
||||
)
|
||||
const {
|
||||
PROFILE_FILE_NAME,
|
||||
clearPersistedCodexOAuthProfile,
|
||||
createProfileFile,
|
||||
isPersistedCodexOAuthProfile,
|
||||
loadProfileFile,
|
||||
saveProfileFile,
|
||||
} = providerProfileModule
|
||||
const oauthProfile = createProfileFile('codex', {
|
||||
OPENAI_MODEL: 'codexplan',
|
||||
OPENAI_BASE_URL: DEFAULT_CODEX_BASE_URL,
|
||||
CHATGPT_ACCOUNT_ID: 'acct_oauth',
|
||||
CODEX_CREDENTIAL_SOURCE: 'oauth',
|
||||
})
|
||||
saveProfileFile(oauthProfile, { cwd })
|
||||
|
||||
assert.equal(isPersistedCodexOAuthProfile(loadProfileFile({ cwd })), true)
|
||||
assert.equal(
|
||||
clearPersistedCodexOAuthProfile({ cwd }),
|
||||
join(cwd, PROFILE_FILE_NAME),
|
||||
)
|
||||
assert.equal(loadProfileFile({ cwd }), null)
|
||||
|
||||
const existingCredentialProfile = createProfileFile('codex', {
|
||||
OPENAI_MODEL: 'codexplan',
|
||||
OPENAI_BASE_URL: DEFAULT_CODEX_BASE_URL,
|
||||
CHATGPT_ACCOUNT_ID: 'acct_existing',
|
||||
CODEX_CREDENTIAL_SOURCE: 'existing',
|
||||
})
|
||||
saveProfileFile(existingCredentialProfile, { cwd })
|
||||
|
||||
assert.equal(isPersistedCodexOAuthProfile(loadProfileFile({ cwd })), false)
|
||||
assert.equal(clearPersistedCodexOAuthProfile({ cwd }), null)
|
||||
assert.deepEqual(loadProfileFile({ cwd }), existingCredentialProfile)
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('buildStartupEnvFromProfile applies persisted gemini settings when no provider is explicitly selected', async () => {
|
||||
const env = await buildStartupEnvFromProfile({
|
||||
persisted: profile('gemini', {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
resolveCodexApiCredentials,
|
||||
resolveProviderRequest,
|
||||
} from '../services/api/providerConfig.ts'
|
||||
import { parseChatgptAccountId } from '../services/api/codexOAuthShared.js'
|
||||
import {
|
||||
getGoalDefaultOpenAIModel,
|
||||
normalizeRecommendationGoal,
|
||||
@@ -14,6 +15,20 @@ import {
|
||||
} from './providerRecommendation.ts'
|
||||
import { readGeminiAccessToken } from './geminiCredentials.ts'
|
||||
import { getOllamaChatBaseUrl } from './providerDiscovery.ts'
|
||||
import { getProviderValidationError } from './providerValidation.ts'
|
||||
import {
|
||||
maskSecretForDisplay,
|
||||
redactSecretValueForDisplay,
|
||||
sanitizeApiKey,
|
||||
sanitizeProviderConfigValue,
|
||||
} from './providerSecrets.ts'
|
||||
|
||||
export {
|
||||
maskSecretForDisplay,
|
||||
redactSecretValueForDisplay,
|
||||
sanitizeApiKey,
|
||||
sanitizeProviderConfigValue,
|
||||
} from './providerSecrets.ts'
|
||||
|
||||
export const PROFILE_FILE_NAME = '.openclaude-profile.json'
|
||||
export const DEFAULT_GEMINI_BASE_URL =
|
||||
@@ -33,6 +48,7 @@ const PROFILE_ENV_KEYS = [
|
||||
'OPENAI_MODEL',
|
||||
'OPENAI_API_KEY',
|
||||
'CODEX_API_KEY',
|
||||
'CODEX_CREDENTIAL_SOURCE',
|
||||
'CHATGPT_ACCOUNT_ID',
|
||||
'CODEX_ACCOUNT_ID',
|
||||
'GEMINI_API_KEY',
|
||||
@@ -46,21 +62,20 @@ const PROFILE_ENV_KEYS = [
|
||||
'MISTRAL_MODEL',
|
||||
] as const
|
||||
|
||||
const SECRET_ENV_KEYS = [
|
||||
'OPENAI_API_KEY',
|
||||
'CODEX_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'GOOGLE_API_KEY',
|
||||
'MISTRAL_API_KEY',
|
||||
] as const
|
||||
|
||||
export type ProviderProfile = 'openai' | 'ollama' | 'codex' | 'gemini' | 'atomic-chat' | 'mistral'
|
||||
export type ProviderProfile =
|
||||
| 'openai'
|
||||
| 'ollama'
|
||||
| 'codex'
|
||||
| 'gemini'
|
||||
| 'atomic-chat'
|
||||
| 'mistral'
|
||||
|
||||
export type ProfileEnv = {
|
||||
OPENAI_BASE_URL?: string
|
||||
OPENAI_MODEL?: string
|
||||
OPENAI_API_KEY?: string
|
||||
CODEX_API_KEY?: string
|
||||
CODEX_CREDENTIAL_SOURCE?: 'oauth' | 'existing'
|
||||
CHATGPT_ACCOUNT_ID?: string
|
||||
CODEX_ACCOUNT_ID?: string
|
||||
GEMINI_API_KEY?: string
|
||||
@@ -78,13 +93,6 @@ export type ProfileFile = {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type SecretValueSource = Partial<
|
||||
Pick<
|
||||
NodeJS.ProcessEnv & ProfileEnv,
|
||||
(typeof SECRET_ENV_KEYS)[number]
|
||||
>
|
||||
>
|
||||
|
||||
type ProfileFileLocation = {
|
||||
cwd?: string
|
||||
filePath?: string
|
||||
@@ -109,102 +117,6 @@ export function isProviderProfile(value: unknown): value is ProviderProfile {
|
||||
)
|
||||
}
|
||||
|
||||
export function sanitizeApiKey(
|
||||
key: string | null | undefined,
|
||||
): string | undefined {
|
||||
if (!key || key === 'SUA_CHAVE') return undefined
|
||||
return key
|
||||
}
|
||||
|
||||
function looksLikeSecretValue(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return false
|
||||
|
||||
if (trimmed.startsWith('sk-') || trimmed.startsWith('sk-ant-')) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('AIza')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function collectSecretValues(
|
||||
sources: Array<SecretValueSource | null | undefined>,
|
||||
): string[] {
|
||||
const values = new Set<string>()
|
||||
|
||||
for (const source of sources) {
|
||||
if (!source) continue
|
||||
|
||||
for (const key of SECRET_ENV_KEYS) {
|
||||
const value = sanitizeApiKey(source[key])
|
||||
if (value) {
|
||||
values.add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...values]
|
||||
}
|
||||
|
||||
export function maskSecretForDisplay(
|
||||
value: string | null | undefined,
|
||||
): string | undefined {
|
||||
const sanitized = sanitizeApiKey(value)
|
||||
if (!sanitized) return undefined
|
||||
|
||||
if (sanitized.length <= 8) {
|
||||
return 'configured'
|
||||
}
|
||||
|
||||
if (sanitized.startsWith('sk-')) {
|
||||
return `${sanitized.slice(0, 3)}...${sanitized.slice(-4)}`
|
||||
}
|
||||
|
||||
if (sanitized.startsWith('AIza')) {
|
||||
return `${sanitized.slice(0, 4)}...${sanitized.slice(-4)}`
|
||||
}
|
||||
|
||||
return `${sanitized.slice(0, 2)}...${sanitized.slice(-4)}`
|
||||
}
|
||||
|
||||
export function redactSecretValueForDisplay(
|
||||
value: string | null | undefined,
|
||||
...sources: Array<SecretValueSource | null | undefined>
|
||||
): string | undefined {
|
||||
if (!value) return undefined
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return trimmed
|
||||
|
||||
const secretValues = collectSecretValues(sources)
|
||||
if (secretValues.includes(trimmed) || looksLikeSecretValue(trimmed)) {
|
||||
return maskSecretForDisplay(trimmed) ?? 'configured'
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function sanitizeProviderConfigValue(
|
||||
value: string | null | undefined,
|
||||
...sources: Array<SecretValueSource | null | undefined>
|
||||
): string | undefined {
|
||||
if (!value) return undefined
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
|
||||
const secretValues = collectSecretValues(sources)
|
||||
if (secretValues.includes(trimmed) || looksLikeSecretValue(trimmed)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function buildOllamaProfileEnv(
|
||||
model: string,
|
||||
options: {
|
||||
@@ -335,6 +247,7 @@ export function buildCodexProfileEnv(options: {
|
||||
model?: string | null
|
||||
baseUrl?: string | null
|
||||
apiKey?: string | null
|
||||
credentialSource?: 'oauth' | 'existing'
|
||||
processEnv?: NodeJS.ProcessEnv
|
||||
}): ProfileEnv | null {
|
||||
const processEnv = options.processEnv ?? process.env
|
||||
@@ -346,10 +259,14 @@ export function buildCodexProfileEnv(options: {
|
||||
if (!credentials.apiKey || !credentials.accountId) {
|
||||
return null
|
||||
}
|
||||
const credentialSource =
|
||||
options.credentialSource ??
|
||||
(credentials.source === 'secure-storage' ? 'oauth' : 'existing')
|
||||
|
||||
const env: ProfileEnv = {
|
||||
OPENAI_BASE_URL: options.baseUrl || DEFAULT_CODEX_BASE_URL,
|
||||
OPENAI_MODEL: options.model || 'codexplan',
|
||||
CODEX_CREDENTIAL_SOURCE: credentialSource,
|
||||
}
|
||||
|
||||
if (key) {
|
||||
@@ -399,6 +316,30 @@ export function buildMistralProfileEnv(options: {
|
||||
return env
|
||||
}
|
||||
|
||||
export function buildCodexOAuthProfileEnv(
|
||||
tokens: {
|
||||
accessToken: string
|
||||
idToken?: string
|
||||
accountId?: string
|
||||
},
|
||||
): ProfileEnv | null {
|
||||
const accountId =
|
||||
tokens.accountId ??
|
||||
parseChatgptAccountId(tokens.idToken) ??
|
||||
parseChatgptAccountId(tokens.accessToken)
|
||||
|
||||
if (!accountId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
OPENAI_BASE_URL: DEFAULT_CODEX_BASE_URL,
|
||||
OPENAI_MODEL: 'codexplan',
|
||||
CHATGPT_ACCOUNT_ID: accountId,
|
||||
CODEX_CREDENTIAL_SOURCE: 'oauth',
|
||||
}
|
||||
}
|
||||
|
||||
export function createProfileFile(
|
||||
profile: ProviderProfile,
|
||||
env: ProfileEnv,
|
||||
@@ -410,6 +351,26 @@ export function createProfileFile(
|
||||
}
|
||||
}
|
||||
|
||||
export function isPersistedCodexOAuthProfile(
|
||||
persisted: ProfileFile | null,
|
||||
): boolean {
|
||||
return (
|
||||
persisted?.profile === 'codex' &&
|
||||
persisted.env.CODEX_CREDENTIAL_SOURCE === 'oauth'
|
||||
)
|
||||
}
|
||||
|
||||
export function clearPersistedCodexOAuthProfile(
|
||||
options?: ProfileFileLocation,
|
||||
): string | null {
|
||||
const persisted = loadProfileFile(options)
|
||||
if (!isPersistedCodexOAuthProfile(persisted)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return deleteProfileFile(options)
|
||||
}
|
||||
|
||||
export function loadProfileFile(options?: ProfileFileLocation): ProfileFile | null {
|
||||
const filePath = resolveProfileFilePath(options)
|
||||
if (!existsSync(filePath)) {
|
||||
@@ -545,6 +506,7 @@ export async function buildLaunchEnv(options: {
|
||||
|
||||
delete env.CLAUDE_CODE_USE_OPENAI
|
||||
delete env.CLAUDE_CODE_USE_GITHUB
|
||||
delete env.CODEX_CREDENTIAL_SOURCE
|
||||
|
||||
env.GEMINI_MODEL =
|
||||
shellGeminiModel ||
|
||||
@@ -668,6 +630,7 @@ export async function buildLaunchEnv(options: {
|
||||
delete env.CLAUDE_CODE_USE_FOUNDRY
|
||||
delete env.CLAUDE_CODE_USE_GEMINI
|
||||
delete env.CLAUDE_CODE_USE_GITHUB
|
||||
delete env.CODEX_CREDENTIAL_SOURCE
|
||||
delete env.GEMINI_API_KEY
|
||||
delete env.GEMINI_AUTH_MODE
|
||||
delete env.GEMINI_ACCESS_TOKEN
|
||||
@@ -838,3 +801,40 @@ export function applyProfileEnvToProcessEnv(
|
||||
|
||||
Object.assign(targetEnv, nextEnv)
|
||||
}
|
||||
|
||||
export async function applySavedProfileToCurrentSession(options: {
|
||||
profileFile: ProfileFile
|
||||
processEnv?: NodeJS.ProcessEnv
|
||||
}): Promise<string | null> {
|
||||
const processEnv = options.processEnv ?? process.env
|
||||
const baseEnv = { ...processEnv }
|
||||
const isCodexOAuthProfile =
|
||||
options.profileFile.profile === 'codex' &&
|
||||
options.profileFile.env.CODEX_CREDENTIAL_SOURCE === 'oauth'
|
||||
|
||||
delete baseEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
|
||||
delete baseEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
|
||||
if (isCodexOAuthProfile) {
|
||||
delete baseEnv.CODEX_API_KEY
|
||||
delete baseEnv.CODEX_ACCOUNT_ID
|
||||
delete baseEnv.CHATGPT_ACCOUNT_ID
|
||||
}
|
||||
|
||||
const nextEnv = await buildLaunchEnv({
|
||||
profile: options.profileFile.profile,
|
||||
persisted: options.profileFile,
|
||||
goal: normalizeRecommendationGoal(processEnv.OPENCLAUDE_PROFILE_GOAL),
|
||||
processEnv: baseEnv,
|
||||
getOllamaChatBaseUrl,
|
||||
readGeminiAccessToken,
|
||||
})
|
||||
const validationError = await getProviderValidationError(nextEnv)
|
||||
if (validationError) {
|
||||
return validationError
|
||||
}
|
||||
|
||||
delete processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED
|
||||
delete processEnv.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID
|
||||
applyProfileEnvToProcessEnv(processEnv, nextEnv)
|
||||
return null
|
||||
}
|
||||
|
||||
107
src/utils/providerSecrets.ts
Normal file
107
src/utils/providerSecrets.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
const SECRET_ENV_KEYS = [
|
||||
'OPENAI_API_KEY',
|
||||
'CODEX_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'GOOGLE_API_KEY',
|
||||
'MISTRAL_API_KEY',
|
||||
] as const
|
||||
|
||||
export type SecretValueSource = Partial<
|
||||
Record<(typeof SECRET_ENV_KEYS)[number], string | undefined>
|
||||
>
|
||||
|
||||
export function sanitizeApiKey(
|
||||
key: string | null | undefined,
|
||||
): string | undefined {
|
||||
if (!key || key === 'SUA_CHAVE') return undefined
|
||||
return key
|
||||
}
|
||||
|
||||
function looksLikeSecretValue(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return false
|
||||
|
||||
if (trimmed.startsWith('sk-') || trimmed.startsWith('sk-ant-')) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('AIza')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function collectSecretValues(
|
||||
sources: Array<SecretValueSource | null | undefined>,
|
||||
): string[] {
|
||||
const values = new Set<string>()
|
||||
|
||||
for (const source of sources) {
|
||||
if (!source) continue
|
||||
|
||||
for (const key of SECRET_ENV_KEYS) {
|
||||
const value = sanitizeApiKey(source[key])
|
||||
if (value) {
|
||||
values.add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...values]
|
||||
}
|
||||
|
||||
export function maskSecretForDisplay(
|
||||
value: string | null | undefined,
|
||||
): string | undefined {
|
||||
const sanitized = sanitizeApiKey(value)
|
||||
if (!sanitized) return undefined
|
||||
|
||||
if (sanitized.length <= 8) {
|
||||
return 'configured'
|
||||
}
|
||||
|
||||
if (sanitized.startsWith('sk-')) {
|
||||
return `${sanitized.slice(0, 3)}...${sanitized.slice(-4)}`
|
||||
}
|
||||
|
||||
if (sanitized.startsWith('AIza')) {
|
||||
return `${sanitized.slice(0, 4)}...${sanitized.slice(-4)}`
|
||||
}
|
||||
|
||||
return `${sanitized.slice(0, 2)}...${sanitized.slice(-4)}`
|
||||
}
|
||||
|
||||
export function redactSecretValueForDisplay(
|
||||
value: string | null | undefined,
|
||||
...sources: Array<SecretValueSource | null | undefined>
|
||||
): string | undefined {
|
||||
if (!value) return undefined
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return trimmed
|
||||
|
||||
const secretValues = collectSecretValues(sources)
|
||||
if (secretValues.includes(trimmed) || looksLikeSecretValue(trimmed)) {
|
||||
return maskSecretForDisplay(trimmed) ?? 'configured'
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function sanitizeProviderConfigValue(
|
||||
value: string | null | undefined,
|
||||
...sources: Array<SecretValueSource | null | undefined>
|
||||
): string | undefined {
|
||||
if (!value) return undefined
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
|
||||
const secretValues = collectSecretValues(sources)
|
||||
if (secretValues.includes(trimmed) || looksLikeSecretValue(trimmed)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
resolveProviderRequest,
|
||||
} from '../services/api/providerConfig.js'
|
||||
import { getGlobalClaudeFile } from './env.js'
|
||||
import { isBareMode } from './envUtils.js'
|
||||
import {
|
||||
type GeminiResolvedCredential,
|
||||
resolveGeminiCredential,
|
||||
} from './geminiAuth.js'
|
||||
import { PROFILE_FILE_NAME, redactSecretValueForDisplay } from './providerProfile.js'
|
||||
import { PROFILE_FILE_NAME } from './providerProfile.js'
|
||||
import { redactSecretValueForDisplay } from './providerSecrets.js'
|
||||
|
||||
function isEnvTruthy(value: string | undefined): boolean {
|
||||
if (!value) return false
|
||||
@@ -82,6 +84,7 @@ export async function getProviderValidationError(
|
||||
) => Promise<GeminiResolvedCredential>
|
||||
},
|
||||
): Promise<string | null> {
|
||||
const secretSource = env
|
||||
const useOpenAI = isEnvTruthy(env.CLAUDE_CODE_USE_OPENAI)
|
||||
const useGithub = isEnvTruthy(env.CLAUDE_CODE_USE_GITHUB)
|
||||
|
||||
@@ -131,16 +134,17 @@ export async function getProviderValidationError(
|
||||
if (request.transport === 'codex_responses') {
|
||||
const credentials = resolveCodexApiCredentials(env)
|
||||
if (!credentials.apiKey) {
|
||||
const oauthHint = isBareMode() ? '' : ', choose Codex OAuth in /provider'
|
||||
const authHint = credentials.authPath
|
||||
? ` or put auth.json at ${credentials.authPath}`
|
||||
: ''
|
||||
? `${oauthHint} or put auth.json at ${credentials.authPath}`
|
||||
: oauthHint
|
||||
const safeModel =
|
||||
redactSecretValueForDisplay(request.requestedModel, env) ??
|
||||
redactSecretValueForDisplay(request.requestedModel, secretSource) ??
|
||||
'the requested model'
|
||||
return `Codex auth is required for ${safeModel}. Set CODEX_API_KEY${authHint}.`
|
||||
}
|
||||
if (!credentials.accountId) {
|
||||
return 'Codex auth is missing chatgpt_account_id. Re-login with Codex or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.'
|
||||
return 'Codex auth is missing chatgpt_account_id. Re-login with Codex OAuth, Codex CLI, or set CHATGPT_ACCOUNT_ID/CODEX_ACCOUNT_ID.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -5,6 +5,16 @@ import { windowsCredentialStorage } from './windowsCredentialStorage.js'
|
||||
import { plainTextStorage } from './plainTextStorage.js'
|
||||
|
||||
export interface SecureStorageData {
|
||||
codex?: {
|
||||
apiKey?: string
|
||||
accessToken: string
|
||||
refreshToken?: string
|
||||
idToken?: string
|
||||
accountId?: string
|
||||
profileId?: string
|
||||
lastRefreshAt?: number
|
||||
lastRefreshFailureAt?: number
|
||||
}
|
||||
mcpOAuth?: Record<
|
||||
string,
|
||||
{
|
||||
@@ -36,22 +46,44 @@ export interface SecureStorage {
|
||||
delete(): boolean
|
||||
}
|
||||
|
||||
const unavailableSecureStorage: SecureStorage = {
|
||||
name: 'unavailable-secure-storage',
|
||||
read: () => null,
|
||||
readAsync: async () => null,
|
||||
update: () => ({
|
||||
success: false,
|
||||
warning:
|
||||
'Secure storage is unavailable on this platform without plaintext fallback.',
|
||||
}),
|
||||
delete: () => true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate secure storage implementation for the current platform.
|
||||
* Prefers native OS vaults (Keychain, libsecret, Credential Locker) with a plaintext fallback.
|
||||
*/
|
||||
export function getSecureStorage(): SecureStorage {
|
||||
export function getSecureStorage(options?: {
|
||||
allowPlainTextFallback?: boolean
|
||||
}): SecureStorage {
|
||||
const allowPlainTextFallback = options?.allowPlainTextFallback ?? true
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
return createFallbackStorage(macOsKeychainStorage, plainTextStorage)
|
||||
return allowPlainTextFallback
|
||||
? createFallbackStorage(macOsKeychainStorage, plainTextStorage)
|
||||
: macOsKeychainStorage
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
return createFallbackStorage(linuxSecretStorage, plainTextStorage)
|
||||
return allowPlainTextFallback
|
||||
? createFallbackStorage(linuxSecretStorage, plainTextStorage)
|
||||
: linuxSecretStorage
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return createFallbackStorage(windowsCredentialStorage, plainTextStorage)
|
||||
return allowPlainTextFallback
|
||||
? createFallbackStorage(windowsCredentialStorage, plainTextStorage)
|
||||
: windowsCredentialStorage
|
||||
}
|
||||
|
||||
return plainTextStorage
|
||||
return allowPlainTextFallback ? plainTextStorage : unavailableSecureStorage
|
||||
}
|
||||
|
||||
@@ -64,8 +64,10 @@ describe("Secure Storage Platform Implementations", () => {
|
||||
windowsCredentialStorage.update(testData);
|
||||
|
||||
const script = mockExecaSync.mock.calls[0][1][1];
|
||||
const options = mockExecaSync.mock.calls[0][2];
|
||||
expect(script).toContain(expectedName);
|
||||
expect(script).toContain("Add-Type -AssemblyName System.Runtime.WindowsRuntime");
|
||||
expect(script).toContain("ProtectedData");
|
||||
expect(options.input).toContain("secret-token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,32 +87,54 @@ describe("Secure Storage Platform Implementations", () => {
|
||||
windowsCredentialStorage.update(dataWithDollar);
|
||||
|
||||
const script = mockExecaSync.mock.calls[0][1][1];
|
||||
// Should use single quotes for the payload
|
||||
expect(script).toMatch(/'\{.*\}'/);
|
||||
// Should escape ' by doubling it
|
||||
expect(script).not.toContain("'token-with-$env:USERNAME'");
|
||||
// But since it's JSON, the value will be "token-with-$env:USERNAME" inside the single-quoted string
|
||||
// The JSON itself shouldn't have single quotes unless the data has them.
|
||||
const options = mockExecaSync.mock.calls[0][2];
|
||||
expect(script).toContain("[Console]::In.ReadToEnd()");
|
||||
expect(options.input).toContain("token-with-$env:USERNAME");
|
||||
|
||||
const dataWithQuote = { mcpOAuth: { "s": { accessToken: "token'quote", expiresAt: 1, serverName: "s", serverUrl: "u" } } };
|
||||
windowsCredentialStorage.update(dataWithQuote);
|
||||
const script2 = mockExecaSync.mock.calls[1][1][1];
|
||||
expect(script2).toContain("token''quote");
|
||||
const options2 = mockExecaSync.mock.calls[1][2];
|
||||
expect(options2.input).toContain("token'quote");
|
||||
});
|
||||
|
||||
test("delete() includes assembly load", () => {
|
||||
windowsCredentialStorage.delete();
|
||||
const script = mockExecaSync.mock.calls[0][1][1];
|
||||
const script = mockExecaSync.mock.calls[1][1][1];
|
||||
expect(script).toContain("Add-Type -AssemblyName System.Runtime.WindowsRuntime");
|
||||
});
|
||||
|
||||
test("escapes double quotes in username", () => {
|
||||
process.env.USER = 'user"name';
|
||||
windowsCredentialStorage.read();
|
||||
const script = mockExecaSync.mock.calls[0][1][1];
|
||||
const script = mockExecaSync.mock.calls[1][1][1];
|
||||
expect(script).toContain('user`"name');
|
||||
expect(script).not.toContain('user"name');
|
||||
});
|
||||
|
||||
test("read() falls back to legacy PasswordVault when the DPAPI payload is invalid JSON", () => {
|
||||
mockExecaSync
|
||||
.mockImplementationOnce(() => ({ exitCode: 0, stdout: "{not-json" }))
|
||||
.mockImplementationOnce(() => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify(testData),
|
||||
}));
|
||||
|
||||
const result = windowsCredentialStorage.read();
|
||||
|
||||
expect(result).toEqual(testData);
|
||||
expect(mockExecaSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("read() fails closed when the legacy PasswordVault payload is invalid JSON", () => {
|
||||
mockExecaSync
|
||||
.mockImplementationOnce(() => ({ exitCode: 1, stdout: "" }))
|
||||
.mockImplementationOnce(() => ({ exitCode: 0, stdout: "{not-json" }));
|
||||
|
||||
const result = windowsCredentialStorage.read();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockExecaSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Linux secret-tool Interaction", () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { execaSync } from 'execa'
|
||||
import { join } from 'path'
|
||||
import { getClaudeConfigHomeDir } from '../envUtils.js'
|
||||
import { jsonParse, jsonStringify } from '../slowOperations.js'
|
||||
import {
|
||||
CREDENTIALS_SERVICE_SUFFIX,
|
||||
@@ -8,90 +10,216 @@ import {
|
||||
import type { SecureStorage, SecureStorageData } from './index.js'
|
||||
|
||||
/**
|
||||
* Windows-specific secure storage implementation using the Windows Credential Locker.
|
||||
* Accessed via PowerShell's [Windows.Security.Credentials.PasswordVault].
|
||||
* Windows-specific secure storage implementation using DPAPI for new writes,
|
||||
* with best-effort reads/deletes from the legacy PasswordVault path.
|
||||
*/
|
||||
export const windowsCredentialStorage: SecureStorage = {
|
||||
name: 'credential-locker',
|
||||
read(): SecureStorageData | null {
|
||||
const resourceName = getSecureStorageServiceName(
|
||||
CREDENTIALS_SERVICE_SUFFIX,
|
||||
).replace(/"/g, '`"')
|
||||
const username = getUsername().replace(/"/g, '`"')
|
||||
// PowerShell script to retrieve password from vault
|
||||
const script = `
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
function escapePowerShellSingleQuoted(value: string): string {
|
||||
return value.replace(/'/g, "''")
|
||||
}
|
||||
|
||||
function getLegacyResourceName(): string {
|
||||
return getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX)
|
||||
}
|
||||
|
||||
function getWindowsSecureStorageEntropy(): string {
|
||||
return `${getLegacyResourceName()}:${getUsername()}`
|
||||
}
|
||||
|
||||
function getWindowsSecureStorageFilePath(): string {
|
||||
const resourceName = getLegacyResourceName().replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
return join(getClaudeConfigHomeDir(), `${resourceName}.secure.dpapi`)
|
||||
}
|
||||
|
||||
function runPowerShell(
|
||||
script: string,
|
||||
options?: { input?: string },
|
||||
): ReturnType<typeof execaSync> | null {
|
||||
try {
|
||||
return execaSync('powershell.exe', ['-Command', script], {
|
||||
input: options?.input,
|
||||
reject: false,
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getFailureWarning(
|
||||
result: ReturnType<typeof execaSync> | null,
|
||||
fallback: string,
|
||||
): string {
|
||||
const stderr = result?.stderr?.trim()
|
||||
if (stderr) {
|
||||
return stderr
|
||||
}
|
||||
|
||||
if (typeof result?.exitCode === 'number' && result.exitCode !== 0) {
|
||||
return `${fallback} (exit code ${result.exitCode}).`
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function readLegacyPasswordVault(): SecureStorageData | null {
|
||||
const resourceName = getLegacyResourceName().replace(/"/g, '`"')
|
||||
const username = getUsername().replace(/"/g, '`"')
|
||||
const script = `
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
try {
|
||||
$vault = New-Object Windows.Security.Credentials.PasswordVault
|
||||
$cred = $vault.Retrieve("${resourceName}", "${username}")
|
||||
$cred.FillPassword()
|
||||
[Console]::Out.Write($cred.Password)
|
||||
} catch {
|
||||
exit 1
|
||||
}
|
||||
`
|
||||
|
||||
const result = runPowerShell(script)
|
||||
if (result?.exitCode === 0 && result.stdout) {
|
||||
try {
|
||||
return jsonParse(result.stdout)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const windowsCredentialStorage: SecureStorage = {
|
||||
name: 'credential-locker-dpapi',
|
||||
read(): SecureStorageData | null {
|
||||
const filePath = escapePowerShellSingleQuoted(
|
||||
getWindowsSecureStorageFilePath(),
|
||||
)
|
||||
const entropy = escapePowerShellSingleQuoted(
|
||||
getWindowsSecureStorageEntropy(),
|
||||
)
|
||||
const script = `
|
||||
try {
|
||||
$cred = $vault.Retrieve("${resourceName}", "${username}")
|
||||
$cred.FillPassword()
|
||||
$cred.Password
|
||||
Add-Type -AssemblyName System.Security
|
||||
$path = '${filePath}'
|
||||
if (!(Test-Path -LiteralPath $path)) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
$protectedBase64 = [System.IO.File]::ReadAllText(
|
||||
$path,
|
||||
[System.Text.Encoding]::UTF8
|
||||
).Trim()
|
||||
if (-not $protectedBase64) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
$protectedBytes = [Convert]::FromBase64String($protectedBase64)
|
||||
$entropyBytes = [System.Text.Encoding]::UTF8.GetBytes('${entropy}')
|
||||
$bytes = [System.Security.Cryptography.ProtectedData]::Unprotect(
|
||||
$protectedBytes,
|
||||
$entropyBytes,
|
||||
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
|
||||
)
|
||||
[Console]::Out.Write([System.Text.Encoding]::UTF8.GetString($bytes))
|
||||
} catch {
|
||||
exit 1
|
||||
}
|
||||
`
|
||||
try {
|
||||
const result = execaSync('powershell.exe', ['-Command', script], {
|
||||
reject: false,
|
||||
})
|
||||
if (result.exitCode === 0 && result.stdout) {
|
||||
|
||||
const result = runPowerShell(script)
|
||||
if (result?.exitCode === 0 && result.stdout) {
|
||||
try {
|
||||
return jsonParse(result.stdout)
|
||||
} catch {
|
||||
return readLegacyPasswordVault()
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return null
|
||||
|
||||
return readLegacyPasswordVault()
|
||||
},
|
||||
async readAsync(): Promise<SecureStorageData | null> {
|
||||
return this.read()
|
||||
},
|
||||
update(data: SecureStorageData): { success: boolean; warning?: string } {
|
||||
const resourceName = getSecureStorageServiceName(
|
||||
CREDENTIALS_SERVICE_SUFFIX,
|
||||
).replace(/"/g, '`"')
|
||||
const username = getUsername().replace(/"/g, '`"')
|
||||
// Use single quotes for the payload and escape ' by doubling it ('').
|
||||
// This prevents PowerShell from expanding $... inside the string.
|
||||
const payload = jsonStringify(data).replace(/'/g, "''")
|
||||
// PowerShell script to add/update credential in vault
|
||||
const filePath = escapePowerShellSingleQuoted(
|
||||
getWindowsSecureStorageFilePath(),
|
||||
)
|
||||
const entropy = escapePowerShellSingleQuoted(
|
||||
getWindowsSecureStorageEntropy(),
|
||||
)
|
||||
const payload = jsonStringify(data)
|
||||
const script = `
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
$vault = New-Object Windows.Security.Credentials.PasswordVault
|
||||
$cred = New-Object Windows.Security.Credentials.PasswordCredential("${resourceName}", "${username}", '${payload}')
|
||||
$vault.Add($cred)
|
||||
try {
|
||||
Add-Type -AssemblyName System.Security
|
||||
$path = '${filePath}'
|
||||
$directory = [System.IO.Path]::GetDirectoryName($path)
|
||||
if ($directory) {
|
||||
[System.IO.Directory]::CreateDirectory($directory) | Out-Null
|
||||
}
|
||||
|
||||
$payload = [Console]::In.ReadToEnd()
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($payload)
|
||||
$entropyBytes = [System.Text.Encoding]::UTF8.GetBytes('${entropy}')
|
||||
$protectedBytes = [System.Security.Cryptography.ProtectedData]::Protect(
|
||||
$bytes,
|
||||
$entropyBytes,
|
||||
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
|
||||
)
|
||||
$protectedBase64 = [Convert]::ToBase64String($protectedBytes)
|
||||
[System.IO.File]::WriteAllText(
|
||||
$path,
|
||||
$protectedBase64,
|
||||
[System.Text.Encoding]::UTF8
|
||||
)
|
||||
} catch {
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
}
|
||||
`
|
||||
try {
|
||||
const result = execaSync('powershell.exe', ['-Command', script], {
|
||||
reject: false,
|
||||
})
|
||||
return { success: result.exitCode === 0 }
|
||||
} catch {
|
||||
return { success: false }
|
||||
const result = runPowerShell(script, { input: payload })
|
||||
if (result?.exitCode === 0) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
warning: getFailureWarning(
|
||||
result,
|
||||
'Windows secure storage could not encrypt credentials with DPAPI',
|
||||
),
|
||||
}
|
||||
},
|
||||
delete(): boolean {
|
||||
const resourceName = getSecureStorageServiceName(
|
||||
CREDENTIALS_SERVICE_SUFFIX,
|
||||
).replace(/"/g, '`"')
|
||||
const username = getUsername().replace(/"/g, '`"')
|
||||
// PowerShell script to remove credential from vault
|
||||
const script = `
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
$vault = New-Object Windows.Security.Credentials.PasswordVault
|
||||
const filePath = escapePowerShellSingleQuoted(
|
||||
getWindowsSecureStorageFilePath(),
|
||||
)
|
||||
const removeDpapiScript = `
|
||||
try {
|
||||
$path = '${filePath}'
|
||||
if (Test-Path -LiteralPath $path) {
|
||||
Remove-Item -LiteralPath $path -Force
|
||||
}
|
||||
} catch {
|
||||
exit 1
|
||||
}
|
||||
`
|
||||
const removeDpapiResult = runPowerShell(removeDpapiScript)
|
||||
|
||||
const resourceName = getLegacyResourceName().replace(/"/g, '`"')
|
||||
const username = getUsername().replace(/"/g, '`"')
|
||||
const removeLegacyScript = `
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
try {
|
||||
$vault = New-Object Windows.Security.Credentials.PasswordVault
|
||||
$cred = $vault.Retrieve("${resourceName}", "${username}")
|
||||
$vault.Remove($cred)
|
||||
} catch {
|
||||
exit 0
|
||||
}
|
||||
`
|
||||
try {
|
||||
const result = execaSync('powershell.exe', ['-Command', script], {
|
||||
reject: false,
|
||||
})
|
||||
return result.exitCode === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const removeLegacyResult = runPowerShell(removeLegacyScript)
|
||||
|
||||
void removeLegacyResult
|
||||
|
||||
return (removeDpapiResult?.exitCode ?? 1) === 0
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user