fix(web): support openclaw path conflict scan

This commit is contained in:
晴天
2026-05-23 00:20:47 +08:00
parent 1ae223a0b1
commit f4d644ea06
2 changed files with 190 additions and 0 deletions

View File

@@ -632,6 +632,96 @@ function scanAllOpenclawInstallations(activePath = resolveOpenclawCliPath()) {
})
}
function sourceLabelForCliConflict(source) {
switch (source) {
case 'cherrystudio': return 'Cherry Studio 内嵌'
case 'cursor': return 'Cursor 内嵌'
case 'npm-zh': return 'npm 汉化版安装'
case 'npm-official':
case 'npm-global': return 'npm 官方/全局安装'
case 'standalone': return 'ClawPanel standalone'
default: return '未识别来源'
}
}
function canonicalLowerPathForConflict(rawPath) {
const normalized = normalizeCliPath(rawPath)
if (!normalized) return ''
let resolved = normalized
try { resolved = fs.realpathSync.native(normalized) } catch {}
let text = resolved.replace(/\\/g, '/').toLowerCase()
if (text.startsWith('//?/')) text = text.slice(4)
while (text.endsWith('/')) text = text.slice(0, -1)
return text
}
function standaloneConflictDirs() {
const dirs = []
try { dirs.push(standaloneInstallDir()) } catch {}
dirs.push(path.join(homedir(), '.openclaw-bin'))
return dirs.map(canonicalLowerPathForConflict).filter(Boolean)
}
function isStandaloneConflictPath(cliPath, source = '') {
if (source === 'standalone') return true
const canon = canonicalLowerPathForConflict(cliPath)
if (!canon) return false
return standaloneConflictDirs().some(dir => canon === dir || canon.startsWith(`${dir}/`))
}
export function buildOpenclawPathConflictRecords(installations = scanAllOpenclawInstallations()) {
const seen = new Set()
const records = []
for (const item of Array.isArray(installations) ? installations : []) {
const cliPath = item?.path
if (!cliPath || isStandaloneConflictPath(cliPath, item.source)) continue
const key = canonicalLowerPathForConflict(cliPath) || String(cliPath)
if (seen.has(key)) continue
seen.add(key)
const stat = (() => {
try { return fs.statSync(cliPath) } catch { return null }
})()
records.push({
path: cliPath,
source: item.source || classifyCliSource(cliPath) || 'unknown',
sourceLabel: sourceLabelForCliConflict(item.source || classifyCliSource(cliPath) || 'unknown'),
version: item.version || readVersionFromInstallation(cliPath) || null,
sizeBytes: stat?.isFile() ? stat.size : null,
})
}
return records
}
function formatConflictTimestamp(now = new Date()) {
const pad = n => String(n).padStart(2, '0')
return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
}
export function quarantineOpenclawPathForWeb(rawPath, options = {}) {
const original = normalizeCliPath(rawPath)
if (!original || !fs.existsSync(original)) throw new Error(`文件不存在: ${rawPath}`)
const stat = fs.statSync(original)
if (!stat.isFile()) throw new Error(`不是文件: ${rawPath}`)
if (isStandaloneConflictPath(original, classifyCliSource(original))) {
throw new Error('拒绝隔离 standalone 安装目录下的 OpenClaw这是当前运行版本')
}
const fileName = path.basename(original)
if (!fileName.toLowerCase().startsWith('openclaw')) {
throw new Error(`拒绝隔离非 openclaw 文件: ${fileName}`)
}
const ts = formatConflictTimestamp(options.now || new Date())
const quarantinedPath = path.join(path.dirname(original), `${fileName}.disabled-by-clawpanel-${ts}.bak`)
if (fs.existsSync(quarantinedPath)) {
throw new Error(`目标文件已存在,请稍后再试: ${quarantinedPath}`)
}
fs.renameSync(original, quarantinedPath)
return {
originalPath: original,
quarantinedPath,
quarantinedAt: new Date().toISOString(),
}
}
function resolveOpenclawCliInput(rawPath) {
const normalized = normalizeCliPath(rawPath)
if (!normalized) return null
@@ -6957,6 +7047,27 @@ const handlers = {
return scanAllOpenclawInstallations()
},
scan_openclaw_path_conflicts() {
return buildOpenclawPathConflictRecords()
},
quarantine_openclaw_path({ path: targetPath } = {}) {
return quarantineOpenclawPathForWeb(targetPath)
},
quarantine_openclaw_paths_bulk({ paths = [] } = {}) {
const records = []
const failed = []
for (const targetPath of Array.isArray(paths) ? paths : []) {
try {
records.push(quarantineOpenclawPathForWeb(targetPath))
} catch (e) {
failed.push({ path: targetPath, error: e?.message || String(e) })
}
}
return { records, failed }
},
check_openclaw_at_path({ cliPath }) {
const resolved = resolveOpenclawCliInput(cliPath)
if (!resolved) {

View File

@@ -0,0 +1,79 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import {
buildOpenclawPathConflictRecords,
quarantineOpenclawPathForWeb,
} from '../scripts/dev-api.js'
test('Web API CLI 冲突扫描会返回横幅需要的字段', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-cli-conflict-'))
try {
const cliPath = path.join(tmp, process.platform === 'win32' ? 'openclaw.cmd' : 'openclaw')
fs.writeFileSync(cliPath, process.platform === 'win32' ? '@echo off\r\n' : '#!/bin/sh\n')
const pkgDir = path.join(tmp, 'node_modules', 'openclaw')
fs.mkdirSync(pkgDir, { recursive: true })
fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name: 'openclaw', version: '9.9.9' }))
const records = buildOpenclawPathConflictRecords([{
path: cliPath,
source: 'npm-official',
version: '9.9.9',
active: false,
}])
assert.equal(records.length, 1)
assert.equal(records[0].path, cliPath)
assert.equal(records[0].source, 'npm-official')
assert.equal(records[0].sourceLabel, 'npm 官方/全局安装')
assert.equal(records[0].version, '9.9.9')
assert.equal(typeof records[0].sizeBytes, 'number')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('Web API CLI 冲突扫描会排除 standalone 安装', () => {
const records = buildOpenclawPathConflictRecords([{
path: path.join(os.tmpdir(), 'openclaw-standalone', 'openclaw.cmd'),
source: 'standalone',
version: '1.0.0',
active: true,
}])
assert.deepEqual(records, [])
})
test('Web API CLI 隔离只允许 openclaw 文件并保留可恢复备份', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-cli-quarantine-'))
try {
const cliPath = path.join(tmp, process.platform === 'win32' ? 'openclaw.cmd' : 'openclaw')
fs.writeFileSync(cliPath, 'echo openclaw\n')
const record = quarantineOpenclawPathForWeb(cliPath, { now: new Date('2026-05-23T00:00:00Z') })
assert.equal(record.originalPath, cliPath)
assert.match(path.basename(record.quarantinedPath), /^openclaw(\.cmd)?\.disabled-by-clawpanel-\d{8}-\d{6}\.bak$/)
assert.equal(fs.existsSync(cliPath), false)
assert.equal(fs.existsSync(record.quarantinedPath), true)
assert.ok(record.quarantinedAt)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('Web API CLI 隔离拒绝非 openclaw 文件', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-cli-quarantine-deny-'))
try {
const filePath = path.join(tmp, 'node.cmd')
fs.writeFileSync(filePath, 'echo node\n')
assert.throws(() => quarantineOpenclawPathForWeb(filePath), /拒绝隔离非 openclaw 文件/)
assert.equal(fs.existsSync(filePath), true)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})