Files
dsh_shenxian/scripts/ensure-anysearch-admin.cjs
T
admin 452924d89c feat(config): 涉密内容外置到配置目录(档案 140)
把散落在代码里的真实部署值统一收进 config/,代码改为引用配置,
使仓库副本/开源导出不再带出生产域名、IP、内网路径与凭据。

新增 config/:platform.env.example(模板)· load.sh(shell 加载器)·
index.cjs(node 加载器)· README.md(键一览与优先级)。
真实值放 config/platform.env —— 已 .gitignore 排除,不入库、不进导出。

TS 侧新增 src/platform-paths.ts 作部署路径的唯一解析处(零副作用):
platformDir/stateDir/backupDir/artifactDir/installDir/scriptPath。
config.ts 接入这些字段;内置中继种子由生产 URL 改为空(改由
DSHS_OVERLAY_BOOTSTRAP_SEEDS 提供)。修掉 5 处硬编码绝对路径,
src/** 注释中性化 116 行/53 文件。

scripts/** 36 个内部运维脚本:真令牌/PG 口令/隧道目标/主机号/路径
一律改从配置取;web/wake.html 的注册域白名单改为运行时从
location.hostname 推导;test/** 夹具 119 行/13 文件改 RFC 2606/5737
保留值,并把「内置种子必须为空」固化为回归断言。

取证:tsc 0 错;npm test 373/375(唯一失败 lease 属既有);
全仓扫描(大小写不敏感)代码面涉密标识 = 0;已部署 47 并零回归
(/opt/dsh/* 未搬家,/var/lib/dshs/platform 未被误建)。
2026-09-19 15:12:19 +08:00

312 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
const cfg = require('../config/index.cjs')
/**
* ensure-anysearch-admin.cjs —— 给指定实例接入 AnySearch 联网搜索(档案 64 · B 方案)
*
* 背景:平台现用 DeepSeek 官方搜索(`dsh-web-search-deepseek`,走 Anthropic Messages
* 的原生 `web_search` server tool)——**一次搜索 = 一次模型 turn**,成本偏高。本脚本把
* `@anysearch/anysearch-dsh` 装进目标用户 profile,并把 key 写进该用户的 dsh 凭据文件,
* 让 `web_search` 改走 AnySearch REST `/v1/search`(返回结构化 title/url/snippet)。
*
* 三个设计点(用户 2026-09-12 拍板):
* ① **分两步走**:先只装 admin 验证效果与配额,确认后再铺普通用户 —— 故默认只处理 admin。
* ② **保留本地抓取**:插件自带的 patch 会把 `fetchProvider` 也换成 anysearch;这里在
* profile 层追加覆写段把它拉回本地 `http`(保留 SSRF 防护:拒非公网地址、逐跳校验重定向)。
* ③ **key 只写该用户自己的 `.credentials.yaml`**(`refs` 段按环境变量名存),
* 不注入实例 env、不改平台 `baseEnv` —— 少一处外泄点。
*
* 顺序不可颠倒:**先写 key、再装插件**。profile 的 `patchReload: live` 有热加载可能,
* 反序会出现「provider 已切到 anysearch、key 还没配」的窗口。
*
* 用法:
* node ensure-anysearch-admin.cjs # 干跑(只打印计划,默认目标 admin)
* node ensure-anysearch-admin.cjs --apply # 执行(写 key + patch + 装插件)
* node ensure-anysearch-admin.cjs --apply --restart # 执行并停实例(新 bundle 才生效)
* node ensure-anysearch-admin.cjs --apply --restart guest # 第二步:铺普通用户
*
* key 从环境变量 `ANYSEARCH_API_KEY` 读,**不落盘到本脚本**:
* ANYSEARCH_API_KEY=as_sk_… node ensure-anysearch-admin.cjs --apply --restart
*
* 幂等:凭据已含该 key / patch 已含管理标记 / 依赖已是该 tgz → 各自跳过。
*/
// ─────────────────────────────────────────────────────────────────────────────
// ⛔ 2026-09-13 已退役(档案 65 §7.3 第 3 条 · 档案 70 §九)
// AnySearch 已按用户决定【彻底放弃】(候选池条目下架 + 存量插件下架)。
// 本脚本「写 provider 覆写段 + 装 anysearch 插件」的职责,已由**平台托管段**
// (档案 65:功能插件启停 ↔ web provider 配置联动)接管。
// 若两段并存,后者会覆盖前者 → 产生难以察觉的配置漂移,故在此**硬拦**。
// 确需运行(考古 / 回滚)时,显式设 DSH_ALLOW_RETIRED_ANYSEARCH=1。
// ─────────────────────────────────────────────────────────────────────────────
if (process.env.DSH_ALLOW_RETIRED_ANYSEARCH !== "1") {
console.error("⛔ ensure-anysearch-admin.cjs 已退役:AnySearch 已彻底放弃,provider 托管段由平台(档案 65)接管。");
console.error(" 确需运行请显式设置 DSH_ALLOW_RETIRED_ANYSEARCH=1(仅考古/回滚用)。");
process.exit(2);
}
const { execFileSync } = require('node:child_process')
const {
chmodSync, chownSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync,
} = require('node:fs')
const { basename, dirname, join, resolve } = require('node:path')
const Database = require('better-sqlite3')
const DB_PATH = cfg.dbFile()
const ART_DIR = cfg.artifactDir()
const PKG = '@anysearch/anysearch-dsh'
const PROFILE = 'web'
const KEY_ENV = 'ANYSEARCH_API_KEY'
const MARK = 'platform: anysearch-search'
const PATCH_BLOCK = [
'',
`# >>> ${MARK} (managed by ensure-anysearch-admin.cjs)`,
'# 只换 search:AnySearch 提供联网搜索;fetch 仍走本地 http provider(保留 SSRF 防护)。',
'# ⚠ 本块对 dsh-web 条目是 **整体替换 config**(非字段级合并),所以两个字段都必须写全:',
'# 实测只写 fetchProvider 时,插件自带 patch 的 searchProvider 会被一并冲掉,',
'# 而 search registry 上 deepseek-official 与 anysearch 都 available()=true、又无显式选择,',
'# → 命中 WEB_PROVIDER_AMBIGUOUS(不是自动选一个,是直接报错)。',
'- id: web',
' config:',
' searchProvider: anysearch',
' fetchProvider: http',
`# <<< ${MARK}`,
'',
].join('\n')
const argv = process.argv.slice(2)
const APPLY = argv.includes('--apply')
const RESTART = argv.includes('--restart')
const positional = []
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a === '--user') { positional.push(argv[++i] ?? ''); continue }
if (a.startsWith('--')) continue
positional.push(a)
}
const wanted = positional.filter(Boolean)
const KEY = process.env[KEY_ENV] ?? ''
/** 取产物目录里版本号最大的 anysearch-dsh-*.tgz。 */
function artifactPath() {
const prefix = 'anysearch-dsh-'
const cands = readdirSync(ART_DIR).filter((f) => f.startsWith(prefix) && f.endsWith('.tgz'))
if (cands.length === 0) throw new Error(`no ${prefix}*.tgz under ${ART_DIR}`)
const ver = (f) => f.slice(prefix.length, -4).split('.').map(Number)
cands.sort((a, b) => {
const va = ver(a); const vb = ver(b)
for (let i = 0; i < 3; i++) { const x = va[i] || 0; const y = vb[i] || 0; if (x !== y) return x - y }
return 0
})
return join(ART_DIR, cands[cands.length - 1])
}
/** 读既有 node_modules 的 storeDir(沿用旧 store,否则 pnpm 会要求全量重装)。 */
function existingStoreDir(profileDir) {
try {
const txt = readFileSync(join(profileDir, 'node_modules', '.modules.yaml'), 'utf8')
const m = /^storeDir:\s*(.+)$/m.exec(txt)
return m ? m[1].trim() : ''
} catch { return '' }
}
/** 摘掉指向不存在文件的 `file:` 依赖,否则 pnpm add 会在解析阶段 ENOENT 失败(档案 63)。 */
function pruneBrokenFileDeps(pkgPath) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
const deps = pkg.dependencies ?? {}
const removed = []
for (const [k, v] of Object.entries(deps)) {
if (typeof v !== 'string' || !v.startsWith('file:')) continue
const abs = resolve(dirname(pkgPath), v.slice('file:'.length))
if (!existsSync(abs)) { delete deps[k]; removed.push(`${k} → ${v}`) }
}
if (removed.length > 0) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
return removed
} catch { return [] }
}
function isBundle(dir, dep) {
try {
const pkg = JSON.parse(readFileSync(join(dir, 'node_modules', dep, 'package.json'), 'utf8'))
return pkg.dsh?.bundle?.patch !== undefined
} catch { return false }
}
/** 对齐 dsh plugin add 的 reconcile:dependencies 里带 dsh.bundle.patch 的进 bundles。 */
function reconcileBundles(dir) {
const path = join(dir, 'package.json')
const pkg = JSON.parse(readFileSync(path, 'utf8'))
const deps = Object.keys(pkg.dependencies ?? {})
const bundles = pkg.dsh?.profile?.bundles ?? []
const kept = bundles.filter((b) => b.startsWith('@deepseek-ai/') || deps.includes(b))
for (const dep of deps) if (!kept.includes(dep) && isBundle(dir, dep)) kept.push(dep)
pkg.dsh = pkg.dsh ?? {}
pkg.dsh.profile = pkg.dsh.profile ?? {}
pkg.dsh.profile.bundles = kept
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
return kept
}
/** 停掉该 uid 名下所有 dsh scope(下次访问由编排器自动拉起)。 */
function stopInstance(uid) {
let out = ''
try {
out = execFileSync('systemctl', ['list-units', '--type=scope', '--all', '--no-legend', '--plain'], { encoding: 'utf8' })
} catch { return 0 }
let n = 0
for (const line of out.split('\n')) {
const unit = line.trim().split(/\s+/)[0]
if (!unit || !unit.startsWith(`dsh-${uid}-`) || !unit.endsWith('.scope')) continue
try {
execFileSync('systemctl', ['stop', unit], { stdio: 'pipe' })
execFileSync('systemctl', ['reset-failed', unit], { stdio: 'pipe' })
n += 1
} catch { /* 单个 scope 停不掉不阻断 */ }
}
return n
}
/**
* 在凭据文档里放一个 `refs.<ENV>` 条目(refs 段按环境变量名存 key 值)。
* 文档只有 `version` / `refs` / `records` 三个顶层段,其余一律拒绝加载(dsh 的行为)。
* 已存在同名条目 → 跳过(不覆盖用户可能已改过的值)。
*/
function ensureCredential(credPath, value) {
const text = readFileSync(credPath, 'utf8')
if (new RegExp(`(^|\\n)\\s*${KEY_ENV}:`, 'm').test(text)) return 'present'
let next
if (/^refs:[ \t]*$/m.test(text)) {
next = text.replace(/^refs:[ \t]*$/m, `refs:\n ${KEY_ENV}: ${value}`)
} else if (/^version: 1[ \t]*$/m.test(text)) {
next = text.replace(/^version: 1[ \t]*$/m, `version: 1\nrefs:\n ${KEY_ENV}: ${value}`)
} else {
throw new Error('凭据文档结构异常:找不到 "version: 1" 行,拒绝写入')
}
writeFileSync(credPath, next)
return 'inserted'
}
/** 幂等写入覆写段:无则追加,有但内容落后(缺字段)则原地替换整块。 */
function ensurePatch(patchPath) {
const text = readFileSync(patchPath, 'utf8')
const open = `# >>> ${MARK}`
const close = `# <<< ${MARK}`
const start = text.indexOf(open)
const end = text.indexOf(close)
if (start >= 0 && end > start) {
const tail = end + close.length
const next = text.slice(0, start) + PATCH_BLOCK.trimStart() + text.slice(tail)
if (next === text) return 'present'
writeFileSync(patchPath, next)
return 'updated'
}
writeFileSync(patchPath, text.replace(/\s*$/, '\n') + PATCH_BLOCK)
return 'appended'
}
// ---- main ----
if (!existsSync(DB_PATH)) { console.error('✗ 找不到平台 DB'); process.exit(1) }
const db = new Database(DB_PATH, { readonly: true })
const all = db.prepare('SELECT id, username, uid, role, home_dir FROM users').all()
db.close()
const users = all.filter((u) => (wanted.length > 0
? (wanted.includes(u.username) || wanted.includes(u.id) || wanted.includes(String(u.uid)))
: u.username === 'admin'))
if (users.length === 0) { console.error(`✗ 没匹配到用户:${wanted.join(',') || 'admin'}`); process.exit(1) }
if (APPLY && KEY === '') { console.error(`✗ 需要环境变量 ${KEY_ENV} 提供 key`); process.exit(1) }
let artifact
try { artifact = artifactPath() } catch (err) { console.error(`✗ ${err.message}`); process.exit(1) }
console.log(`artifact = ${artifact}`)
console.log(`mode = ${APPLY ? 'APPLY' : 'DRY-RUN'}${RESTART ? ' + RESTART' : ''}`)
console.log(`targets = ${users.map((u) => `${u.username}(uid ${u.uid})`).join(', ')}`)
for (const u of users) {
console.log(`\n=== ${u.username} (uid ${u.uid}) ===`)
const root = join(u.home_dir, '..')
const ws = join(root, 'ws')
const dir = join(u.home_dir, 'profiles', PROFILE)
const pkgPath = join(dir, 'package.json')
if (!existsSync(dir) || !existsSync(pkgPath)) { console.log(' · 无 profile(未首登)→ 跳过'); continue }
const credPath = join(u.home_dir, '.credentials.yaml')
const patchPath = join(dir, 'cordis.patch.yml')
const pkg0 = JSON.parse(readFileSync(pkgPath, 'utf8'))
const hasDep = Object.keys(pkg0.dependencies ?? {}).includes(PKG)
const inBundles = (pkg0.dsh?.profile?.bundles ?? []).includes(PKG)
const staged = join(u.home_dir, '.dsh-stage', basename(artifact))
console.log(` [计划] 凭据 ${credPath} → refs.${KEY_ENV}`)
console.log(` [计划] patch ${patchPath} → 追加 fetchProvider: http 覆写段`)
console.log(` [计划] 安装 ${PKG}(现 deps=${hasDep ? '有' : '无'} / bundles=${inBundles ? '有' : '无'})`)
console.log(` [计划] 停实例 scope:${RESTART ? '是' : '否'}`)
if (!APPLY) continue
// 1) 先写 key(顺序不可颠倒)
if (!existsSync(credPath)) { console.log(` ✗ 缺凭据文件 ${credPath} → 跳过该用户`); continue }
const credBackup = `${credPath}.bak-anysearch`
if (!existsSync(credBackup)) { copyFileSync(credPath, credBackup); chmodSync(credBackup, 0o600) }
try {
const credAction = ensureCredential(credPath, KEY)
chownSync(credPath, u.uid, u.uid)
chmodSync(credPath, 0o600)
console.log(` ✓ 凭据 ${credAction}(已恢复 600 + uid ${u.uid})`)
} catch (err) {
console.log(` ✗ 凭据写入失败:${err.message} → 跳过该用户(插件未装,避免无 key 窗口)`)
continue
}
// 2) 再写 profile patch(保留本地 fetch)
if (existsSync(patchPath)) {
const patchBackup = `${patchPath}.bak-anysearch`
if (!existsSync(patchBackup)) copyFileSync(patchBackup === patchPath ? patchPath : patchPath, patchBackup)
const patchAction = ensurePatch(patchPath)
chownSync(patchPath, u.uid, u.uid)
console.log(` ✓ patch ${patchAction}`)
} else {
console.log(` ⚠ 无 ${patchPath} → 跳过覆写(fetch 将跟随插件默认走 anysearch)`)
}
// 3) 装插件
try {
const pruned = pruneBrokenFileDeps(pkgPath)
if (pruned.length > 0) {
console.log(` · 清理断裂依赖 ${pruned.length} 个(${pruned.join(';')})`)
try { chownSync(pkgPath, u.uid, u.uid) } catch { /* 尽力而为 */ }
}
const stageDir = join(u.home_dir, '.dsh-stage')
mkdirSync(stageDir, { recursive: true, mode: 0o755 })
if (!existsSync(staged)) copyFileSync(artifact, staged)
chmodSync(staged, 0o444)
execFileSync('chown', [`${u.uid}:${u.uid}`, stageDir, staged], { stdio: 'pipe' })
const legacyStore = existingStoreDir(dir)
const storeDir = legacyStore !== '' ? legacyStore : join(u.home_dir, '.pnpm-store')
const legacyCache = join(ws, '.cache', 'pnpm')
const cacheDir = existsSync(legacyCache) ? legacyCache : join(u.home_dir, '.pnpm-cache')
const isRoot = existsSync(join(dir, 'pnpm-workspace.yaml'))
const args = [
'--reuid', String(u.uid), '--regid', String(u.uid), '--clear-groups',
'env', `HOME=${u.home_dir}`, 'pnpm', 'add',
'--store-dir', storeDir, '--cache-dir', cacheDir,
]
if (isRoot) args.push('-w')
args.push(`file:${staged}`)
execFileSync('setpriv', args, { cwd: dir, timeout: 180000, stdio: 'pipe' })
console.log(` ✓ 已安装 ${PKG}(store=${legacyStore !== '' ? '沿用旧' : '新建 home'})`)
const final = reconcileBundles(dir)
console.log(` ✓ bundles=${final.length}(含 ${PKG}: ${final.includes(PKG)})`)
} catch (err) {
const detail = String(err.stderr ?? '').trim() || err.message || String(err)
console.log(` ✗ 安装失败 ${detail.split('\n').slice(0, 3).join(' | ')}`)
continue
}
// 4) 停实例(新 bundle 才生效)
if (RESTART) {
const n = stopInstance(u.uid)
console.log(` ✓ 已停 ${n} 个实例 scope(下次访问自动拉起,新 bundle 才生效)`)
} else {
console.log(' · 未停实例:bundle 尚未生效,需后续重启')
}
}
console.log('\ndone')