diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 7100434..a925dbc 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -1610,7 +1610,10 @@ function readPanelConfig() { } try { if (fs.existsSync(PANEL_CONFIG_PATH)) { - _panelConfigCache = JSON.parse(fs.readFileSync(PANEL_CONFIG_PATH, 'utf8')) + _panelConfigCache = readJsonFileRelaxed(PANEL_CONFIG_PATH) + if (!_panelConfigCache || typeof _panelConfigCache !== 'object' || Array.isArray(_panelConfigCache)) { + throw new Error('clawpanel.json 格式错误') + } _panelConfigCacheTime = now applyOpenclawPathConfig(_panelConfigCache) return JSON.parse(JSON.stringify(_panelConfigCache)) @@ -1885,7 +1888,7 @@ function generateCalibrationToken() { return `cp-${crypto.randomBytes(16).toString('hex')}` } -function decodeJsonFileContent(filePath) { +export function decodeJsonFileContent(filePath) { const raw = fs.readFileSync(filePath) if (raw.length >= 3 && raw[0] === 0xEF && raw[1] === 0xBB && raw[2] === 0xBF) { return raw.subarray(3).toString('utf8') @@ -1893,7 +1896,7 @@ function decodeJsonFileContent(filePath) { return raw.toString('utf8') } -function readJsonFileRelaxed(filePath) { +export function readJsonFileRelaxed(filePath) { if (!fs.existsSync(filePath)) return null try { return JSON.parse(decodeJsonFileContent(filePath)) @@ -2182,7 +2185,7 @@ function wsReadLoop(socket, onMessage, timeoutMs = DOCKER_TASK_TIMEOUT_MS) { function patchGatewayOrigins() { if (!fs.existsSync(CONFIG_PATH)) return false - const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) + const config = readOpenclawConfigRequired() const origins = requiredControlUiOrigins() const existing = config?.gateway?.controlUi?.allowedOrigins || [] // 合并:保留用户已有的 origins,只追加 ClawPanel 需要的 @@ -2198,12 +2201,18 @@ function patchGatewayOrigins() { function readOpenclawConfigOptional() { if (!fs.existsSync(CONFIG_PATH)) return {} - return cleanLoadedConfig(JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))) + const config = readJsonFileRelaxed(CONFIG_PATH) + if (!config || typeof config !== 'object' || Array.isArray(config)) return {} + return cleanLoadedConfig(config) } function readOpenclawConfigRequired() { if (!fs.existsSync(CONFIG_PATH)) throw new Error('openclaw.json 不存在') - return cleanLoadedConfig(JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))) + const config = readJsonFileRelaxed(CONFIG_PATH) + if (!config || typeof config !== 'object' || Array.isArray(config)) { + throw new Error('openclaw.json 格式错误') + } + return cleanLoadedConfig(config) } function mergeConfigsPreservingFields(existing, next) { @@ -2500,19 +2509,90 @@ function putAccessPolicyFormValues(form, source, { telegramCompat = false, menti if (telegramCompat && form.allowFrom) form.allowedUsers = form.allowFrom } +function normalizeSecretRef(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const source = String(value.source || '').trim() + if (!['env', 'file', 'exec'].includes(source)) return null + const provider = String(value.provider || 'default').trim() || 'default' + const id = String(value.id || '').trim() + if (!id) return null + return { source, provider, id } +} + +function formatSecretRefPlaceholder(ref) { + const normalized = normalizeSecretRef(ref) + if (!normalized) return '' + return `SecretRef(${normalized.source}:${normalized.provider}:${normalized.id})` +} + +function putSecretAwareFormValue(form, source, key) { + if (typeof source?.[key] === 'string') { + form[key] = source[key] + return + } + const ref = normalizeSecretRef(source?.[key]) + if (!ref) return + form[key] = formatSecretRefPlaceholder(ref) + form.__secretRefs = { + ...(form.__secretRefs || {}), + [key]: ref, + } +} + +export function resolveMessagingCredentialValueForSave({ form = {}, current = {}, key }) { + const rawValue = form?.[key] + if (typeof rawValue !== 'string') return rawValue + const value = rawValue.trim() + const currentRef = normalizeSecretRef(current?.[key]) + if (currentRef && (!value || value === formatSecretRefPlaceholder(currentRef))) { + return currentRef + } + return value || undefined +} + +const MESSAGING_CREDENTIAL_FIELDS = [ + 'accessToken', + 'appId', + 'appPassword', + 'appSecret', + 'appToken', + 'botToken', + 'clientId', + 'clientSecret', + 'gatewayPassword', + 'gatewayToken', + 'password', + 'signingSecret', + 'token', +] + +function preserveMessagingCredentialRefs(entry, form, current) { + delete entry.__secretRefs + for (const key of MESSAGING_CREDENTIAL_FIELDS) { + if (!Object.hasOwn(form || {}, key)) continue + const value = resolveMessagingCredentialValueForSave({ form, current, key }) + if (value === undefined) { + delete entry[key] + } else { + entry[key] = value + } + } + return entry +} + export function buildMessagingPlatformFormValues(platform, saved = {}, options = {}) { if (!saved || typeof saved !== 'object') return {} const form = {} const storageKey = platformStorageKey(platform) if (storageKey === 'telegram') { - putStringFormValue(form, saved, 'botToken') + putSecretAwareFormValue(form, saved, 'botToken') putAccessPolicyFormValues(form, saved, { telegramCompat: true }) return form } if (storageKey === 'discord') { - putStringFormValue(form, saved, 'token') + putSecretAwareFormValue(form, saved, 'token') putAccessPolicyFormValues(form, saved) const guilds = saved.guilds && typeof saved.guilds === 'object' ? saved.guilds : null const guildId = guilds ? Object.keys(guilds)[0] : '' @@ -2528,8 +2608,8 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options = } if (storageKey === 'feishu') { - putStringFormValue(form, saved, 'appId') - putStringFormValue(form, saved, 'appSecret') + putSecretAwareFormValue(form, saved, 'appId') + putSecretAwareFormValue(form, saved, 'appSecret') const shared = options.channelRoot && typeof options.channelRoot === 'object' ? { ...saved, ...options.channelRoot } : saved @@ -2545,7 +2625,7 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options = if (storageKey === 'slack') { for (const key of ['mode', 'botToken', 'appToken', 'signingSecret', 'webhookPath', 'teamId', 'appId', 'socketMode']) { - putStringFormValue(form, saved, key) + putSecretAwareFormValue(form, saved, key) } putAccessPolicyFormValues(form, saved, { mentionCompat: true }) putBoolFormValue(form, saved, 'userTokenReadOnly') @@ -2561,7 +2641,7 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options = if (storageKey === 'signal') { for (const key of ['account', 'cliPath', 'httpUrl', 'httpHost', 'httpPort']) { - putStringFormValue(form, saved, key) + putSecretAwareFormValue(form, saved, key) } putAccessPolicyFormValues(form, saved) return form @@ -2569,7 +2649,7 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options = if (storageKey === 'matrix') { for (const key of ['homeserver', 'accessToken', 'userId', 'password', 'deviceId']) { - putStringFormValue(form, saved, key) + putSecretAwareFormValue(form, saved, key) } putAccessPolicyFormValues(form, saved) putBoolFormValue(form, saved, 'e2ee') @@ -2580,7 +2660,7 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options = if (storageKey === 'msteams') { for (const key of ['appId', 'appPassword', 'tenantId', 'botEndpoint', 'webhookPath']) { - putStringFormValue(form, saved, key) + putSecretAwareFormValue(form, saved, key) } putAccessPolicyFormValues(form, saved) putBoolFormValue(form, saved, 'requireMention') @@ -2590,6 +2670,7 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options = for (const [key, value] of Object.entries(saved)) { if (key === 'enabled' || key === 'accounts') continue if (typeof value === 'string') form[key] = value + else if (normalizeSecretRef(value)) putSecretAwareFormValue(form, saved, key) else if (Array.isArray(value)) { const csv = csvForForm(value) if (csv) form[key] = csv @@ -2850,6 +2931,11 @@ function channelHasQqbotCredentials(entry) { return !!(entry && typeof entry === 'object' && (entry.appId || entry.clientSecret || entry.appSecret || entry.token)) } +function secretAwareAccountDisplayValue(value) { + if (typeof value === 'string') return value.trim() + return formatSecretRefPlaceholder(value) +} + function resolvePlatformConfigEntry(channelRoot, platform, accountId) { if (!channelRoot || typeof channelRoot !== 'object') return null const accountKey = typeof accountId === 'string' ? accountId.trim() : '' @@ -2860,14 +2946,16 @@ function resolvePlatformConfigEntry(channelRoot, platform, accountId) { return channelRoot } -function listPlatformAccounts(channelRoot) { +export function listPlatformAccounts(channelRoot) { if (!channelRoot || typeof channelRoot !== 'object' || !channelRoot.accounts || typeof channelRoot.accounts !== 'object') { return [] } return Object.entries(channelRoot.accounts) .map(([accountId, value]) => { const entry = { accountId } - const displayId = value?.appId || value?.clientId || value?.account || null + const displayId = ['appId', 'clientId', 'account'] + .map(key => secretAwareAccountDisplayValue(value?.[key])) + .find(Boolean) if (displayId) entry.appId = displayId return entry }) @@ -2954,7 +3042,9 @@ function bindingIdentityMatches(binding, agentId, targetMatch) { function triggerGatewayReloadNonBlocking(reason) { setTimeout(() => { try { - handlers.reload_gateway() + Promise.resolve(handlers.reload_gateway()).catch((e) => { + console.warn(`[dev-api] Gateway reload skipped after ${reason}: ${e.message || e}`) + }) } catch (e) { console.warn(`[dev-api] Gateway reload skipped after ${reason}: ${e.message || e}`) } @@ -4345,6 +4435,7 @@ const handlers = { if (!cfg.channels) cfg.channels = {} const storageKey = platformStorageKey(platform) const normalizedAccountId = typeof accountId === 'string' ? accountId.trim() : '' + const currentSaved = resolvePlatformConfigEntry(cfg.channels?.[storageKey], platform, normalizedAccountId) || {} const setRootChannelEntry = (entry) => { const current = cfg.channels?.[storageKey] // 合并模式:保留用户通过 CLI 或手动编辑的自定义字段(streaming, retry, dmPolicy 等) @@ -4409,6 +4500,7 @@ const handlers = { entry.reactionNotifications = form.reactionNotifications entry.typingIndicator = form.typingIndicator entry.resolveSenderNames = form.resolveSenderNames + preserveMessagingCredentialRefs(entry, form, currentSaved) if (normalizedAccountId) { setAccountChannelEntry(entry) } else { @@ -4416,6 +4508,7 @@ const handlers = { } } else if (platform === 'dingtalk' || platform === 'dingtalk-connector') { Object.assign(entry, form) + preserveMessagingCredentialRefs(entry, form, currentSaved) if (normalizedAccountId) { setAccountChannelEntry(entry) } else { @@ -4423,10 +4516,12 @@ const handlers = { } } else { Object.assign(entry, form) + preserveMessagingCredentialRefs(entry, form, currentSaved) setRootChannelEntry(entry) } if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector') { + preserveMessagingCredentialRefs(entry, form, currentSaved) // 合并模式:保留用户通过 CLI 或手动编辑的自定义字段 const existing = cfg.channels[storageKey] cfg.channels[storageKey] = (existing && typeof existing === 'object') @@ -4641,7 +4736,7 @@ const handlers = { const output = (result.stdout || '') + (result.stderr || '') if (result.status === 0 && output.includes(pid) && output.includes('built-in')) builtin = true } catch {} - const cfg = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) : {} + const cfg = readOpenclawConfigOptional() const allowArr = cfg.plugins?.allow || [] const allowed = allowArr.includes(pid) const enabled = !!cfg.plugins?.entries?.[pid]?.enabled @@ -6560,13 +6655,13 @@ const handlers = { // Agent 渠道绑定管理 list_all_bindings() { - const cfg = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) : {} + const cfg = readOpenclawConfigOptional() const bindings = cfg.bindings || [] return { bindings } }, get_agent_bindings({ agentId } = {}) { - const cfg = fs.existsSync(CONFIG_PATH) ? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) : {} + const cfg = readOpenclawConfigOptional() const all = Array.isArray(cfg.bindings) ? cfg.bindings : [] const bindings = agentId ? all.filter(b => b?.agentId === agentId) : all return { bindings } diff --git a/src-tauri/src/commands/messaging.rs b/src-tauri/src/commands/messaging.rs index d7a0965..9263f75 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -57,6 +57,111 @@ fn insert_string_if_present(form: &mut Map, source: &Value, key: } } +fn secret_ref_parts(value: &Value) -> Option<(&str, &str, &str)> { + let obj = value.as_object()?; + let source = obj.get("source").and_then(|v| v.as_str())?.trim(); + if !matches!(source, "env" | "file" | "exec") { + return None; + } + let provider = obj + .get("provider") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("default"); + let id = obj + .get("id") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty())?; + Some((source, provider, id)) +} + +fn secret_ref_placeholder(value: &Value) -> Option { + let (source, provider, id) = secret_ref_parts(value)?; + Some(format!("SecretRef({}:{}:{})", source, provider, id)) +} + +fn insert_secret_aware_form_value(form: &mut Map, source: &Value, key: &str) { + if let Some(v) = source.get(key).and_then(|v| v.as_str()) { + form.insert(key.into(), Value::String(v.into())); + return; + } + + let Some(value) = source.get(key) else { + return; + }; + let Some(placeholder) = secret_ref_placeholder(value) else { + return; + }; + form.insert(key.into(), Value::String(placeholder)); + let refs = form + .entry("__secretRefs") + .or_insert_with(|| Value::Object(Map::new())); + if let Some(obj) = refs.as_object_mut() { + obj.insert(key.into(), value.clone()); + } +} + +fn resolve_messaging_credential_value_for_save( + form_obj: &Map, + current: &Value, + key: &str, +) -> Option { + let raw_value = form_obj.get(key)?; + let Value::String(raw) = raw_value else { + return Some(raw_value.clone()); + }; + let value = raw.trim(); + if let Some(current_value) = current.get(key) { + if let Some(placeholder) = secret_ref_placeholder(current_value) { + if value.is_empty() || value == placeholder { + return Some(current_value.clone()); + } + } + } + if value.is_empty() { + None + } else { + Some(Value::String(value.to_string())) + } +} + +fn preserve_messaging_credential_refs( + entry: &mut Map, + form_obj: &Map, + current: &Value, +) { + entry.remove("__secretRefs"); + for key in [ + "accessToken", + "appId", + "appPassword", + "appSecret", + "appToken", + "botToken", + "clientId", + "clientSecret", + "gatewayPassword", + "gatewayToken", + "password", + "signingSecret", + "token", + ] { + if !form_obj.contains_key(key) { + continue; + } + match resolve_messaging_credential_value_for_save(form_obj, current, key) { + Some(value) => { + entry.insert(key.into(), value); + } + None => { + entry.remove(key); + } + } + } +} + fn insert_bool_as_string(form: &mut Map, source: &Value, key: &str) { if let Some(v) = source.get(key).and_then(|v| v.as_bool()) { form.insert( @@ -477,6 +582,34 @@ fn gateway_auth_value(cfg: &Value, key: &str) -> Option { .map(|v| v.to_string()) } +fn resolve_platform_config_entry( + channel_root: Option<&Value>, + platform: &str, + account_id: Option<&str>, +) -> Option { + let root = channel_root?; + let account = account_id.map(str::trim).filter(|s| !s.is_empty()); + if let Some(acct) = account { + if let Some(value) = root.get("accounts").and_then(|a| a.get(acct)) { + return Some(value.clone()); + } + if platform_storage_key(platform) == "qqbot" && !qqbot_channel_has_credentials(root) { + return None; + } + return Some(root.clone()); + } + + if platform_storage_key(platform) == "qqbot" && !qqbot_channel_has_credentials(root) { + return root + .get("accounts") + .and_then(|a| a.get(QQBOT_DEFAULT_ACCOUNT_ID)) + .cloned() + .or_else(|| Some(root.clone())); + } + + Some(root.clone()) +} + /// 读取指定平台的当前配置(从 openclaw.json 中提取表单可用的值) /// account_id: 可选,指定时读取 channels..accounts.(多账号模式) #[tauri::command] @@ -492,25 +625,8 @@ pub async fn read_platform_config( // 多账号模式:读凭证位置 // 飞书:credentials 可写在 root 或 accounts. 下,优先找非空那个 let channel_root = cfg.get("channels").and_then(|c| c.get(storage_key)); - let saved = match (&account_id, channel_root) { - // 读指定账号的凭证(accounts.),查不到时再试 root - (Some(acct), Some(ch)) if !acct.is_empty() => { - ch.get("accounts") - .and_then(|a| a.get(acct.as_str())) - .cloned() - .or_else(|| { - // accountId 指定但该账号不存在 → 尝试读 root(可能是旧格式直接写在 root) - ch.get("appId") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(|_| ch.clone()) - }) - .unwrap_or(Value::Null) - } - // 无账号:直接读 channel root(单账号场景) - (_, Some(ch)) => ch.clone(), - _ => Value::Null, - }; + let saved = resolve_platform_config_entry(channel_root, &platform, account_id.as_deref()) + .unwrap_or(Value::Null); let exists = !saved.is_null(); @@ -521,9 +637,7 @@ pub async fn read_platform_config( } // Discord 配置在 openclaw.json 中是展开的 guilds 结构 // 需要反向提取成表单字段:token, guildId, channelId - if let Some(t) = saved.get("token").and_then(|v| v.as_str()) { - form.insert("token".into(), Value::String(t.into())); - } + insert_secret_aware_form_value(&mut form, &saved, "token"); insert_access_policy_form_values(&mut form, &saved, false, false); if let Some(guilds) = saved.get("guilds").and_then(|v| v.as_object()) { if let Some(gid) = guilds.keys().next() { @@ -544,9 +658,7 @@ pub async fn read_platform_config( return Ok(json!({ "exists": false })); } // Telegram: botToken 直接保存, allowFrom 数组需要拼回逗号字符串 - if let Some(t) = saved.get("botToken").and_then(|v| v.as_str()) { - form.insert("botToken".into(), Value::String(t.into())); - } + insert_secret_aware_form_value(&mut form, &saved, "botToken"); insert_access_policy_form_values(&mut form, &saved, true, false); } "qqbot" => { @@ -660,12 +772,8 @@ pub async fn read_platform_config( return Ok(json!({ "exists": false })); } // 飞书凭证:优先从 accounts. 读(多账号),否则从 root 读 - if let Some(v) = saved.get("appId").and_then(|v| v.as_str()) { - form.insert("appId".into(), Value::String(v.into())); - } - if let Some(v) = saved.get("appSecret").and_then(|v| v.as_str()) { - form.insert("appSecret".into(), Value::String(v.into())); - } + insert_secret_aware_form_value(&mut form, &saved, "appId"); + insert_secret_aware_form_value(&mut form, &saved, "appSecret"); // 读 shared fields:优先从 channel root 读(多账号模式下 credentials 在 accounts 下,shared fields 在 root) if let Some(ref acct) = account_id { if !acct.is_empty() { @@ -739,18 +847,10 @@ pub async fn read_platform_config( } } "dingtalk" | "dingtalk-connector" => { - if let Some(v) = saved.get("clientId").and_then(|v| v.as_str()) { - form.insert("clientId".into(), Value::String(v.into())); - } - if let Some(v) = saved.get("clientSecret").and_then(|v| v.as_str()) { - form.insert("clientSecret".into(), Value::String(v.into())); - } - if let Some(v) = saved.get("gatewayToken").and_then(|v| v.as_str()) { - form.insert("gatewayToken".into(), Value::String(v.into())); - } - if let Some(v) = saved.get("gatewayPassword").and_then(|v| v.as_str()) { - form.insert("gatewayPassword".into(), Value::String(v.into())); - } + insert_secret_aware_form_value(&mut form, &saved, "clientId"); + insert_secret_aware_form_value(&mut form, &saved, "clientSecret"); + insert_secret_aware_form_value(&mut form, &saved, "gatewayToken"); + insert_secret_aware_form_value(&mut form, &saved, "gatewayPassword"); match gateway_auth_mode(&cfg) { Some("token") => { if let Some(v) = gateway_auth_value(&cfg, "token") { @@ -769,9 +869,9 @@ pub async fn read_platform_config( } "slack" => { insert_string_if_present(&mut form, &saved, "mode"); - insert_string_if_present(&mut form, &saved, "botToken"); - insert_string_if_present(&mut form, &saved, "appToken"); - insert_string_if_present(&mut form, &saved, "signingSecret"); + insert_secret_aware_form_value(&mut form, &saved, "botToken"); + insert_secret_aware_form_value(&mut form, &saved, "appToken"); + insert_secret_aware_form_value(&mut form, &saved, "signingSecret"); insert_string_if_present(&mut form, &saved, "webhookPath"); insert_string_if_present(&mut form, &saved, "teamId"); insert_string_if_present(&mut form, &saved, "appId"); @@ -794,9 +894,9 @@ pub async fn read_platform_config( } "matrix" => { insert_string_if_present(&mut form, &saved, "homeserver"); - insert_string_if_present(&mut form, &saved, "accessToken"); + insert_secret_aware_form_value(&mut form, &saved, "accessToken"); insert_string_if_present(&mut form, &saved, "userId"); - insert_string_if_present(&mut form, &saved, "password"); + insert_secret_aware_form_value(&mut form, &saved, "password"); insert_string_if_present(&mut form, &saved, "deviceId"); insert_access_policy_form_values(&mut form, &saved, false, false); insert_bool_as_string(&mut form, &saved, "e2ee"); @@ -809,8 +909,8 @@ pub async fn read_platform_config( } } "msteams" => { - insert_string_if_present(&mut form, &saved, "appId"); - insert_string_if_present(&mut form, &saved, "appPassword"); + insert_secret_aware_form_value(&mut form, &saved, "appId"); + insert_secret_aware_form_value(&mut form, &saved, "appPassword"); insert_string_if_present(&mut form, &saved, "tenantId"); insert_string_if_present(&mut form, &saved, "botEndpoint"); insert_string_if_present(&mut form, &saved, "webhookPath"); @@ -827,7 +927,9 @@ pub async fn read_platform_config( if k == "enabled" { continue; } - if let Some(s) = v.as_str() { + if secret_ref_placeholder(v).is_some() { + insert_secret_aware_form_value(&mut form, &saved, k); + } else if let Some(s) = v.as_str() { form.insert(k.clone(), Value::String(s.into())); } else if v.is_array() { insert_array_as_csv(&mut form, &saved, k); @@ -870,6 +972,12 @@ pub async fn save_messaging_platform( let raw_form_obj = form.as_object().ok_or("表单数据格式错误")?; let normalized_form = normalize_messaging_platform_form(&platform, raw_form_obj); let form_obj = &normalized_form; + let current_saved = resolve_platform_config_entry( + channels_map.get(storage_key.as_str()), + &platform, + account_id.as_deref(), + ) + .unwrap_or(Value::Null); // 用于后续创建 bindings 的平台信息 let saved_account_id = account_id.clone(); @@ -925,6 +1033,7 @@ pub async fn save_messaging_platform( } // 合并到现有配置,保留用户通过 CLI 设置的 streaming / retry / dmPolicy 等 + preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); merge_channel_entry(channels_map, "discord", entry); // 仅在首次创建时设置默认值,不覆盖用户已有的设置 if let Some(Value::Object(d)) = channels_map.get_mut("discord") { @@ -954,6 +1063,7 @@ pub async fn save_messaging_platform( ); put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom")); + preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); merge_channel_entry(channels_map, "telegram", entry); } "qqbot" => { @@ -1068,6 +1178,7 @@ pub async fn save_messaging_platform( form_obj.get("resolveSenderNames"), ); put_bool_value_if_present(&mut entry, "requireMention", form_obj.get("requireMention")); + preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); // 多账号模式:写入 channels..accounts. if let Some(ref acct) = account_id { @@ -1136,6 +1247,7 @@ pub async fn save_messaging_platform( ); } + preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); merge_channel_entry(channels_map, &storage_key, entry); ensure_plugin_allowed(&mut cfg, "dingtalk-connector")?; ensure_chat_completions_enabled(&mut cfg)?; @@ -1191,6 +1303,7 @@ pub async fn save_messaging_platform( form_string(form_obj, "groupPolicy"), ); put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom")); + preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); merge_channel_entry(channels_map, &storage_key, entry); } "whatsapp" => { @@ -1226,6 +1339,7 @@ pub async fn save_messaging_platform( form_string(form_obj, "groupPolicy"), ); put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom")); + preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); merge_channel_entry(channels_map, &storage_key, entry); } "matrix" => { @@ -1256,6 +1370,7 @@ pub async fn save_messaging_platform( ); put_bool_from_form(&mut entry, "e2ee", &form_string(form_obj, "e2ee")); put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom")); + preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); merge_channel_entry(channels_map, &storage_key, entry); ensure_plugin_allowed(&mut cfg, "matrix")?; } @@ -1289,6 +1404,7 @@ pub async fn save_messaging_platform( ); put_bool_value_if_present(&mut entry, "requireMention", form_obj.get("requireMention")); put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom")); + preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); merge_channel_entry(channels_map, &storage_key, entry); ensure_plugin_allowed(&mut cfg, "msteams")?; } @@ -1299,6 +1415,7 @@ pub async fn save_messaging_platform( entry.insert(k.clone(), v.clone()); } entry.insert("enabled".into(), Value::Bool(true)); + preserve_messaging_credential_refs(&mut entry, form_obj, ¤t_saved); merge_channel_entry(channels_map, &storage_key, entry); } } @@ -1933,18 +2050,25 @@ const OPENCLAW_QQBOT_EXTENSION_FOLDER: &str = "openclaw-qqbot"; const QQBOT_DEFAULT_ACCOUNT_ID: &str = "default"; fn qqbot_channel_has_credentials(val: &Value) -> bool { - val.get("appId") - .and_then(|v| v.as_str()) - .is_some_and(|s| !s.trim().is_empty()) + val.get("appId").is_some_and(secret_like_value_present) || val .get("clientSecret") .or_else(|| val.get("appSecret")) - .and_then(|v| v.as_str()) - .is_some_and(|s| !s.trim().is_empty()) - || val - .get("token") - .and_then(|v| v.as_str()) - .is_some_and(|s| !s.trim().is_empty()) + .is_some_and(secret_like_value_present) + || val.get("token").is_some_and(secret_like_value_present) +} + +fn secret_like_value_present(value: &Value) -> bool { + value.as_str().is_some_and(|s| !s.trim().is_empty()) || secret_ref_placeholder(value).is_some() +} + +fn account_display_value(value: &Value, key: &str) -> Option { + value.get(key).and_then(|v| { + v.as_str() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .or_else(|| secret_ref_placeholder(v)) + }) } // ── QQ 插件:扩展目录可能是 ~/.openclaw/extensions/openclaw-qqbot(官方包)或旧版 qqbot 目录 ── @@ -2303,8 +2427,11 @@ pub async fn list_configured_platforms() -> Result { if let Some(accts) = val.get("accounts").and_then(|a| a.as_object()) { for (acct_id, acct_val) in accts { let mut entry = json!({ "accountId": acct_id }); - if let Some(app_id) = acct_val.get("appId").and_then(|v| v.as_str()) { - entry["appId"] = Value::String(app_id.to_string()); + if let Some(display_id) = account_display_value(acct_val, "appId") + .or_else(|| account_display_value(acct_val, "clientId")) + .or_else(|| account_display_value(acct_val, "account")) + { + entry["appId"] = Value::String(display_id); } accounts.push(entry); } @@ -4274,4 +4401,70 @@ mod tests { ); assert_eq!(form.get("allowFrom").and_then(|v| v.as_str()), Some("U123")); } + + #[test] + fn channel_form_readback_masks_secret_refs() { + let saved = json!({ + "botToken": { + "source": "env", + "provider": "default", + "id": "TELEGRAM_BOT_TOKEN" + } + }); + let mut form = Map::new(); + insert_secret_aware_form_value(&mut form, &saved, "botToken"); + + assert_eq!( + form.get("botToken").and_then(|v| v.as_str()), + Some("SecretRef(env:default:TELEGRAM_BOT_TOKEN)") + ); + assert_eq!( + form.get("__secretRefs") + .and_then(|v| v.get("botToken")) + .cloned(), + saved.get("botToken").cloned() + ); + } + + #[test] + fn channel_save_preserves_unchanged_secret_ref_placeholder() { + let current = json!({ + "botToken": { + "source": "env", + "provider": "default", + "id": "SLACK_BOT_TOKEN" + } + }); + let form = json!({ + "botToken": "SecretRef(env:default:SLACK_BOT_TOKEN)" + }); + let value = resolve_messaging_credential_value_for_save( + form.as_object().expect("object"), + ¤t, + "botToken", + ); + + assert_eq!(value, current.get("botToken").cloned()); + } + + #[test] + fn channel_save_replaces_secret_ref_when_user_enters_new_secret() { + let current = json!({ + "token": { + "source": "env", + "provider": "default", + "id": "DISCORD_BOT_TOKEN" + } + }); + let form = json!({ + "token": "new-discord-token" + }); + let value = resolve_messaging_credential_value_for_save( + form.as_object().expect("object"), + ¤t, + "token", + ); + + assert_eq!(value, Some(Value::String("new-discord-token".into()))); + } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 54a5f6a..54938c5 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -152,10 +152,18 @@ fn panel_config_candidate_paths() -> Vec { paths } +fn read_json_file_content(path: &std::path::Path) -> Option { + let raw = std::fs::read(path).ok()?; + let bytes = if raw.starts_with(&[0xEF, 0xBB, 0xBF]) { + &raw[3..] + } else { + &raw + }; + Some(String::from_utf8_lossy(bytes).into_owned()) +} + fn read_panel_config_from(path: &std::path::Path) -> Option { - std::fs::read_to_string(path) - .ok() - .and_then(|content| serde_json::from_str(&content).ok()) + read_json_file_content(path).and_then(|content| serde_json::from_str(&content).ok()) } fn normalize_custom_openclaw_dir(raw: &str) -> Option { @@ -230,7 +238,7 @@ pub fn gateway_listen_port() -> u16 { fn read_gateway_port_from_config() -> u16 { let config_path = openclaw_dir().join("openclaw.json"); - if let Ok(content) = std::fs::read_to_string(&config_path) { + if let Some(content) = read_json_file_content(&config_path) { if let Ok(val) = serde_json::from_str::(&content) { if let Some(port) = val .get("gateway") diff --git a/src/locales/modules/channels.js b/src/locales/modules/channels.js index faf0d45..63b1aec 100644 --- a/src/locales/modules/channels.js +++ b/src/locales/modules/channels.js @@ -303,6 +303,7 @@ export default { preActionsHint: _('适用于需要先执行 CLI 登录、扫码或初始化命令的渠道。', 'For channels that require CLI login, scanning, or initialization commands first.', '適用於需要先執行 CLI 登入、掃碼或初始化命令的頻道。'), gatewayAuthAutoFilled: _('已从当前 Gateway 鉴权配置中自动带出 {type},通常无需手填', 'Auto-filled {type} from current Gateway auth config; usually no need to fill manually', '已從目前 Gateway 鉴權設定中自動帶出 {type},通常無需手填'), existingConfigHint: _('当前已有配置,修改后点击保存即可覆盖', 'Existing config found; edit and click Save to overwrite', '目前已有設定,修改后点擊儲存即可覆蓋'), + secretRefPreserveHint: _('当前密钥由 SecretRef 管理;保持占位不变会保留原引用,输入新值才会替换。', 'This secret is managed by SecretRef. Leave the placeholder unchanged to keep the reference; enter a new value to replace it.', '目前密鑰由 SecretRef 管理;保持占位不變會保留原引用,輸入新值才會替換。'), fullDiagnose: _('完整联通诊断', 'Full Connectivity Diagnosis', '完整聯通诊斷'), qqDiagHint: _('检查已保存到配置文件的凭证、本机 Gateway 端口、/__api/health、QQ 插件与 chatCompletions。QQ 提示「灵魂不在线」时优先看此处,并参考 OpenClaw × QQ 常见问题。', '检查已保存到配置文件的凭证、本机 Gateway 端口、/__api/health、QQ 插件与 chatCompletions。QQ 提示「灵魂不在线」时优先看此处,并参考 OpenClaw × QQ 常见问题。', '檢查已儲存到設定檔案的憑證、本機 Gateway 連接埠、/__api/health、QQ 外掛與 chatCompletions。QQ 提示「靈魂不線上」時優先看此處,並參考 OpenClaw × QQ 常见問題。'), edit: _('编辑', 'Edit', '編輯', '編集', '편집', 'Sửa', 'Editar', 'Editar', 'Редактировать', 'Modifier', 'Bearbeiten'), diff --git a/src/pages/channels.js b/src/pages/channels.js index 90823e6..334fcf9 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -1966,6 +1966,11 @@ async function openConfigDialog(pid, page, state, accountId) { const fieldsHtml = reg.fields.map((f, i) => { const val = existing[f.key] || '' + const secretRefLocked = existing.__secretRefs?.[f.key] + const fieldHint = [ + f.hint, + secretRefLocked ? t('channels.secretRefPreserveHint') : '', + ].filter(Boolean).join('
') if (f.type === 'select' && f.options) { return `
@@ -1986,7 +1991,7 @@ async function openConfigDialog(pid, page, state, accountId) { ${i === 0 ? 'autofocus' : ''} style="flex:1"> ${f.secret ? `` : ''}
- ${f.hint ? `
${f.hint}
` : ''} + ${fieldHint ? `
${fieldHint}
` : ''} ` }).join('') diff --git a/tests/channel-config-normalization.test.js b/tests/channel-config-normalization.test.js index 1de2b2b..4f4b2ff 100644 --- a/tests/channel-config-normalization.test.js +++ b/tests/channel-config-normalization.test.js @@ -3,6 +3,8 @@ import assert from 'node:assert/strict' import { buildMessagingPlatformFormValues, + listPlatformAccounts, + resolveMessagingCredentialValueForSave, normalizeMessagingPlatformForm, } from '../scripts/dev-api.js' @@ -159,3 +161,66 @@ test('渠道保存会在用户改回所有群组时显式清除仅提及开关', assert.equal(form.groupPolicy, 'open') assert.equal(form.requireMention, false) }) + +test('渠道读取会把 SecretRef 密钥显示为安全占位并携带原始对象', () => { + const secretRef = { source: 'env', provider: 'default', id: 'TELEGRAM_BOT_TOKEN' } + const values = buildMessagingPlatformFormValues('telegram', { + botToken: secretRef, + dmPolicy: 'pairing', + groupPolicy: 'allowlist', + }) + + assert.equal(values.botToken, 'SecretRef(env:default:TELEGRAM_BOT_TOKEN)') + assert.deepEqual(values.__secretRefs, { botToken: secretRef }) +}) + +test('渠道保存时用户未改动 SecretRef 占位会保留原始密钥引用', () => { + const secretRef = { source: 'env', provider: 'default', id: 'SLACK_BOT_TOKEN' } + const value = resolveMessagingCredentialValueForSave({ + form: { botToken: 'SecretRef(env:default:SLACK_BOT_TOKEN)' }, + current: { botToken: secretRef }, + key: 'botToken', + }) + + assert.deepEqual(value, secretRef) +}) + +test('渠道保存时用户输入新密钥会替换旧 SecretRef', () => { + const secretRef = { source: 'env', provider: 'default', id: 'DISCORD_BOT_TOKEN' } + const value = resolveMessagingCredentialValueForSave({ + form: { token: 'new-discord-token' }, + current: { token: secretRef }, + key: 'token', + }) + + assert.equal(value, 'new-discord-token') +}) + +test('渠道账号列表会把 SecretRef 标识显示为安全占位', () => { + const accounts = listPlatformAccounts({ + accounts: { + prod: { + appId: { source: 'env', provider: 'default', id: 'FEISHU_APP_ID' }, + }, + backup: { + clientId: { source: 'env', provider: 'default', id: 'DINGTALK_CLIENT_ID' }, + }, + }, + }) + + assert.deepEqual(accounts, [ + { accountId: 'backup', appId: 'SecretRef(env:default:DINGTALK_CLIENT_ID)' }, + { accountId: 'prod', appId: 'SecretRef(env:default:FEISHU_APP_ID)' }, + ]) +}) + +test('渠道保存时 clientId 未改动 SecretRef 占位会保留原始引用', () => { + const secretRef = { source: 'env', provider: 'default', id: 'DINGTALK_CLIENT_ID' } + const value = resolveMessagingCredentialValueForSave({ + form: { clientId: 'SecretRef(env:default:DINGTALK_CLIENT_ID)' }, + current: { clientId: secretRef }, + key: 'clientId', + }) + + assert.deepEqual(value, secretRef) +}) diff --git a/tests/dev-api-cli-conflict.test.js b/tests/dev-api-cli-conflict.test.js index 025f87f..fa78462 100644 --- a/tests/dev-api-cli-conflict.test.js +++ b/tests/dev-api-cli-conflict.test.js @@ -7,8 +7,24 @@ import path from 'node:path' import { buildOpenclawPathConflictRecords, quarantineOpenclawPathForWeb, + readJsonFileRelaxed, } from '../scripts/dev-api.js' +test('Web API JSON 读取会兼容 UTF-8 BOM', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-json-bom-')) + try { + const filePath = path.join(tmp, 'openclaw.json') + fs.writeFileSync(filePath, Buffer.concat([ + Buffer.from([0xEF, 0xBB, 0xBF]), + Buffer.from(JSON.stringify({ gateway: { port: 18790 } }), 'utf8'), + ])) + + assert.deepEqual(readJsonFileRelaxed(filePath), { gateway: { port: 18790 } }) + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +}) + test('Web API CLI 冲突扫描会返回横幅需要的字段', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-cli-conflict-')) try { diff --git a/vite.config.js b/vite.config.js index 8c57bb4..73769b3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,5 +1,5 @@ import { defineConfig } from 'vite' -import { devApiPlugin } from './scripts/dev-api.js' +import { devApiPlugin, readJsonFileRelaxed } from './scripts/dev-api.js' import fs from 'fs' import path from 'path' import { homedir } from 'os' @@ -13,7 +13,7 @@ let gatewayPort = 18789 try { const cfgPath = path.join(homedir(), '.openclaw', 'openclaw.json') if (fs.existsSync(cfgPath)) { - const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')) + const cfg = readJsonFileRelaxed(cfgPath) // 端口必须 > 0 且 < 65536 const port = cfg?.gateway?.port if (port && typeof port === 'number' && port > 0 && port < 65536) {