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 { Command } from '../commands.js'
|
||||||
import type { LocalCommandCall } from '../types/command.js'
|
import type { LocalCommandCall } from '../types/command.js'
|
||||||
import { getAPIProvider } from '../utils/model/providers.js'
|
import { getAPIProvider } from '../utils/model/providers.js'
|
||||||
|
import { isThirdPartyApiEnabled } from '../services/apiControlConfig/index.js'
|
||||||
import { updateSettingsForSource } from '../utils/settings/settings.js'
|
import { updateSettingsForSource } from '../utils/settings/settings.js'
|
||||||
import { getSettings_DEPRECATED } from '../utils/settings/settings.js'
|
import { getSettings_DEPRECATED } from '../utils/settings/settings.js'
|
||||||
import { applyConfigEnvironmentVariables } from '../utils/managedEnv.js'
|
import { applyConfigEnvironmentVariables } from '../utils/managedEnv.js'
|
||||||
|
|
@ -85,16 +86,11 @@ const call: LocalCommandCall = async (args, _context) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate provider
|
// Validate provider — dynamically filter based on remote API control config
|
||||||
const validProviders = [
|
const thirdPartyEnabled = isThirdPartyApiEnabled()
|
||||||
'anthropic',
|
const validProviders = thirdPartyEnabled
|
||||||
'openai',
|
? ['anthropic', 'openai', 'gemini', 'grok', 'bedrock', 'vertex', 'foundry']
|
||||||
'gemini',
|
: []
|
||||||
'grok',
|
|
||||||
'bedrock',
|
|
||||||
'vertex',
|
|
||||||
'foundry',
|
|
||||||
]
|
|
||||||
if (!validProviders.includes(arg)) {
|
if (!validProviders.includes(arg)) {
|
||||||
return {
|
return {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { Spinner } from './Spinner.js'
|
||||||
import TextInput from './TextInput.js'
|
import TextInput from './TextInput.js'
|
||||||
import { useSetAppState } from '../state/AppState.js'
|
import { useSetAppState } from '../state/AppState.js'
|
||||||
import { fi } from 'zod/v4/locales'
|
import { fi } from 'zod/v4/locales'
|
||||||
|
import { isThirdPartyApiEnabled } from '../services/apiControlConfig/index.js'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onDone(): void
|
onDone(): void
|
||||||
|
|
@ -100,6 +101,13 @@ export function ConsoleOAuthFlow({
|
||||||
return { state: 'ready_to_start' }
|
return { state: 'ready_to_start' }
|
||||||
}
|
}
|
||||||
if (forceLoginMethod === 'claudeai' || forceLoginMethod === 'console') {
|
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: 'ready_to_start' }
|
||||||
}
|
}
|
||||||
return { state: 'idle' }
|
return { state: 'idle' }
|
||||||
|
|
@ -416,6 +424,7 @@ export function ConsoleOAuthFlow({
|
||||||
setOAuthStatus={setOAuthStatus}
|
setOAuthStatus={setOAuthStatus}
|
||||||
setLoginWithClaudeAi={setLoginWithClaudeAi}
|
setLoginWithClaudeAi={setLoginWithClaudeAi}
|
||||||
onDone={onDone}
|
onDone={onDone}
|
||||||
|
setAppState={setAppState}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -437,6 +446,7 @@ type OAuthStatusMessageProps = {
|
||||||
handleSubmitCode: (value: string, url: string) => void
|
handleSubmitCode: (value: string, url: string) => void
|
||||||
setOAuthStatus: (status: OAuthStatus) => void
|
setOAuthStatus: (status: OAuthStatus) => void
|
||||||
setLoginWithClaudeAi: (value: boolean) => void
|
setLoginWithClaudeAi: (value: boolean) => void
|
||||||
|
setAppState: ReturnType<typeof useSetAppState>
|
||||||
}
|
}
|
||||||
|
|
||||||
function OAuthStatusMessage({
|
function OAuthStatusMessage({
|
||||||
|
|
@ -454,10 +464,61 @@ function OAuthStatusMessage({
|
||||||
setOAuthStatus,
|
setOAuthStatus,
|
||||||
setLoginWithClaudeAi,
|
setLoginWithClaudeAi,
|
||||||
onDone,
|
onDone,
|
||||||
|
setAppState,
|
||||||
}: OAuthStatusMessageProps): React.ReactNode {
|
}: OAuthStatusMessageProps): React.ReactNode {
|
||||||
const setAppState = useSetAppState()
|
|
||||||
switch (oauthStatus.state) {
|
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 (
|
return (
|
||||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||||
<Text bold>
|
<Text bold>
|
||||||
|
|
@ -470,50 +531,7 @@ function OAuthStatusMessage({
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Select
|
<Select
|
||||||
options={[
|
options={loginOptions}
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onChange={value => {
|
onChange={value => {
|
||||||
if (value === 'costrict') {
|
if (value === 'costrict') {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|
@ -565,12 +583,11 @@ function OAuthStatusMessage({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 预取失败,直接进入 success
|
// 预取失败,直接进入 success
|
||||||
}
|
}
|
||||||
|
|
||||||
setOAuthStatus({ state: 'success' })
|
setOAuthStatus({ state: 'success' })
|
||||||
void onDone()
|
} catch (err: any) {
|
||||||
} catch (err: any) {
|
|
||||||
setOAuthStatus({
|
setOAuthStatus({
|
||||||
state: 'error',
|
state: 'error',
|
||||||
message: err.message || String(err),
|
message: err.message || String(err),
|
||||||
|
|
@ -617,6 +634,7 @@ function OAuthStatusMessage({
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
case 'custom_platform':
|
case 'custom_platform':
|
||||||
{
|
{
|
||||||
|
|
@ -739,7 +757,6 @@ function OAuthStatusMessage({
|
||||||
// Reset AppState model so logo reflects the new provider default
|
// Reset AppState model so logo reflects the new provider default
|
||||||
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
|
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
|
||||||
setOAuthStatus({ state: 'success' })
|
setOAuthStatus({ state: 'success' })
|
||||||
void onDone()
|
|
||||||
}
|
}
|
||||||
}, [activeField, inputValue, displayValues, setOAuthStatus, onDone])
|
}, [activeField, inputValue, displayValues, setOAuthStatus, onDone])
|
||||||
|
|
||||||
|
|
@ -965,7 +982,6 @@ function OAuthStatusMessage({
|
||||||
// Reset AppState model so logo reflects the new provider default
|
// Reset AppState model so logo reflects the new provider default
|
||||||
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
|
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
|
||||||
setOAuthStatus({ state: 'success' })
|
setOAuthStatus({ state: 'success' })
|
||||||
void onDone()
|
|
||||||
}
|
}
|
||||||
}, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone])
|
}, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone])
|
||||||
|
|
||||||
|
|
@ -1204,7 +1220,6 @@ function OAuthStatusMessage({
|
||||||
// Reset AppState model so logo reflects the new provider default
|
// Reset AppState model so logo reflects the new provider default
|
||||||
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
|
setAppState(prev => ({ ...prev, mainLoopModel: null, mainLoopModelForSession: null }))
|
||||||
setOAuthStatus({ state: 'success' })
|
setOAuthStatus({ state: 'success' })
|
||||||
void onDone()
|
|
||||||
}
|
}
|
||||||
}, [activeField, geminiInputValue, geminiDisplayValues, onDone, setOAuthStatus])
|
}, [activeField, geminiInputValue, geminiDisplayValues, onDone, setOAuthStatus])
|
||||||
|
|
||||||
|
|
@ -1371,7 +1386,6 @@ function OAuthStatusMessage({
|
||||||
updateSettingsForSource('userSettings', { model: value } as any)
|
updateSettingsForSource('userSettings', { model: value } as any)
|
||||||
setAppState(prev => ({ ...prev, mainLoopModel: value, mainLoopModelForSession: null }));
|
setAppState(prev => ({ ...prev, mainLoopModel: value, mainLoopModelForSession: null }));
|
||||||
setOAuthStatus({ state: 'success' });
|
setOAuthStatus({ state: 'success' });
|
||||||
void onDone();
|
|
||||||
}}
|
}}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
const selected = sortedModels[0]?.id ?? '';
|
const selected = sortedModels[0]?.id ?? '';
|
||||||
|
|
@ -1379,7 +1393,6 @@ function OAuthStatusMessage({
|
||||||
updateSettingsForSource('userSettings', { model: selected } as any)
|
updateSettingsForSource('userSettings', { model: selected } as any)
|
||||||
setAppState(prev => ({ ...prev, mainLoopModel: selected, mainLoopModelForSession: null }));
|
setAppState(prev => ({ ...prev, mainLoopModel: selected, mainLoopModelForSession: null }));
|
||||||
setOAuthStatus({ state: 'success' });
|
setOAuthStatus({ state: 'success' });
|
||||||
void onDone();
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,10 @@ import { setShellIfWindows } from '../utils/windowsPaths.js'
|
||||||
import { initSentry } from '../utils/sentry.js'
|
import { initSentry } from '../utils/sentry.js'
|
||||||
import { initUser } from '../utils/user.js'
|
import { initUser } from '../utils/user.js'
|
||||||
import { initLangfuse, shutdownLangfuse } from '../services/langfuse/index.js'
|
import { initLangfuse, shutdownLangfuse } from '../services/langfuse/index.js'
|
||||||
|
import {
|
||||||
|
checkApiControlConfig,
|
||||||
|
disableNonCoStrictProviders,
|
||||||
|
} from '../services/apiControlConfig/index.js'
|
||||||
import { setThemeConfigCallbacks } from '@anthropic/ink'
|
import { setThemeConfigCallbacks } from '@anthropic/ink'
|
||||||
|
|
||||||
// initialize1PEventLogging is dynamically imported to defer OpenTelemetry sdk-logs/resources
|
// 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')
|
logForDebugging('[init] configureGlobalAgents complete')
|
||||||
profileCheckpoint('init_network_configured')
|
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)
|
// Initialize Sentry for error reporting (no-op if SENTRY_DSN not set)
|
||||||
initSentry()
|
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