feat(models): 平台自建「模型设置」—— 用户自配厂家 / 条目各自开关 / 共享模型开关(档案 87)

- 官方「设置 → 模型」页在平台环境**必然报错**(判据在**浏览器页面**的 loopback 判定;官方 README 原文 Non-loopback pages get no durable settings)⇒ 该分区对全角色(含 admin)隐藏,用户自配改走平台自建页(插件 0.3.11)
- DB 迁移 V6:credential_vault.route/base_url/api/models + users.shared_model_enabled;并**重定义 getEnabledCredentialKeyRef**(互斥删除后原实现无 ORDER BY ⇒ 「任取一条」)
- 新落地层 src/web/model-landing.ts:spawn 时把「已启用条目」写进实例 .credentials.yaml 与 settings.yaml 的 llm-pi-ai.providers.<route>;字段名与官方包实测对齐(apiKeyEnv / baseURL / api,**不是** protocol);只碰自己写过的 + 一次性交接
- 接口 /api/me/keys、/api/me/keys/:id/toggle、/api/me/models/shared;前端新增「设置 → 模型设置」分区(settings.section id=model-settings / order 100 / 全角色)
- 顺手修两处:ensure-role-profile-patch.cjs 的 --force 整文件覆盖会抹掉 admin 的 disable-hmr 与 workspace-scoped-picker 两个平台块(改为 stripManagedBlock 只替换自己那段);verify-mem-model.mjs 因档案 86 重构而长期失败的陈旧断言

⚠️ 本提交同时包含**档案 86(admin 跨用户实例管理 + 两处改名)**的代码改动 —— 该部分已上线并端到端验证;其 import/register 与本次改动同处 src/web/server.ts、poc/business-plugins/lib/client.js 等文件,按**文件粒度无法拆分**,且不带它会让仓库 tsc 直接失败(缺 src/web/routes/admin-user-ops.ts)。
This commit is contained in:
admin committed 2026-09-14 00:00:15 +08:00
1 parent eb50ca2d5b
commit 918f1d3a51
20 files changed
+2073 -276

No files matched your search

+158 -70
View File
@@ -11,7 +11,7 @@ import { basename, dirname, join } from 'node:path'
import { writeFileSync } from 'node:fs'
import { chown, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import type { ServerConfig } from '../config.js'
import { createDbAdapter, type DbAdapter, type PublicUser } from '../db/index.js'
import { createDbAdapter, type CredentialLandingRow, type DbAdapter, type PublicUser } from '../db/index.js'
import { createUserFs } from '../fs/provider.js'
import type { UserFs } from '../fs/user-fs.js'
import { decrypt, deriveKey } from '../crypto.js'
@@ -20,9 +20,20 @@ import { LocalSpawner } from '../supervisor/orchestrator.js'
import { K8sSpawner } from '../supervisor/k8s-spawner.js'
import { registerDshProxy } from '../supervisor/proxy.js'
import type { Spawner } from '../supervisor/spawner.js'
import {
BUILTIN_REF,
normalizeProtocol,
parseModels,
readRefValue,
reconcileCredentials,
reconcileSettings,
refForEntry,
type SettingsEntry,
} from './model-landing.js'
import { rateLimit } from './middleware/rate-limit.js'
import { authRoutes } from './routes/auth.js'
import { adminRoutes } from './routes/admin.js'
import { adminUserOpsRoutes } from './routes/admin-user-ops.js'
import { businessPluginRoutes } from './routes/business-plugins.js'
import { desktopRoutes } from './routes/desktop.js'
import { dshRoutes } from './routes/dsh.js'
@@ -65,99 +76,174 @@ function isAllowedOrigin(origin: string, baseDomain: string): boolean {
export async function buildServer(config: ServerConfig): Promise<FastifyInstance> {
const db = await createDbAdapter(config)
const encryptionKey = deriveKey(config.encryptionSecret)
/**
* 把「平台共享密钥」预置进用户的 dsh 凭据文件 `$DSH_HOME/.credentials.yaml` 的 `refs:` 段。
*
* 为什么是写文件而不是注入 env(2026-09-13 读官方源码定的):
* · dsh 凭据解析顺序 `inherited process environment (read-only, wins) > $DSH_HOME/.credentials.yaml > …`
* ⇒ **env 永远赢**;
* · 更要命的是 `dsh-credentials-local` 的 `write()` 里有 `assertUnshadowed()`:
* 只要 env 里存在同名 ref,用户在官方「设置 → 模型」页**保存该 key 会直接报错**
* ("supplied read-only by the launching environment … unset it in the shell you start dsh from")。
* ⇒ 注入 env 等于**把用户锁死在"不能自配 DeepSeek key"**的状态。
* · 所以平台改为**预置到凭据文件**:用户没配 ⇒ 用平台共享 key;用户去模型页改 ⇒ 直接覆盖同一个 ref。
*
* 安全约束(保守到极限):
* ① 只在 `refs:` 段**没有**该 ref 时写 —— 用户配过就绝不碰;
* ② 写前备份,但**备份必须放到平台自己的目录**(`/opt/dsh/backups`),
* ⛔ **绝不能落在用户 home 里**:dsh 用 chokidar watch 该目录,一个**实例读不了**的文件
* (root 属主 600)会让它抛 `EACCES` ⇒ **实例崩溃循环**(2026-09-13 实测踩过,
* 当时 .credentials.yaml.bak-platform 直接把 guest 打进 attempt=5);
* ③ 只在 `version: 1` 的文档上插入,**不重排、不重写其它行**;
* ④ 写完 chown 给实例 uid(否则 600 权限下实例读不了自己的凭据文件)。
*/
async function ensureRefInCredentials(homeDir: string, ref: string, value: string): Promise<boolean> {
const file = join(homeDir, '.credentials.yaml')
let text = ''
// ── 模型条目落地(档案 87)──────────────────────────────────────────────────
// 用户口径(2026-09-13 定):条目**各自开关、可同时启用**;admin 配的**平台共享模型
// **也列入**、用户可开关(`users.shared_model_enabled`);平台只负责把「**已启用**」
// 的都配好 —— 具体用哪个模型在 dsh 对话框的模型选择器里挑。
//
// 为什么必须由平台写文件:官方「设置 → 模型」页在平台环境**必然报错**(该页要 Host
// settings 镜像,而平台是浏览器经域名访问远程服务器 ⇒ `isLoopback=false` ⇒ persistence
// 降级 `memory` ⇒ 页面报「加载提供方目录失败」)。详见 `ensure-role-profile-patch.cjs`。
//
// 为什么**仍然不注入 env**(2026-09-13 读官方源码定的):dsh 凭据解析顺序是
// `inherited process environment (read-only, wins) > $DSH_HOME/.credentials.yaml > …`,
// 且 `dsh-credentials-local.write()` 里有 `assertUnshadowed()` —— 只要 env 存在同名 ref,
// 保存就报错("supplied read-only by the launching environment …")⇒ 注入 env 等于
// **把用户锁死在"不能自配 key"**。所以共享 key 改成**预置进凭据文件**,本函数恒返回 null。
//
// 落地两处(字段名 2026-09-13 读官方包实测,勿凭记忆改 —— 见 model-landing.ts 头注释):
// · `$DSH_HOME/.credentials.yaml` 的 `refs.<REF>`
// · `$DSH_HOME/settings.yaml` 的 `llm-pi-ai.providers.<route>`
//
// ⛔ 备份**绝不能落在用户 home 里**:dsh 用 chokidar watch 整个 home,一个**实例读不了**
// 的文件(root 属主 600)会让它抛 `EACCES` ⇒ **实例崩溃循环**(2026-09-13 实测踩过,
// 当时 .credentials.yaml.bak-platform 直接把 guest 打进 attempt=5)。
interface Managed {
refs: string[]
routes: string[]
}
/** 托管清单落点:**平台状态目录**(不在 home、也不在文档库)。 */
const managedDir = join(process.env.DSH_PLATFORM_STATE_DIR ?? '/opt/dsh/state', 'model-landing')
/** 只有清单里的 ref / route 才允许被平台改写或删除 —— 用户自己配的一律不碰。 */
const readManaged = async (userId: string): Promise<Managed> => {
const strs = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [])
try {
text = await readFile(file, 'utf8')
const raw = JSON.parse(await readFile(join(managedDir, userId + '.json'), 'utf8')) as Record<string, unknown>
return { refs: strs(raw.refs), routes: strs(raw.routes) }
} catch {
text = '' // 文件不存在 ⇒ 从零创建一个最小合法文档
return { refs: [], routes: [] } // 不存在 / 读坏 ⇒ 视为"平台还没管过任何东西"(保守)
}
const has = new RegExp('^[ \\t]*' + ref + '[ \\t]*:', 'm')
if (has.test(text)) return false // 用户已自配(或有该 ref)⇒ 绝不覆盖
const line = ' ' + ref + ": '" + value + "'"
let next: string
if (text.trim() === '') {
next = 'version: 1\nrefs:\n' + line + '\n'
} else if (/^refs:[ \t]*$/m.test(text)) {
next = text.replace(/^refs:[ \t]*$/m, (m) => m + '\n' + line)
} else if (/^version:[ \t]*1[ \t]*$/m.test(text)) {
// 有 version 但还没 refs 段 ⇒ 紧跟 version 建一个
next = text.replace(/^version:[ \t]*1[ \t]*$/m, (m) => m + '\nrefs:\n' + line)
} else {
return false // 认不出的布局 ⇒ 宁可不动(让实例照旧报"没有 key",也不冒写坏凭据的风险)
}
const writeManaged = async (userId: string, m: Managed): Promise<void> => {
await mkdir(managedDir, { recursive: true })
await writeFile(join(managedDir, userId + '.json'), JSON.stringify(m), { mode: 0o600 })
}
const readTextOrEmpty = async (file: string): Promise<string> => {
try {
return await readFile(file, 'utf8')
} catch {
return ''
}
// 备份落在**平台目录**(不进 home —— 见上面 ②)
if (text !== '') {
try {
const bakDir = process.env.DSH_PLATFORM_BACKUP_DIR ?? '/opt/dsh/backups'
await mkdir(bakDir, { recursive: true })
writeFileSync(join(bakDir, 'credentials-' + basename(homeDir) + '-' + Date.now() + '.yaml'), text, { mode: 0o600 })
} catch {
/* 备份失败不阻断 */
}
}
/**
* 写 home 里的配置文件:备份(落**平台目录**)→ 写 → chown 给 home 属主。
* 实例以 dsh-<uid> 身份运行,root 写的 600 文件它读不了 ⇒ 最后一步不能省。
*/
const writeHomeFile = async (homeDir: string, file: string, text: string): Promise<void> => {
try {
const bakDir = process.env.DSH_PLATFORM_BACKUP_DIR ?? '/opt/dsh/backups'
await mkdir(bakDir, { recursive: true })
const label = basename(file).replace(/^\./, '').replace(/\.ya?ml$/, '')
// ⚠️ 带上 home 的**父目录名**(= 用户 id):只写 basename 的话每个人都是 "home",
// 备份文件互相看不出是谁的(旧实现就是这个毛病:credentials-home-*.yaml)。
const who = basename(dirname(homeDir))
writeFileSync(join(bakDir, `${label}-${who}-${Date.now()}.yaml`), text, { mode: 0o600 })
} catch {
/* 备份失败不阻断 */
}
await writeFile(file, next, { mode: 0o600 })
// 实例以 dsh-<uid> 身份运行;root 写的 600 文件它读不了 ⇒ 交给该 home 的属主
await writeFile(file, text, { mode: 0o600 })
try {
const st = await stat(homeDir)
await chown(file, st.uid, st.gid)
} catch {
/* chown 失败(非 root 运行等)不阻断 */
}
return true
}
// ── 模型密钥供给(档案 86;2026-09-13 用户要求用户可自配模型厂家)────────────────
// 口径:**平台不再向实例注入 `DEEPSEEK_API_KEY` env**,改为在 spawn 时把「平台共享密钥」
// 预置进该用户的凭据文件(仅当他还没配过)。这样:
// · 没配的用户 —— 照旧能用(共享 key);
// · 想用自己的 —— 直接去官方「设置 → 模型」页改,覆盖同一个 ref,**不会再被 env 挡住**;
// · 配了自定义厂家(OpenAI 兼容网关)的 —— 那走各自的 `<ROUTE>_API_KEY`,平台完全不介入。
// ⚠️ 返回 null(不注入 env)是**刻意的**,不是"没有 key"。见上面 ensureRefInCredentials 的注释。
const resolveApiKey = async (userId: string): Promise<string | null> => {
/** 平台共享条目(admin 配的、已启用的那些)—— 只在该用户开关打开、且他不是那个 admin 时纳入。 */
const sharedLandingRows = async (userId: string): Promise<CredentialLandingRow[]> => {
if (!(await db.getSharedModelEnabled(userId))) return []
const admins = (await db.listPublicUsers()).filter((u) => u.role === 'admin')
if (admins.length === 0 || admins[0].id === userId) return []
return db.listCredentialLandingRows(admins[0].id)
}
/**
* 把「已启用条目」对账进实例的两个配置文件。**幂等**,且只在真有变化时写盘。
* 合并顺序 = **自己的在前** ⇒ 同一个 ref 上,用户自己配的 key 永远赢过平台共享的那把。
*/
const landModels = async (userId: string): Promise<void> => {
const owner = await db.findUserById(userId)
if (owner === undefined) return null
if (owner === undefined) return
const previous = await readManaged(userId)
const rows = [...(await db.listCredentialLandingRows(userId)), ...(await sharedLandingRows(userId))]
const seen = new Set<string>()
const creds: Array<{ ref: string; value: string }> = []
const providers: SettingsEntry[] = []
for (const row of rows) {
const ref = refForEntry({ route: row.route, baseUrl: row.baseUrl })
if (seen.has(ref)) continue
let value: string
try {
value = decrypt(row.encryptedRef, encryptionKey)
} catch {
// 解不开的条目跳过:宁可少配一个厂家,也不能让整次 spawn 失败。
console.error('model landing: 解不开的条目已跳过', { userId, name: row.name })
continue
}
seen.add(ref)
creds.push({ ref, value })
// 只有"自定义厂家"才写 settings.yaml:内置 DeepSeek 由官方 `dsh-llm-deepseek` 自己管
// (它的 route 是 `deepseek-official`,不是 `llm-pi-ai` 下的键)。
if (row.baseUrl !== null && row.baseUrl !== '' && row.route !== null && row.route !== '') {
providers.push({
route: row.route,
apiKeyEnv: ref,
baseURL: row.baseUrl,
api: normalizeProtocol(row.api),
models: parseModels(row.models),
})
}
}
const credFile = join(owner.home_dir, '.credentials.yaml')
const setFile = join(owner.home_dir, 'settings.yaml')
const credText = await readTextOrEmpty(credFile)
const setText = await readTextOrEmpty(setFile)
// 一次性交接(档案 87):老实现把平台共享 key 写进 `refs.DEEPSEEK_API_KEY` 时没有托管清单,
// 新逻辑会把它当成"用户自己写的" ⇒ 关掉共享开关后那行仍留着("关掉即生效"不成立)。
// 首次运行(没有任何清单)且**文件里那行确实等于平台共享 key 明文**时,认领它;
// 不相等 = 用户自己配的 ⇒ 绝不碰。
let prevRefs = previous.refs
if (previous.refs.length === 0 && previous.routes.length === 0) {
if (readRefValue(credText, BUILTIN_REF) !== null) {
const shared = await sharedDeepseekKey()
if (shared !== null && shared === readRefValue(credText, BUILTIN_REF)) prevRefs = [BUILTIN_REF]
}
}
const nextCred = reconcileCredentials(credText, creds, prevRefs)
const nextSet = reconcileSettings(setText, providers, previous.routes)
if (nextCred.text !== credText) await writeHomeFile(owner.home_dir, credFile, nextCred.text)
if (nextSet.text !== setText) await writeHomeFile(owner.home_dir, setFile, nextSet.text)
await writeManaged(userId, { refs: nextCred.managed, routes: nextSet.managed })
}
/** 保底:平台共享的那把内置 DeepSeek key 明文 —— 只在写配置失败退回 env 注入时才用。 */
const sharedDeepseekKey = async (): Promise<string | null> => {
const admins = (await db.listPublicUsers()).filter((u) => u.role === 'admin')
if (admins.length === 0) return null
const ref = await db.getEnabledCredentialKeyRef(admins[0].id)
if (ref === null) return null
let shared: string | null = null
try {
shared = decrypt(ref, encryptionKey)
return decrypt(ref, encryptionKey)
} catch {
return null // corrupt ref — treat as unset, let the admin re-enter it
return null // 密文坏了 ⇒ 当作没配,等 admin 重填
}
if (shared === null) return null
}
/**
* 「该给实例注入什么 env」的答案:**什么也不注入**(恒 `null`)。
* 保留函数名与签名是因为 `Spawner` 的接口就是这么定义的(见上面那段大注释:注入 env 会把
* 用户在模型页的保存打回错误)。写配置失败时**退回 env 注入保底** —— 宁可让用户暂时用
* 平台共享 key,也不能因为写文件出错就让实例起不来。
*/
const resolveApiKey = async (userId: string): Promise<string | null> => {
try {
await ensureRefInCredentials(owner.home_dir, 'DEEPSEEK_API_KEY', shared)
await landModels(userId)
return null
} catch (err) {
// 写失败不能让实例起不来:退回老办法(注入 env)保底
console.error('ensureRefInCredentials failed, falling back to env injection', err)
return shared
console.error('model landing failed, falling back to env injection', err)
return await sharedDeepseekKey()
}
return null
}
const resolveUid = async (userId: string): Promise<number> => {
const user = await db.findUserById(userId)
@@ -216,6 +302,8 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
// Domain-specific route groups (API).
await app.register(authRoutes)
await app.register(adminRoutes)
// 档案 87:admin 视角的「用户服务 / 工作区文件」—— admin 在「服务管理」里管**任意用户**
await app.register(adminUserOpsRoutes)
await app.register(businessPluginRoutes)
await app.register(desktopRoutes)
await app.register(dshRoutes)