feat(auth): add remote API control config to gate third-party providers
This commit is contained in:
parent
a1dca02b55
commit
25ea4a0cf3
|
|
@ -1,6 +1,7 @@
|
|||
import type { Command } from '../commands.js'
|
||||
import type { LocalCommandCall } from '../types/command.js'
|
||||
import { getAPIProvider } from '../utils/model/providers.js'
|
||||
import { isThirdPartyApiEnabled } from '../services/apiControlConfig/index.js'
|
||||
import { updateSettingsForSource } from '../utils/settings/settings.js'
|
||||
import { getSettings_DEPRECATED } from '../utils/settings/settings.js'
|
||||
import { applyConfigEnvironmentVariables } from '../utils/managedEnv.js'
|
||||
|
|
@ -85,16 +86,11 @@ const call: LocalCommandCall = async (args, _context) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Validate provider
|
||||
const validProviders = [
|
||||
'anthropic',
|
||||
'openai',
|
||||
'gemini',
|
||||
'grok',
|
||||
'bedrock',
|
||||
'vertex',
|
||||
'foundry',
|
||||
]
|
||||
// Validate provider — dynamically filter based on remote API control config
|
||||
const thirdPartyEnabled = isThirdPartyApiEnabled()
|
||||
const validProviders = thirdPartyEnabled
|
||||
? ['anthropic', 'openai', 'gemini', 'grok', 'bedrock', 'vertex', 'foundry']
|
||||
: []
|
||||
if (!validProviders.includes(arg)) {
|
||||
return {
|
||||
type: 'text',
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { Spinner } from './Spinner.js'
|
|||
import TextInput from './TextInput.js'
|
||||
import { useSetAppState } from '../state/AppState.js'
|
||||
import { fi } from 'zod/v4/locales'
|
||||
import { isThirdPartyApiEnabled } from '../services/apiControlConfig/index.js'
|
||||
|
||||
type Props = {
|
||||
onDone(): void
|
||||
|
|
@ -100,6 +101,13 @@ export function ConsoleOAuthFlow({
|
|||
return { state: 'ready_to_start' }
|
||||
}
|
||||
if (forceLoginMethod === 'claudeai' || forceLoginMethod === 'console') {
|
||||
// When third-party APIs are disabled by remote config, ignore
|
||||
// forceLoginMethod and show the idle screen (CoStrict only).
|
||||
// This prevents bypassing the isThirdPartyApiEnabled() gate
|
||||
// through settings.forceLoginMethod.
|
||||
if (!isThirdPartyApiEnabled()) {
|
||||
return { state: 'idle' }
|
||||
}
|
||||
return { state: 'ready_to_start' }
|
||||
}
|
||||
return { state: 'idle' }
|
||||
|
|
@ -416,6 +424,7 @@ export function ConsoleOAuthFlow({
|
|||
setOAuthStatus={setOAuthStatus}
|
||||
setLoginWithClaudeAi={setLoginWithClaudeAi}
|
||||
onDone={onDone}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -437,6 +446,7 @@ type OAuthStatusMessageProps = {
|
|||
handleSubmitCode: (value: string, url: string) => void
|
||||
setOAuthStatus: (status: OAuthStatus) => void
|
||||
setLoginWithClaudeAi: (value: boolean) => void
|
||||
setAppState: ReturnType<typeof useSetAppState>
|
||||
}
|
||||
|
||||
function OAuthStatusMessage({
|
||||
|
|
@ -454,10 +464,61 @@ function OAuthStatusMessage({
|
|||
setOAuthStatus,
|
||||
setLoginWithClaudeAi,
|
||||
onDone,
|
||||
setAppState,
|
||||
}: OAuthStatusMessageProps): React.ReactNode {
|
||||
const setAppState = useSetAppState()
|
||||
switch (oauthStatus.state) {
|
||||
case 'idle':
|
||||
case 'idle': {
|
||||
const thirdPartyEnabled = isThirdPartyApiEnabled()
|
||||
const loginOptions: Array<{ label: React.ReactNode; value: string }> = [
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
CoStrict ·{' '}
|
||||
<Text dimColor>Sign in with CoStrict account</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'costrict',
|
||||
},
|
||||
]
|
||||
|
||||
if (thirdPartyEnabled) {
|
||||
loginOptions.push(
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
Anthropic Compatible ·{' '}
|
||||
<Text dimColor>Configure your own API endpoint</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'custom_platform',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
OpenAI Compatible ·{' '}
|
||||
<Text dimColor>
|
||||
Ollama, DeepSeek, vLLM, One API, etc.
|
||||
</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'openai_chat_api',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
Gemini API ·{' '}
|
||||
<Text dimColor>Google Gemini native REST/SSE</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'gemini_api',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||
<Text bold>
|
||||
|
|
@ -470,50 +531,7 @@ function OAuthStatusMessage({
|
|||
|
||||
<Box>
|
||||
<Select
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
CoStrict ·{' '}
|
||||
<Text dimColor>Sign in with CoStrict account</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'costrict',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
Anthropic Compatible ·{' '}
|
||||
<Text dimColor>Configure your own API endpoint</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'custom_platform',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
OpenAI Compatible ·{' '}
|
||||
<Text dimColor>
|
||||
Ollama, DeepSeek, vLLM, One API, etc.
|
||||
</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'openai_chat_api',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Text>
|
||||
Gemini API ·{' '}
|
||||
<Text dimColor>Google Gemini native REST/SSE</Text>
|
||||
{'\n'}
|
||||
</Text>
|
||||
),
|
||||
value: 'gemini_api',
|
||||
},
|
||||
]}
|
||||
options={loginOptions}
|
||||
onChange={value => {
|
||||
if (value === 'costrict') {
|
||||
void (async () => {
|
||||
|
|
@ -565,12 +583,11 @@ function OAuthStatusMessage({
|
|||
return
|
||||
}
|
||||
} catch {
|
||||
// 预取失败,直接进入 success
|
||||
}
|
||||
|
||||
setOAuthStatus({ state: 'success' })
|
||||
void onDone()
|
||||
} catch (err: any) {
|
||||
// 预取失败,直接进入 success
|
||||
}
|
||||
|
||||
setOAuthStatus({ state: 'success' })
|
||||
} catch (err: any) {
|
||||
setOAuthStatus({
|
||||
state: 'error',
|
||||
message: err.message || String(err),
|
||||
|
|
@ -617,6 +634,7 @@ function OAuthStatusMessage({
|
|||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
case 'custom_platform':
|
||||
{
|
||||
|
|
@ -739,7 +757,6 @@ function OAuthStatusMessage({
|
|||
// Reset AppState model so logo reflects the new provider default
|
||||
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
|
||||
setOAuthStatus({ state: 'success' })
|
||||
void onDone()
|
||||
}
|
||||
}, [activeField, inputValue, displayValues, setOAuthStatus, onDone])
|
||||
|
||||
|
|
@ -965,7 +982,6 @@ function OAuthStatusMessage({
|
|||
// Reset AppState model so logo reflects the new provider default
|
||||
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
|
||||
setOAuthStatus({ state: 'success' })
|
||||
void onDone()
|
||||
}
|
||||
}, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone])
|
||||
|
||||
|
|
@ -1204,7 +1220,6 @@ function OAuthStatusMessage({
|
|||
// Reset AppState model so logo reflects the new provider default
|
||||
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
|
||||
setOAuthStatus({ state: 'success' })
|
||||
void onDone()
|
||||
}
|
||||
}, [activeField, geminiInputValue, geminiDisplayValues, onDone, setOAuthStatus])
|
||||
|
||||
|
|
@ -1371,7 +1386,6 @@ function OAuthStatusMessage({
|
|||
updateSettingsForSource('userSettings', { model: value } as any)
|
||||
setAppState(prev => ({ ...prev, mainLoopModel: value, mainLoopModelForSession: null }));
|
||||
setOAuthStatus({ state: 'success' });
|
||||
void onDone();
|
||||
}}
|
||||
onCancel={() => {
|
||||
const selected = sortedModels[0]?.id ?? '';
|
||||
|
|
@ -1379,7 +1393,6 @@ function OAuthStatusMessage({
|
|||
updateSettingsForSource('userSettings', { model: selected } as any)
|
||||
setAppState(prev => ({ ...prev, mainLoopModel: selected, mainLoopModelForSession: null }));
|
||||
setOAuthStatus({ state: 'success' });
|
||||
void onDone();
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ import { setShellIfWindows } from '../utils/windowsPaths.js'
|
|||
import { initSentry } from '../utils/sentry.js'
|
||||
import { initUser } from '../utils/user.js'
|
||||
import { initLangfuse, shutdownLangfuse } from '../services/langfuse/index.js'
|
||||
import {
|
||||
checkApiControlConfig,
|
||||
disableNonCoStrictProviders,
|
||||
} from '../services/apiControlConfig/index.js'
|
||||
import { setThemeConfigCallbacks } from '@anthropic/ink'
|
||||
|
||||
// initialize1PEventLogging is dynamically imported to defer OpenTelemetry sdk-logs/resources
|
||||
|
|
@ -172,6 +176,20 @@ export const init = memoize(async (): Promise<void> => {
|
|||
logForDebugging('[init] configureGlobalAgents complete')
|
||||
profileCheckpoint('init_network_configured')
|
||||
|
||||
// Check remote API control config — if enabled_api is false, disable
|
||||
// third-party providers (OpenAI, Gemini, Grok, Anthropic) so only
|
||||
// CoStrict remains available.
|
||||
try {
|
||||
const result = await checkApiControlConfig()
|
||||
if (result.enabled_api === false) {
|
||||
disableNonCoStrictProviders()
|
||||
}
|
||||
} catch (err) {
|
||||
logForDebugging(
|
||||
`[init] API control config check failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Initialize Sentry for error reporting (no-op if SENTRY_DSN not set)
|
||||
initSentry()
|
||||
|
||||
|
|
|
|||
106
src/services/apiControlConfig/index.ts
Normal file
106
src/services/apiControlConfig/index.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { getCoStrictBaseURL } from '../../costrict/provider/auth.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import {
|
||||
getSettings_DEPRECATED,
|
||||
updateSettingsForSource,
|
||||
} from '../../utils/settings/settings.js'
|
||||
|
||||
let _apiEnabled: boolean | null = null
|
||||
|
||||
export async function checkApiControlConfig(): Promise<{
|
||||
enabled_api: boolean
|
||||
}> {
|
||||
|
||||
const baseUrl = getCoStrictBaseURL()
|
||||
const url = `${baseUrl}/costrict-static/cli-api-control/config.json`
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal })
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (response.status !== 200) {
|
||||
logForDebugging(
|
||||
`API control config: non-200 status (${response.status}) from ${url}, disabling third-party APIs`,
|
||||
)
|
||||
_apiEnabled = false
|
||||
return { enabled_api: false }
|
||||
}
|
||||
|
||||
let data: unknown
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch {
|
||||
logForDebugging(
|
||||
`API control config: JSON parse failed from ${url}, disabling third-party APIs`,
|
||||
)
|
||||
_apiEnabled = false
|
||||
return { enabled_api: false }
|
||||
}
|
||||
|
||||
const enabled =
|
||||
data !== null &&
|
||||
typeof data === 'object' &&
|
||||
'enabled_api' in (data as Record<string, unknown>) &&
|
||||
(data as Record<string, unknown>).enabled_api === true
|
||||
|
||||
_apiEnabled = enabled
|
||||
logForDebugging(
|
||||
`API control config: fetched from ${url}, status=${response.status}, enabled_api=${enabled}`,
|
||||
)
|
||||
return { enabled_api: enabled }
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId)
|
||||
const reason = err instanceof Error ? err.message : String(err)
|
||||
logForDebugging(
|
||||
`API control config: request failed for ${url}: ${reason}, disabling third-party APIs`,
|
||||
)
|
||||
_apiEnabled = false
|
||||
return { enabled_api: false }
|
||||
}
|
||||
}
|
||||
|
||||
export function disableNonCoStrictProviders(): void {
|
||||
// Clear all third-party provider environment variables
|
||||
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||
delete process.env.CLAUDE_CODE_USE_GEMINI
|
||||
delete process.env.CLAUDE_CODE_USE_GROK
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY
|
||||
|
||||
// Force CoStrict as the only available provider
|
||||
process.env.CLAUDE_CODE_USE_COSTRICT = '1'
|
||||
|
||||
const settings = getSettings_DEPRECATED()
|
||||
const modelType = settings.modelType as string | undefined
|
||||
|
||||
// Clear any non-CoStrict modelType from settings
|
||||
if (
|
||||
modelType !== undefined &&
|
||||
modelType !== 'costrict'
|
||||
) {
|
||||
logForDebugging(
|
||||
`API control config: clearing modelType '${modelType}' from userSettings`,
|
||||
)
|
||||
updateSettingsForSource('userSettings', { modelType: undefined })
|
||||
}
|
||||
|
||||
// Clear forceLoginMethod — prevents bypassing the idle login screen
|
||||
// when third-party APIs are disabled. If forceLoginMethod were left as
|
||||
// 'claudeai' or 'console', ConsoleOAuthFlow would skip the idle state
|
||||
// and go directly to Anthropic OAuth, bypassing isThirdPartyApiEnabled().
|
||||
if (settings.forceLoginMethod) {
|
||||
logForDebugging(
|
||||
`API control config: clearing forceLoginMethod '${settings.forceLoginMethod}' from userSettings`,
|
||||
)
|
||||
updateSettingsForSource('userSettings', { forceLoginMethod: undefined } as any)
|
||||
}
|
||||
}
|
||||
|
||||
export function isThirdPartyApiEnabled(): boolean {
|
||||
return _apiEnabled === true
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user