fix(channels): preserve SecretRef credentials

This commit is contained in:
晴天
2026-05-23 02:23:52 +08:00
parent eccf91ed1e
commit 6aa7a05f36
8 changed files with 472 additions and 89 deletions

View File

@@ -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 }

View File

@@ -57,6 +57,111 @@ fn insert_string_if_present(form: &mut Map<String, Value>, 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<String> {
let (source, provider, id) = secret_ref_parts(value)?;
Some(format!("SecretRef({}:{}:{})", source, provider, id))
}
fn insert_secret_aware_form_value(form: &mut Map<String, Value>, 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<String, Value>,
current: &Value,
key: &str,
) -> Option<Value> {
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<String, Value>,
form_obj: &Map<String, Value>,
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<String, Value>, 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<String> {
.map(|v| v.to_string())
}
fn resolve_platform_config_entry(
channel_root: Option<&Value>,
platform: &str,
account_id: Option<&str>,
) -> Option<Value> {
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.<platform>.accounts.<account_id>(多账号模式)
#[tauri::command]
@@ -492,25 +625,8 @@ pub async fn read_platform_config(
// 多账号模式:读凭证位置
// 飞书credentials 可写在 root 或 accounts.<id> 下,优先找非空那个
let channel_root = cfg.get("channels").and_then(|c| c.get(storage_key));
let saved = match (&account_id, channel_root) {
// 读指定账号的凭证accounts.<id>),查不到时再试 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.<id> 读(多账号),否则从 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, &current_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, &current_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, &current_saved);
// 多账号模式:写入 channels.<storage_key>.accounts.<account_id>
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, &current_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, &current_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, &current_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, &current_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, &current_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, &current_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<String> {
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<Value, String> {
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"),
&current,
"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"),
&current,
"token",
);
assert_eq!(value, Some(Value::String("new-discord-token".into())));
}
}

View File

@@ -152,10 +152,18 @@ fn panel_config_candidate_paths() -> Vec<PathBuf> {
paths
}
fn read_json_file_content(path: &std::path::Path) -> Option<String> {
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<serde_json::Value> {
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<PathBuf> {
@@ -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::<serde_json::Value>(&content) {
if let Some(port) = val
.get("gateway")

View File

@@ -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: _('检查<strong>已保存到配置文件</strong>的凭证、本机 Gateway 端口、<code>/__api/health</code>、QQ 插件与 chatCompletions。QQ 提示「灵魂不在线」时优先看此处,并参考 <a href="https://q.qq.com/qqbot/openclaw/faq.html" target="_blank" rel="noopener">OpenClaw × QQ 常见问题</a>。', '检查<strong>已保存到配置文件</strong>的凭证、本机 Gateway 端口、<code>/__api/health</code>、QQ 插件与 chatCompletions。QQ 提示「灵魂不在线」时优先看此处,并参考 <a href="https://q.qq.com/qqbot/openclaw/faq.html" target="_blank" rel="noopener">OpenClaw × QQ 常见问题</a>。', '檢查<strong>已儲存到設定檔案</strong>的憑證、本機 Gateway 連接埠、<code>/__api/health</code>、QQ 外掛與 chatCompletions。QQ 提示「靈魂不線上」時優先看此處,並參考 <a href="https://q.qq.com/qqbot/openclaw/faq.html" target="_blank" rel="noopener">OpenClaw × QQ 常见問題</a>。'),
edit: _('编辑', 'Edit', '編輯', '編集', '편집', 'Sửa', 'Editar', 'Editar', 'Редактировать', 'Modifier', 'Bearbeiten'),

View File

@@ -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('<br>')
if (f.type === 'select' && f.options) {
return `
<div class="form-group">
@@ -1986,7 +1991,7 @@ async function openConfigDialog(pid, page, state, accountId) {
${i === 0 ? 'autofocus' : ''} style="flex:1">
${f.secret ? `<button type="button" class="btn btn-sm btn-secondary toggle-vis" data-field="${f.key}">${t('channels.show')}</button>` : ''}
</div>
${f.hint ? `<div class="form-hint">${f.hint}</div>` : ''}
${fieldHint ? `<div class="form-hint">${fieldHint}</div>` : ''}
</div>
`
}).join('')

View File

@@ -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)
})

View File

@@ -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 {

View File

@@ -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) {