feat(config): improve agent and WebUI configuration security
- add WebUI management key settings - generate strong per-install management secrets - rotate the legacy 123456 management key on upgrade - preserve custom management keys - restore Codex configuration without a state JSON file
This commit is contained in:
1 parent
d8f73cb067
commit
88baeab512
8 files changed
+1226
-167
No files matched your search
Generated
+1
@@ -588,6 +588,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"getrandom 0.3.4",
|
||||
"json5",
|
||||
"libc",
|
||||
"notify",
|
||||
|
||||
@@ -13,6 +13,7 @@ base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
flate2 = "1"
|
||||
futures-util = "0.3"
|
||||
getrandom = "0.3"
|
||||
json5 = "0.4"
|
||||
notify = "7"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
|
||||
+663
-133
File diff suppressed because it is too large.
Load diff
@@ -264,6 +264,24 @@ export const jaOverrides = {
|
||||
'config.notice.sessionAffinityEnabled': 'セッションアフィニティを有効にしました',
|
||||
'config.notice.sessionAffinityDisabled': 'セッションアフィニティを無効にしました',
|
||||
'config.notice.sessionTtlUpdated': 'セッションアフィニティ TTL を保存しました',
|
||||
'config.webuiKey.title': 'WebUI キー',
|
||||
'config.webuiKey.configured': '設定済み',
|
||||
'config.webuiKey.unconfigured': '未設定',
|
||||
'config.webuiKey.heading': '管理 API のアクセス認証情報',
|
||||
'config.webuiKey.description': 'CPA WebUI へのログインと管理 API の呼び出しに使用します。上の API 認証キーとは用途が異なります。',
|
||||
'config.webuiKey.securityHint': '現在のキーは表示されません。保存後、他の WebUI では新しいキーで再ログインしてください。',
|
||||
'config.webuiKey.newKey': '新しい WebUI キー',
|
||||
'config.webuiKey.confirmKey': '新しいキーを確認',
|
||||
'config.webuiKey.placeholder': '新しい平文キーを入力',
|
||||
'config.webuiKey.confirmPlaceholder': '新しいキーをもう一度入力',
|
||||
'config.webuiKey.generate': '安全なキーを生成',
|
||||
'config.webuiKey.save': 'キーを保存',
|
||||
'config.webuiKey.error.empty': 'WebUI キーを入力してください',
|
||||
'config.webuiKey.error.legacyDefault': '旧版の既定キー 123456 は使用できません',
|
||||
'config.webuiKey.error.tooLong': 'WebUI キーは 512 文字以内で入力してください',
|
||||
'config.webuiKey.error.invalid': 'WebUI キーに制御文字は使用できません',
|
||||
'config.webuiKey.error.mismatch': '入力した WebUI キーが一致しません',
|
||||
'config.webuiKey.notice.updated': 'WebUI キーを更新しました',
|
||||
'config.keys.title': '認証キー',
|
||||
'config.keys.count': 'キー数',
|
||||
'config.keys.add': '認証キーを追加',
|
||||
@@ -520,6 +538,7 @@ export const jaOverrides = {
|
||||
'agents.oauthLoginRequired.launchDescription': 'OAuth 設定方式が有効ですが、有効な Codex のログイン認証情報が見つかりません。起動する前に Codex で ChatGPT アカウントにログインしてください。',
|
||||
'agents.oauthLoginRequired.confirm': '了解しました',
|
||||
'agents.modify.apply': '設定変更を適用',
|
||||
'agents.modify.close': '設定変更を終了',
|
||||
'agents.modify.default': 'デフォルト設定',
|
||||
'agents.modify.clear': '設定を消去',
|
||||
'agents.modify.invalid': '現在の設定を解析できません。「設定変更を適用」は最小の有効な設定を再構築し、「デフォルト設定」はファイル全体をリセットします。',
|
||||
|
||||
@@ -282,6 +282,24 @@ export const zhCN = {
|
||||
'config.notice.sessionAffinityEnabled': '会话粘性路由已启用',
|
||||
'config.notice.sessionAffinityDisabled': '会话粘性路由已停用',
|
||||
'config.notice.sessionTtlUpdated': '会话粘性 TTL 已保存',
|
||||
'config.webuiKey.title': 'WebUI 密钥',
|
||||
'config.webuiKey.configured': '已配置',
|
||||
'config.webuiKey.unconfigured': '未配置',
|
||||
'config.webuiKey.heading': '管理接口访问凭据',
|
||||
'config.webuiKey.description': '用于登录 CPA WebUI 和调用管理接口,与上方 API 鉴权密钥用途不同。',
|
||||
'config.webuiKey.securityHint': '现有密钥不会回显;保存后,其他 WebUI 需要使用新密钥重新登录。',
|
||||
'config.webuiKey.newKey': '新 WebUI 密钥',
|
||||
'config.webuiKey.confirmKey': '确认新密钥',
|
||||
'config.webuiKey.placeholder': '输入新的明文密钥',
|
||||
'config.webuiKey.confirmPlaceholder': '再次输入新密钥',
|
||||
'config.webuiKey.generate': '生成安全密钥',
|
||||
'config.webuiKey.save': '保存密钥',
|
||||
'config.webuiKey.error.empty': 'WebUI 密钥不能为空',
|
||||
'config.webuiKey.error.legacyDefault': '不能继续使用旧版默认密钥 123456',
|
||||
'config.webuiKey.error.tooLong': 'WebUI 密钥不能超过 512 个字符',
|
||||
'config.webuiKey.error.invalid': 'WebUI 密钥不能包含控制字符',
|
||||
'config.webuiKey.error.mismatch': '两次输入的 WebUI 密钥不一致',
|
||||
'config.webuiKey.notice.updated': 'WebUI 密钥已更新',
|
||||
'config.keys.title': '鉴权密钥',
|
||||
'config.keys.count': '密钥数量',
|
||||
'config.keys.add': '新增鉴权密钥',
|
||||
@@ -435,6 +453,7 @@ export const zhCN = {
|
||||
'agents.error.modelsLoading': '模型列表仍在加载,请稍后再试',
|
||||
'agents.error.noModels': '当前没有可选模型,无法应用配置修改',
|
||||
'agents.error.selectionGone': '当前选择已不在可用模型列表中,请刷新模型后重新选择',
|
||||
'agents.error.mappingSelectionGone': 'Claude Desktop 模型映射包含不可用模型,请刷新模型后重新选择',
|
||||
'agents.error.applyFirst': '模型选择尚未应用,请先点击“应用配置修改”',
|
||||
'agents.status.detecting': '检测中',
|
||||
'agents.status.unsupported': '平台不支持',
|
||||
@@ -525,6 +544,11 @@ export const zhCN = {
|
||||
'agents.notFetched': '未获取',
|
||||
'agents.useModel': '使用模型',
|
||||
'agents.pending': '尚未应用',
|
||||
'agents.claudeDesktopMapping.title': 'Claude Desktop 模型 → 上游模型',
|
||||
'agents.claudeDesktopMapping.description': '选择每个 Claude Desktop 模型实际调用的上游模型。',
|
||||
'agents.claudeDesktopMapping.opus': 'Claude Opus 5 映射到',
|
||||
'agents.claudeDesktopMapping.sonnet': 'Claude Sonnet 4.6 映射到',
|
||||
'agents.claudeDesktopMapping.haiku': 'Claude Haiku 4.5 映射到',
|
||||
'agents.model.readingAvailable': '正在读取可用模型',
|
||||
'agents.model.current': '当前已应用:{model}',
|
||||
'agents.model.firstSelection': '{count} 个可用模型,首次默认选择第一项',
|
||||
@@ -538,6 +562,7 @@ export const zhCN = {
|
||||
'agents.oauthLoginRequired.launchDescription': '已开启 OAuth 配置模式,但未检测到有效的 Codex 登录凭据。请先在 Codex 中登录 ChatGPT 账号,然后再启动。',
|
||||
'agents.oauthLoginRequired.confirm': '我知道了',
|
||||
'agents.modify.apply': '应用配置修改',
|
||||
'agents.modify.close': '关闭配置修改',
|
||||
'agents.modify.default': '默认配置',
|
||||
'agents.modify.clear': '清空配置',
|
||||
'agents.modify.invalid': '当前配置无法解析。点击“应用配置修改”会重建最小合法配置;“默认配置”会重置整个文件。',
|
||||
@@ -1145,6 +1170,24 @@ export const en: Record<MessageKey, string> = {
|
||||
'config.notice.sessionAffinityEnabled': 'Session affinity enabled',
|
||||
'config.notice.sessionAffinityDisabled': 'Session affinity disabled',
|
||||
'config.notice.sessionTtlUpdated': 'Session affinity TTL saved',
|
||||
'config.webuiKey.title': 'WebUI Key',
|
||||
'config.webuiKey.configured': 'Configured',
|
||||
'config.webuiKey.unconfigured': 'Not configured',
|
||||
'config.webuiKey.heading': 'Management access credential',
|
||||
'config.webuiKey.description': 'Used to sign in to the CPA WebUI and call the Management API. It is different from the API authentication keys above.',
|
||||
'config.webuiKey.securityHint': 'The current key is never displayed. Other WebUIs must sign in again with the new key after it is saved.',
|
||||
'config.webuiKey.newKey': 'New WebUI key',
|
||||
'config.webuiKey.confirmKey': 'Confirm new key',
|
||||
'config.webuiKey.placeholder': 'Enter a new plaintext key',
|
||||
'config.webuiKey.confirmPlaceholder': 'Enter the new key again',
|
||||
'config.webuiKey.generate': 'Generate secure key',
|
||||
'config.webuiKey.save': 'Save key',
|
||||
'config.webuiKey.error.empty': 'The WebUI key cannot be empty',
|
||||
'config.webuiKey.error.legacyDefault': 'The legacy default key 123456 is no longer allowed',
|
||||
'config.webuiKey.error.tooLong': 'The WebUI key cannot exceed 512 characters',
|
||||
'config.webuiKey.error.invalid': 'The WebUI key cannot contain control characters',
|
||||
'config.webuiKey.error.mismatch': 'The WebUI keys do not match',
|
||||
'config.webuiKey.notice.updated': 'WebUI key updated',
|
||||
'config.keys.title': 'Authentication Keys',
|
||||
'config.keys.count': 'Key count',
|
||||
'config.keys.add': 'Add Authentication Key',
|
||||
@@ -1298,6 +1341,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'agents.error.modelsLoading': 'The model list is still loading. Try again shortly.',
|
||||
'agents.error.noModels': 'No models are available, so configuration changes cannot be applied',
|
||||
'agents.error.selectionGone': 'The selected model is no longer available. Refresh and select it again.',
|
||||
'agents.error.mappingSelectionGone': 'A Claude Desktop mapping is no longer available. Refresh the models and select it again.',
|
||||
'agents.error.applyFirst': 'The selected model has not been applied. Click “Apply Configuration Changes” first.',
|
||||
'agents.status.detecting': 'Detecting',
|
||||
'agents.status.unsupported': 'Unsupported Platform',
|
||||
@@ -1388,6 +1432,11 @@ export const en: Record<MessageKey, string> = {
|
||||
'agents.notFetched': 'Not available',
|
||||
'agents.useModel': 'Model',
|
||||
'agents.pending': 'Not Applied',
|
||||
'agents.claudeDesktopMapping.title': 'Claude Desktop Model → Upstream Model',
|
||||
'agents.claudeDesktopMapping.description': 'Choose the upstream model each Claude Desktop model actually calls.',
|
||||
'agents.claudeDesktopMapping.opus': 'Map Claude Opus 5 to',
|
||||
'agents.claudeDesktopMapping.sonnet': 'Map Claude Sonnet 4.6 to',
|
||||
'agents.claudeDesktopMapping.haiku': 'Map Claude Haiku 4.5 to',
|
||||
'agents.model.readingAvailable': 'Loading available models',
|
||||
'agents.model.current': 'Currently applied: {model}',
|
||||
'agents.model.firstSelection': '{count} models available; the first is selected by default',
|
||||
@@ -1401,6 +1450,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'agents.oauthLoginRequired.launchDescription': 'OAuth configuration mode is enabled, but no valid Codex sign-in credentials were found. Sign in to your ChatGPT account in Codex before launching it.',
|
||||
'agents.oauthLoginRequired.confirm': 'Got it',
|
||||
'agents.modify.apply': 'Apply Configuration Changes',
|
||||
'agents.modify.close': 'Close Configuration Changes',
|
||||
'agents.modify.default': 'Default Configuration',
|
||||
'agents.modify.clear': 'Clear Configuration',
|
||||
'agents.modify.invalid': 'The current configuration cannot be parsed. Apply Configuration Changes rebuilds a minimal valid configuration; Default Configuration resets the whole file.',
|
||||
|
||||
+180
-34
@@ -69,6 +69,7 @@ type AgentConfigStatus = {
|
||||
modificationState: AgentModificationState;
|
||||
backupAvailable: boolean;
|
||||
appliedModel: string | null;
|
||||
claudeDesktopModelMappings: ClaudeDesktopModelMappings | null;
|
||||
warnings: string[];
|
||||
error: string | null;
|
||||
};
|
||||
@@ -94,8 +95,31 @@ type ChatGptCloseResult = {
|
||||
|
||||
type OAuthLoginRequiredAction = 'enable' | 'apply' | 'launch';
|
||||
|
||||
type ClaudeDesktopModelMappings = {
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
};
|
||||
|
||||
const CODEX_OAUTH_LOGIN_REQUIRED_ERROR = 'CODEX_OAUTH_LOGIN_REQUIRED';
|
||||
|
||||
const createClaudeDesktopModelMappings = (model: string): ClaudeDesktopModelMappings => ({
|
||||
opus: model,
|
||||
sonnet: model,
|
||||
haiku: model,
|
||||
});
|
||||
|
||||
const sameClaudeDesktopModelMappings = (
|
||||
left: ClaudeDesktopModelMappings,
|
||||
right: ClaudeDesktopModelMappings,
|
||||
) => left.opus === right.opus && left.sonnet === right.sonnet && left.haiku === right.haiku;
|
||||
|
||||
const claudeDesktopMappingRoles = [
|
||||
{ key: 'opus', labelKey: 'agents.claudeDesktopMapping.opus' },
|
||||
{ key: 'sonnet', labelKey: 'agents.claudeDesktopMapping.sonnet' },
|
||||
{ key: 'haiku', labelKey: 'agents.claudeDesktopMapping.haiku' },
|
||||
] as const;
|
||||
|
||||
type AgentDefinition = {
|
||||
id: AgentClientId;
|
||||
name: string;
|
||||
@@ -487,10 +511,13 @@ export function AgentsPage() {
|
||||
const [modelByClient, setModelByClient] = useState<Partial<Record<AgentClientId, string>>>(
|
||||
readAgentModelSelections,
|
||||
);
|
||||
const [claudeDesktopModelMappingsDraft, setClaudeDesktopModelMappingsDraft] = useState<ClaudeDesktopModelMappings>(
|
||||
createClaudeDesktopModelMappings(''),
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modelLoading, setModelLoading] = useState(false);
|
||||
const [busyAction, setBusyAction] = useState<
|
||||
'apply' | 'default' | 'clear' | 'launch' | 'launch-cli' | 'launch-app' | 'close-app' | 'oauth-check' | null
|
||||
'apply' | 'close-config' | 'default' | 'clear' | 'launch' | 'launch-cli' | 'launch-app' | 'close-app' | 'oauth-check' | null
|
||||
>(null);
|
||||
const busy = busyAction !== null;
|
||||
const [detectionError, setDetectionError] = useState('');
|
||||
@@ -509,6 +536,7 @@ export function AgentsPage() {
|
||||
const [oauthLoginRequiredAction, setOauthLoginRequiredAction] = useState<OAuthLoginRequiredAction | null>(null);
|
||||
const [oauthConfigurationDraft, setOauthConfigurationDraft] = useState<boolean | null>(null);
|
||||
const modelRequestRef = useRef(0);
|
||||
const claudeDesktopModelMappingsDirtyRef = useRef(false);
|
||||
|
||||
const loadStatuses = useCallback(async (forceRefresh = false) => {
|
||||
const command = forceRefresh
|
||||
@@ -616,24 +644,55 @@ export function AgentsPage() {
|
||||
const savedSelectedModel = modelByClient[selected] ?? '';
|
||||
const selectedModelOption = findAgentModel(models, savedSelectedModel);
|
||||
const selectedModel = selectedModelOption?.name ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (selected !== 'claude-desktop' || !selectedModel) return;
|
||||
const appliedMappings = activeStatus?.claudeDesktopModelMappings;
|
||||
setClaudeDesktopModelMappingsDraft((current) => {
|
||||
const source = claudeDesktopModelMappingsDirtyRef.current
|
||||
? current
|
||||
: appliedMappings ?? current;
|
||||
const next: ClaudeDesktopModelMappings = {
|
||||
opus: findAgentModel(models, source.opus)?.name ?? selectedModel,
|
||||
sonnet: findAgentModel(models, source.sonnet)?.name ?? selectedModel,
|
||||
haiku: findAgentModel(models, source.haiku)?.name ?? selectedModel,
|
||||
};
|
||||
return sameClaudeDesktopModelMappings(current, next) ? current : next;
|
||||
});
|
||||
}, [activeStatus?.claudeDesktopModelMappings, models, selected, selectedModel]);
|
||||
|
||||
const activeLaunchTargets = activeStatus?.launchTargets ?? [];
|
||||
const defaultLaunchTarget = activeLaunchTargets[0] ?? null;
|
||||
const cliLaunchTarget = activeLaunchTargets.find((target) => target.id === 'cli') ?? null;
|
||||
const appLaunchTarget = activeLaunchTargets.find((target) => target.id === 'app') ?? null;
|
||||
const appliedModel = activeStatus?.appliedModel ?? activeStatus?.currentModel ?? '';
|
||||
const modelDraftChanged = Boolean(
|
||||
const modelDraftChanged = selected !== 'claude-desktop' && Boolean(
|
||||
selectedModel.trim()
|
||||
&& appliedModel.trim()
|
||||
&& selectedModel.trim() !== appliedModel.trim(),
|
||||
);
|
||||
const appliedClaudeDesktopModelMappings = activeStatus?.claudeDesktopModelMappings
|
||||
?? createClaudeDesktopModelMappings(appliedModel);
|
||||
const claudeDesktopMappingsReady = selected !== 'claude-desktop'
|
||||
|| claudeDesktopMappingRoles.every((role) =>
|
||||
Boolean(findAgentModel(models, claudeDesktopModelMappingsDraft[role.key])),
|
||||
);
|
||||
const claudeDesktopMappingDraftChanged = selected === 'claude-desktop'
|
||||
&& activeStatus?.modificationState === 'applied'
|
||||
&& !sameClaudeDesktopModelMappings(
|
||||
claudeDesktopModelMappingsDraft,
|
||||
appliedClaudeDesktopModelMappings,
|
||||
);
|
||||
const oauthConfigurationChanged = selected === 'codex'
|
||||
&& oauthConfiguration !== Boolean(activeStatus?.oauthConfiguration);
|
||||
const draftChanged = modelDraftChanged || oauthConfigurationChanged;
|
||||
const draftChanged = modelDraftChanged || claudeDesktopMappingDraftChanged || oauthConfigurationChanged;
|
||||
const canEnable = Boolean(
|
||||
activeStatus?.supportedPlatform
|
||||
&& activeStatus.installed
|
||||
&& !modelLoading
|
||||
&& selectedModelOption,
|
||||
&& (selected === 'claude-desktop'
|
||||
? claudeDesktopMappingsReady
|
||||
: selectedModelOption),
|
||||
);
|
||||
const launchEnabled = Boolean(
|
||||
activeStatus?.supportedPlatform
|
||||
@@ -678,6 +737,17 @@ export function AgentsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const selectClaudeDesktopModelMapping = (
|
||||
role: keyof ClaudeDesktopModelMappings,
|
||||
value: string,
|
||||
) => {
|
||||
const model = findAgentModel(models, value);
|
||||
if (!model) return;
|
||||
claudeDesktopModelMappingsDirtyRef.current = true;
|
||||
setModelSelectionError('');
|
||||
setClaudeDesktopModelMappingsDraft((current) => ({ ...current, [role]: model.name }));
|
||||
};
|
||||
|
||||
const requireSelectedModel = () => {
|
||||
if (modelLoading) {
|
||||
setModelSelectionError(t('agents.error.modelsLoading'));
|
||||
@@ -696,6 +766,20 @@ export function AgentsPage() {
|
||||
return model.name;
|
||||
};
|
||||
|
||||
const requireClaudeDesktopModelMappings = (): ClaudeDesktopModelMappings | null => {
|
||||
if (selected !== 'claude-desktop') return null;
|
||||
const resolved = {} as ClaudeDesktopModelMappings;
|
||||
for (const role of claudeDesktopMappingRoles) {
|
||||
const model = findAgentModel(models, claudeDesktopModelMappingsDraft[role.key]);
|
||||
if (!model) {
|
||||
setModelSelectionError(t('agents.error.mappingSelectionGone'));
|
||||
return null;
|
||||
}
|
||||
resolved[role.key] = model.name;
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const handleOAuthLoginError = (requestError: unknown, action: OAuthLoginRequiredAction) => {
|
||||
const message = String(requestError);
|
||||
if (message.includes(CODEX_OAUTH_LOGIN_REQUIRED_ERROR)) {
|
||||
@@ -726,7 +810,11 @@ export function AgentsPage() {
|
||||
|
||||
const applyConfigurationChanges = async () => {
|
||||
setConfigurationError('');
|
||||
const model = requireSelectedModel();
|
||||
const claudeDesktopModelMappings = requireClaudeDesktopModelMappings();
|
||||
if (selected === 'claude-desktop' && !claudeDesktopModelMappings) return;
|
||||
const model = selected === 'claude-desktop'
|
||||
? claudeDesktopModelMappings?.sonnet ?? null
|
||||
: requireSelectedModel();
|
||||
if (!model) return;
|
||||
setBusyAction('apply');
|
||||
try {
|
||||
@@ -734,9 +822,11 @@ export function AgentsPage() {
|
||||
client: selected,
|
||||
model,
|
||||
oauthConfiguration,
|
||||
claudeDesktopModelMappings,
|
||||
});
|
||||
await reloadStatusesAfterAction();
|
||||
setOauthConfigurationDraft(null);
|
||||
claudeDesktopModelMappingsDirtyRef.current = false;
|
||||
} catch (requestError) {
|
||||
if (!handleOAuthLoginError(requestError, 'apply')) {
|
||||
setConfigurationError(String(requestError));
|
||||
@@ -746,8 +836,26 @@ export function AgentsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const closeConfigurationChanges = async () => {
|
||||
setConfigurationError('');
|
||||
setBusyAction('close-config');
|
||||
try {
|
||||
await invoke<AgentConfigActionResult>('close_agent_config_modification', { client: selected });
|
||||
await reloadStatusesAfterAction();
|
||||
setOauthConfigurationDraft(null);
|
||||
} catch (requestError) {
|
||||
setConfigurationError(String(requestError));
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const resetConfigurationToDefault = async () => {
|
||||
const model = requireSelectedModel();
|
||||
const claudeDesktopModelMappings = requireClaudeDesktopModelMappings();
|
||||
if (selected === 'claude-desktop' && !claudeDesktopModelMappings) return;
|
||||
const model = selected === 'claude-desktop'
|
||||
? claudeDesktopModelMappings?.sonnet ?? null
|
||||
: requireSelectedModel();
|
||||
if (!model) return;
|
||||
setBusyAction('default');
|
||||
setDefaultError('');
|
||||
@@ -756,10 +864,12 @@ export function AgentsPage() {
|
||||
client: selected,
|
||||
model,
|
||||
oauthConfiguration,
|
||||
claudeDesktopModelMappings,
|
||||
});
|
||||
setDefaultConfirmOpen(false);
|
||||
await reloadStatusesAfterAction();
|
||||
setOauthConfigurationDraft(null);
|
||||
claudeDesktopModelMappingsDirtyRef.current = false;
|
||||
} catch (requestError) {
|
||||
if (!handleOAuthLoginError(requestError, 'apply')) {
|
||||
setDefaultError(String(requestError));
|
||||
@@ -962,30 +1072,60 @@ export function AgentsPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="agent-core-setting-section agent-model-section">
|
||||
<div className="agent-section-heading">
|
||||
<div><strong>{t('agents.useModel')}</strong></div>
|
||||
{modelDraftChanged ? <span className="agent-pending-badge">{t('agents.pending')}</span> : null}
|
||||
</div>
|
||||
<AgentModelPicker
|
||||
models={models}
|
||||
value={selectedModel}
|
||||
loading={modelLoading}
|
||||
error={modelError}
|
||||
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform}
|
||||
onChange={selectModel}
|
||||
onRefresh={refreshModels}
|
||||
/>
|
||||
{modelHint ? (
|
||||
<span
|
||||
className={`agent-model-hint ${modelSelectionError || modelError ? 'error' : ''}`}
|
||||
role={modelSelectionError || modelError ? 'alert' : undefined}
|
||||
aria-live="polite"
|
||||
>
|
||||
{modelHint}
|
||||
</span>
|
||||
) : null}
|
||||
</section>
|
||||
{selected !== 'claude-desktop' ? (
|
||||
<section className="agent-core-setting-section agent-model-section">
|
||||
<div className="agent-section-heading">
|
||||
<div><strong>{t('agents.useModel')}</strong></div>
|
||||
{modelDraftChanged ? <span className="agent-pending-badge">{t('agents.pending')}</span> : null}
|
||||
</div>
|
||||
<AgentModelPicker
|
||||
models={models}
|
||||
value={selectedModel}
|
||||
loading={modelLoading}
|
||||
error={modelError}
|
||||
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform}
|
||||
onChange={selectModel}
|
||||
onRefresh={refreshModels}
|
||||
/>
|
||||
{modelHint ? (
|
||||
<span
|
||||
className={`agent-model-hint ${modelSelectionError || modelError ? 'error' : ''}`}
|
||||
role={modelSelectionError || modelError ? 'alert' : undefined}
|
||||
aria-live="polite"
|
||||
>
|
||||
{modelHint}
|
||||
</span>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{selected === 'claude-desktop' ? (
|
||||
<section className="agent-core-setting-section agent-claude-desktop-mapping">
|
||||
<div className="agent-section-heading">
|
||||
<div>
|
||||
<strong>{t('agents.claudeDesktopMapping.title')}</strong>
|
||||
<span>{t('agents.claudeDesktopMapping.description')}</span>
|
||||
</div>
|
||||
{claudeDesktopMappingDraftChanged ? <span className="agent-pending-badge">{t('agents.pending')}</span> : null}
|
||||
</div>
|
||||
<div className="agent-claude-desktop-mapping-grid">
|
||||
{claudeDesktopMappingRoles.map((role) => (
|
||||
<div className="agent-claude-desktop-mapping-row" key={role.key}>
|
||||
<strong>{t(role.labelKey)}</strong>
|
||||
<AgentModelPicker
|
||||
models={models}
|
||||
value={claudeDesktopModelMappingsDraft[role.key]}
|
||||
loading={modelLoading}
|
||||
error={modelError}
|
||||
disabled={busy || !activeStatus?.installed || !activeStatus.supportedPlatform}
|
||||
onChange={(value) => selectClaudeDesktopModelMapping(role.key, value)}
|
||||
onRefresh={refreshModels}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className={`agent-core-setting-section agent-modification-actions ${activeStatus?.modificationState === 'applied' ? 'enabled' : ''}`}>
|
||||
<div className="agent-section-heading">
|
||||
@@ -1021,14 +1161,20 @@ export function AgentsPage() {
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={() => void applyConfigurationChanges()}
|
||||
onClick={() => void (activeStatus?.modificationState === 'applied'
|
||||
? closeConfigurationChanges()
|
||||
: applyConfigurationChanges())}
|
||||
disabled={
|
||||
busy
|
||||
|| !canEnable
|
||||
|| (activeStatus?.modificationState === 'applied' ? false : !canEnable)
|
||||
}
|
||||
>
|
||||
{busyAction === 'apply' ? <LoaderCircle size={16} className="spin" /> : <Sparkles size={16} />}
|
||||
{t('agents.modify.apply')}
|
||||
{busyAction === 'apply' || busyAction === 'close-config'
|
||||
? <LoaderCircle size={16} className="spin" />
|
||||
: <Sparkles size={16} />}
|
||||
{activeStatus?.modificationState === 'applied'
|
||||
? t('agents.modify.close')
|
||||
: t('agents.modify.apply')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Route,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
X,
|
||||
@@ -26,6 +27,7 @@ import { ThinkingAliasesPage } from './ThinkingAliasesPage';
|
||||
|
||||
type CoreConfigSettings = {
|
||||
apiKeys: CoreApiKey[];
|
||||
managementSecretConfigured: boolean;
|
||||
port: number;
|
||||
allowLan: boolean;
|
||||
routingStrategy: string;
|
||||
@@ -43,6 +45,7 @@ type ConfigAction =
|
||||
| 'add-key'
|
||||
| 'update-key'
|
||||
| 'delete-key'
|
||||
| 'management-secret'
|
||||
| 'routing'
|
||||
| 'network'
|
||||
| null;
|
||||
@@ -68,6 +71,10 @@ export function ConfigPanelPage() {
|
||||
const [newApiKeyRemark, setNewApiKeyRemark] = useState('');
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [formError, setFormError] = useState('');
|
||||
const [managementSecretDraft, setManagementSecretDraft] = useState('');
|
||||
const [managementSecretConfirm, setManagementSecretConfirm] = useState('');
|
||||
const [showManagementSecret, setShowManagementSecret] = useState(false);
|
||||
const [managementSecretError, setManagementSecretError] = useState('');
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
const [notice, setNotice] = useState<{ message: string; tone: NoticeTone } | null>(null);
|
||||
const [activeSubpage, setActiveSubpage] = useState<ConfigSubpage>('general');
|
||||
@@ -199,6 +206,55 @@ export function ConfigPanelPage() {
|
||||
setFormError('');
|
||||
};
|
||||
|
||||
const generateManagementSecret = () => {
|
||||
const bytes = new Uint8Array(24);
|
||||
crypto.getRandomValues(bytes);
|
||||
const value = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
const secret = `webui-${value}`;
|
||||
setManagementSecretDraft(secret);
|
||||
setManagementSecretConfirm(secret);
|
||||
setShowManagementSecret(true);
|
||||
setManagementSecretError('');
|
||||
};
|
||||
|
||||
const saveManagementSecret = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const secretKey = managementSecretDraft.trim();
|
||||
if (!secretKey) {
|
||||
setManagementSecretError(t('config.webuiKey.error.empty'));
|
||||
return;
|
||||
}
|
||||
if (secretKey === '123456') {
|
||||
setManagementSecretError(t('config.webuiKey.error.legacyDefault'));
|
||||
return;
|
||||
}
|
||||
if (secretKey.length > 512) {
|
||||
setManagementSecretError(t('config.webuiKey.error.tooLong'));
|
||||
return;
|
||||
}
|
||||
if (/[\u0000-\u001f\u007f-\u009f]/.test(secretKey)) {
|
||||
setManagementSecretError(t('config.webuiKey.error.invalid'));
|
||||
return;
|
||||
}
|
||||
if (secretKey !== managementSecretConfirm.trim()) {
|
||||
setManagementSecretError(t('config.webuiKey.error.mismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = await runMutation(
|
||||
'management-secret',
|
||||
'set_core_management_secret_key',
|
||||
{ secretKey },
|
||||
t('config.webuiKey.notice.updated'),
|
||||
);
|
||||
if (saved) {
|
||||
setManagementSecretDraft('');
|
||||
setManagementSecretConfirm('');
|
||||
setShowManagementSecret(false);
|
||||
setManagementSecretError('');
|
||||
}
|
||||
};
|
||||
|
||||
const submitApiKey = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const apiKey = newApiKey.trim();
|
||||
@@ -361,6 +417,7 @@ export function ConfigPanelPage() {
|
||||
deleteIndex === null ? '' : settings?.apiKeys[deleteIndex]?.apiKey || '';
|
||||
const deletingLastKey = deleteIndex !== null && settings?.apiKeys.length === 1;
|
||||
const keyMutationBusy = busyAction === 'add-key' || busyAction === 'update-key';
|
||||
const managementSecretBusy = busyAction === 'management-secret';
|
||||
|
||||
return (
|
||||
<section className="page config-page">
|
||||
@@ -523,6 +580,115 @@ export function ConfigPanelPage() {
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="panel config-management-panel">
|
||||
<div className="config-panel-heading">
|
||||
<div className="config-heading-title">
|
||||
<ShieldCheck size={18} aria-hidden="true" />
|
||||
<h2>{t('config.webuiKey.title')}</h2>
|
||||
</div>
|
||||
{!loading && settings ? (
|
||||
<span
|
||||
className={`state-pill ${settings.managementSecretConfigured ? 'success' : ''}`}
|
||||
>
|
||||
{settings.managementSecretConfigured
|
||||
? t('config.webuiKey.configured')
|
||||
: t('config.webuiKey.unconfigured')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="config-management-content">
|
||||
<div className="config-management-description">
|
||||
<strong>{t('config.webuiKey.heading')}</strong>
|
||||
<p>{t('config.webuiKey.description')}</p>
|
||||
<small>{t('config.webuiKey.securityHint')}</small>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="config-management-form"
|
||||
onSubmit={(event) => void saveManagementSecret(event)}
|
||||
>
|
||||
<label className="config-management-field">
|
||||
<span>{t('config.webuiKey.newKey')}</span>
|
||||
<div className="config-secret-input">
|
||||
<input
|
||||
type={showManagementSecret ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
maxLength={512}
|
||||
value={managementSecretDraft}
|
||||
disabled={controlsDisabled}
|
||||
aria-invalid={Boolean(managementSecretError)}
|
||||
placeholder={t('config.webuiKey.placeholder')}
|
||||
onChange={(event) => {
|
||||
setManagementSecretDraft(event.currentTarget.value);
|
||||
setManagementSecretError('');
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button quiet"
|
||||
disabled={controlsDisabled}
|
||||
onClick={() => setShowManagementSecret((value) => !value)}
|
||||
title={showManagementSecret ? t('config.keys.hide') : t('config.keys.show')}
|
||||
aria-label={showManagementSecret ? t('config.keys.hide') : t('config.keys.show')}
|
||||
>
|
||||
{showManagementSecret ? (
|
||||
<EyeOff size={16} aria-hidden="true" />
|
||||
) : (
|
||||
<Eye size={16} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="config-management-field">
|
||||
<span>{t('config.webuiKey.confirmKey')}</span>
|
||||
<input
|
||||
className="config-dialog-text-input"
|
||||
type={showManagementSecret ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
maxLength={512}
|
||||
value={managementSecretConfirm}
|
||||
disabled={controlsDisabled}
|
||||
aria-invalid={Boolean(managementSecretError)}
|
||||
placeholder={t('config.webuiKey.confirmPlaceholder')}
|
||||
onChange={(event) => {
|
||||
setManagementSecretConfirm(event.currentTarget.value);
|
||||
setManagementSecretError('');
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="config-management-form-footer">
|
||||
<span
|
||||
className={`config-management-error ${managementSecretError ? 'visible' : ''}`}
|
||||
role="alert"
|
||||
>
|
||||
{managementSecretError || ' '}
|
||||
</span>
|
||||
<div className="config-management-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button compact-button"
|
||||
disabled={controlsDisabled}
|
||||
onClick={generateManagementSecret}
|
||||
>
|
||||
<Sparkles size={16} aria-hidden="true" />
|
||||
{t('config.webuiKey.generate')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="primary-button compact-button"
|
||||
disabled={controlsDisabled || !managementSecretDraft.trim()}
|
||||
>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{managementSecretBusy ? t('common.saving') : t('config.webuiKey.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : activeSubpage === 'network' ? (
|
||||
<div
|
||||
|
||||
+146
@@ -1661,6 +1661,105 @@ textarea:focus-visible {
|
||||
height: 350px;
|
||||
}
|
||||
|
||||
.config-management-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.config-management-content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.72fr) minmax(420px, 1.28fr);
|
||||
align-items: stretch;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.config-management-description,
|
||||
.config-management-form {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--theme-e3e1db);
|
||||
border-radius: 8px;
|
||||
background: var(--theme-faf8f2);
|
||||
}
|
||||
|
||||
.config-management-description {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.config-management-description strong {
|
||||
color: var(--theme-2d2a26);
|
||||
font-size: var(--font-size-section);
|
||||
}
|
||||
|
||||
.config-management-description p,
|
||||
.config-management-description small {
|
||||
margin: 0;
|
||||
color: var(--theme-6d6760);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-body);
|
||||
}
|
||||
|
||||
.config-management-description small {
|
||||
color: var(--theme-7a746c);
|
||||
font-size: var(--font-size-meta);
|
||||
}
|
||||
|
||||
.config-management-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px 16px;
|
||||
}
|
||||
|
||||
.config-management-field {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
color: var(--theme-5f5a53);
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.config-management-field .config-secret-input input,
|
||||
.config-management-field > .config-dialog-text-input {
|
||||
background: var(--theme-fffdf8);
|
||||
}
|
||||
|
||||
.config-management-field > .config-dialog-text-input[aria-invalid='true'] {
|
||||
border-color: var(--theme-a83f32);
|
||||
}
|
||||
|
||||
.config-management-form-footer {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.config-management-error {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: transparent;
|
||||
font-size: var(--font-size-label);
|
||||
line-height: var(--line-height-body);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-management-error.visible {
|
||||
color: var(--theme-9f2d20);
|
||||
}
|
||||
|
||||
.config-management-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.config-network-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
@@ -4988,6 +5087,27 @@ textarea:focus-visible {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.agent-claude-desktop-mapping {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.agent-claude-desktop-mapping-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.agent-claude-desktop-mapping-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.agent-claude-desktop-mapping-row > strong {
|
||||
color: var(--theme-5f5a53);
|
||||
font-size: var(--font-size-label);
|
||||
}
|
||||
|
||||
.agent-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -6771,6 +6891,10 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
@container agent-panel (max-width: 640px) {
|
||||
.agent-claude-desktop-mapping-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.agent-codex-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -6882,6 +7006,10 @@ textarea:focus-visible {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.config-management-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@@ -6937,6 +7065,14 @@ textarea:focus-visible {
|
||||
grid-auto-rows: auto;
|
||||
}
|
||||
|
||||
.config-management-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.config-management-form-footer {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.client-api-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -7153,6 +7289,16 @@ textarea:focus-visible {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.config-management-form-footer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.config-management-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.kernel-layout .panel-heading {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user