#!/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.` 条目(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')