mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 04:10:00 +08:00
fix(channels): preserve SecretRef credentials
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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, ¤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.<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, ¤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<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"),
|
||||
¤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())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user