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:
lzt404 committed 2026-08-02 00:08:16 +08:00
1 parent d8f73cb067
commit 88baeab512
8 files changed
+1226 -167

No files matched your search

+1
View File
@@ -588,6 +588,7 @@ dependencies = [
"chrono",
"flate2",
"futures-util",
"getrandom 0.3.4",
"json5",
"libc",
"notify",
+1
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -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': '現在の設定を解析できません。「設定変更を適用」は最小の有効な設定を再構築し、「デフォルト設定」はファイル全体をリセットします。',
+50
View File
@@ -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
View File
@@ -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"
+166
View File
@@ -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
View File
@@ -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;
}