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

+17 -1
View File
@@ -8,6 +8,8 @@
import type {
BusinessPlugin,
CredentialKey,
CredentialKeyMeta,
CredentialLandingRow,
CreateSessionInput,
CreateUserInput,
Domain,
@@ -71,9 +73,23 @@ export interface DbAdapter {
setDomainVerified(id: string, verified: boolean): Promise<boolean>
// credential vault
listCredentialKeys(userId: string): Promise<CredentialKey[]>
/** 档案 86:该用户**全部已启用**的条目(spawn 时按它们写实例配置)。 */
listEnabledCredentialKeys(userId: string): Promise<CredentialKey[]>
/** 档案 87:同上 + **encrypted ref** —— **仅供 `server.ts` 的落地层**,绝不经 API 返回。 */
listCredentialLandingRows(userId: string): Promise<CredentialLandingRow[]>
getEnabledCredentialKeyRef(userId: string): Promise<string | null>
setCredentialKey(userId: string, name: string, encryptedRef: string): Promise<CredentialKey>
setCredentialKey(
userId: string,
name: string,
encryptedRef: string,
meta?: CredentialKeyMeta,
): Promise<CredentialKey>
selectCredentialKey(userId: string, id: string): Promise<boolean>
/** 档案 86:开/关**单个**条目(不动其它条目 —— 用户口径:可同时启用多个)。 */
toggleCredentialKey(userId: string, id: string, enabled: boolean): Promise<boolean>
/** 档案 86:用户是否使用「平台共享模型」(admin 配的那把)—— 用户侧偏好。 */
getSharedModelEnabled(userId: string): Promise<boolean>
setSharedModelEnabled(userId: string, enabled: boolean): Promise<boolean>
deleteCredentialKey(userId: string, id: string): Promise<boolean>
// instances (desired state the k8s controller reconciles against — docs/k8s.md §5.7)
upsertInstance(input: UpsertDshInstanceInput): Promise<void>
+120 -28
View File
@@ -20,6 +20,8 @@ import {
toWorkspace,
type BusinessPlugin,
type CredentialKey,
type CredentialKeyMeta,
type CredentialLandingRow,
type CreateSessionInput,
type CreateUserInput,
type Domain,
@@ -64,6 +66,38 @@ export async function withTx<T>(pool: Pool, fn: (client: PoolClient) => Promise<
}
}
/**
* 凭据行的列清单与映射(档案 87)—— 与 `repo.ts` 里同名的一组**逐字对应**:
* 两个后端必须选出同一批列,否则就是"SQLite 上好好的、k8s 上少字段"这种
* 只在生产才出现的偏差。(**不**从 `repo.ts` 导入:那会把 better-sqlite3 原生依赖
* 拖进 pg 模式。)
*/
const CREDENTIAL_COLS = 'id, key_name, enabled, updated_at, route, base_url, api, models'
interface CredentialRow {
id: string
key_name: string
enabled: number
updated_at: number
route: string | null
base_url: string | null
api: string | null
models: string | null
}
function toCredentialKey(r: CredentialRow): CredentialKey {
return {
id: r.id,
name: r.key_name,
enabled: r.enabled === 1,
updatedAt: r.updated_at,
route: r.route,
baseUrl: r.base_url,
api: r.api,
models: r.models,
}
}
export class PgAdapter implements DbAdapter {
constructor(private readonly pool: Pool, private readonly baseUid: number) {}
@@ -336,65 +370,123 @@ export class PgAdapter implements DbAdapter {
async listCredentialKeys(userId: string): Promise<CredentialKey[]> {
const { rows } = await this.pool.query(
'SELECT id, key_name, enabled, updated_at FROM credential_vault WHERE user_id = $1 ORDER BY updated_at DESC',
`SELECT ${CREDENTIAL_COLS} FROM credential_vault WHERE user_id = $1 ORDER BY updated_at DESC`,
[userId],
)
return (rows as Array<{ id: string; key_name: string; enabled: number; updated_at: number }>).map((r) => ({
id: r.id,
return (rows as CredentialRow[]).map(toCredentialKey)
}
async listEnabledCredentialKeys(userId: string): Promise<CredentialKey[]> {
const { rows } = await this.pool.query(
`SELECT ${CREDENTIAL_COLS} FROM credential_vault WHERE user_id = $1 AND enabled = 1 ORDER BY updated_at DESC`,
[userId],
)
return (rows as CredentialRow[]).map(toCredentialKey)
}
/** 落地层专用:多返回 `secret_ref`(密文)—— 与 `listEnabledCredentialKeys` 的唯一差别。 */
async listCredentialLandingRows(userId: string): Promise<CredentialLandingRow[]> {
const { rows } = await this.pool.query(
'SELECT key_name, route, base_url, api, models, secret_ref FROM credential_vault WHERE user_id = $1 AND enabled = 1 ORDER BY updated_at DESC',
[userId],
)
return (
rows as Array<{
key_name: string
route: string | null
base_url: string | null
api: string | null
models: string | null
secret_ref: string
}>
).map((r) => ({
name: r.key_name,
enabled: r.enabled === 1,
updatedAt: r.updated_at,
route: r.route,
baseUrl: r.base_url,
api: r.api,
models: r.models,
encryptedRef: r.secret_ref,
}))
}
/**
* **内置 DeepSeek 条目**的 encrypted ref(档案 87 的语义重定义)。
* 互斥被删之后 `enabled = 1` 可能命中多行 ⇒ 必须钉死"内置 + 最新一条",
* 否则调用方会随机拿到某一个厂家的 key(详见 `repo.ts` 同名函数的注释)。
*/
async getEnabledCredentialKeyRef(userId: string): Promise<string | null> {
const { rows } = await this.pool.query(
'SELECT secret_ref FROM credential_vault WHERE user_id = $1 AND enabled = 1',
"SELECT secret_ref FROM credential_vault WHERE user_id = $1 AND enabled = 1 AND (base_url IS NULL OR base_url = '') ORDER BY updated_at DESC, id DESC LIMIT 1",
[userId],
)
const row = rows[0] as { secret_ref: string } | undefined
return row?.secret_ref ?? null
}
async setCredentialKey(userId: string, name: string, encryptedRef: string): Promise<CredentialKey> {
/** Upsert by `name` 并启用它 —— **不动**其它条目(档案 87,互斥已删)。 */
async setCredentialKey(
userId: string,
name: string,
encryptedRef: string,
meta?: CredentialKeyMeta,
): Promise<CredentialKey> {
const route = meta?.route ?? null
const baseUrl = meta?.baseUrl ?? null
const api = meta?.api ?? null
const models = meta?.models ?? null
try {
return await withTx(this.pool, async (client) => {
await client.query('UPDATE credential_vault SET enabled = 0 WHERE user_id = $1', [userId])
const existing = await client.query(
'SELECT id FROM credential_vault WHERE user_id = $1 AND key_name = $2',
[userId, name],
)
const existing = await client.query('SELECT id FROM credential_vault WHERE user_id = $1 AND key_name = $2', [
userId,
name,
])
let id: string
if (existing.rows.length > 0) {
id = (existing.rows[0] as { id: string }).id
await client.query('UPDATE credential_vault SET secret_ref = $1, enabled = 1, updated_at = $2 WHERE id = $3', [
encryptedRef,
Date.now(),
id,
])
await client.query(
'UPDATE credential_vault SET secret_ref = $1, route = $2, base_url = $3, api = $4, models = $5, enabled = 1, updated_at = $6 WHERE id = $7',
[encryptedRef, route, baseUrl, api, models, Date.now(), id],
)
} else {
id = randomUUID()
await client.query(
'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, enabled, updated_at) VALUES ($1, $2, $3, $4, 1, $5)',
[id, userId, name, encryptedRef, Date.now()],
'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, route, base_url, api, models, enabled, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 1, $9)',
[id, userId, name, encryptedRef, route, baseUrl, api, models, Date.now()],
)
}
return { id, name, enabled: true, updatedAt: Date.now() }
return { id, name, enabled: true, updatedAt: Date.now(), route, baseUrl, api, models }
})
} catch (e) {
mapPgError(e)
}
}
/** 启用一个条目(档案 87:**非互斥**,不再先全关)。 */
async selectCredentialKey(userId: string, id: string): Promise<boolean> {
return await withTx(this.pool, async (client) => {
await client.query('UPDATE credential_vault SET enabled = 0 WHERE user_id = $1', [userId])
const result = await client.query('UPDATE credential_vault SET enabled = 1 WHERE id = $1 AND user_id = $2', [
id,
userId,
])
return (result.rowCount ?? 0) > 0
})
return await this.toggleCredentialKey(userId, id, true)
}
async toggleCredentialKey(userId: string, id: string, enabled: boolean): Promise<boolean> {
const result = await this.pool.query(
'UPDATE credential_vault SET enabled = $1, updated_at = $2 WHERE id = $3 AND user_id = $4',
[enabled ? 1 : 0, Date.now(), id, userId],
)
return (result.rowCount ?? 0) > 0
}
/** 该用户是否启用「平台共享模型」(档案 87)—— 缺失行按 `true`(默认值)。 */
async getSharedModelEnabled(userId: string): Promise<boolean> {
const { rows } = await this.pool.query('SELECT shared_model_enabled FROM users WHERE id = $1', [userId])
const row = rows[0] as { shared_model_enabled: number } | undefined
return row === undefined ? true : Number(row.shared_model_enabled) !== 0
}
async setSharedModelEnabled(userId: string, enabled: boolean): Promise<boolean> {
const result = await this.pool.query('UPDATE users SET shared_model_enabled = $1 WHERE id = $2', [
enabled ? 1 : 0,
userId,
])
return (result.rowCount ?? 0) > 0
}
async deleteCredentialKey(userId: string, id: string): Promise<boolean> {
+160 -27
View File
@@ -21,6 +21,8 @@ import {
toWorkspace,
type BusinessPlugin,
type CredentialKey,
type CredentialKeyMeta,
type CredentialLandingRow,
type CreateSessionInput,
type CreateUserInput,
type Domain,
@@ -211,57 +213,188 @@ export function upsertDomain(db: Database, userId: string, domain: string, nginx
return findDomainByUser(db, userId)!
}
/**
* 两个凭据列表共用的列清单与行映射(档案 87)—— 抽出来是为了**两处不可能漂**:
* 之前 `listCredentialKeys` 与 `listEnabledCredentialKeys` 各写一份 SELECT,
* 加一次列就要改两处,漏一处就是"一个列表有 route、另一个没有"。
*/
const CREDENTIAL_COLS = 'id, key_name, enabled, updated_at, route, base_url, api, models'
interface CredentialRow {
id: string
key_name: string
enabled: number
updated_at: number
route: string | null
base_url: string | null
api: string | null
models: string | null
}
function toCredentialKey(r: CredentialRow): CredentialKey {
return {
id: r.id,
name: r.key_name,
enabled: r.enabled === 1,
updatedAt: r.updated_at,
route: r.route,
baseUrl: r.base_url,
api: r.api,
models: r.models,
}
}
/** List a user's named credential keys (metadata only). */
export function listCredentialKeys(db: Database, userId: string): CredentialKey[] {
const rows = prepare(
db,
'SELECT id, key_name, enabled, updated_at FROM credential_vault WHERE user_id = ? ORDER BY updated_at DESC',
).all(userId) as Array<{ id: string; key_name: string; enabled: number; updated_at: number }>
return rows.map((r) => ({ id: r.id, name: r.key_name, enabled: r.enabled === 1, updatedAt: r.updated_at }))
`SELECT ${CREDENTIAL_COLS} FROM credential_vault WHERE user_id = ? ORDER BY updated_at DESC`,
).all(userId) as CredentialRow[]
return rows.map(toCredentialKey)
}
/** The enabled key's encrypted ref for a user (decrypt with the deployment secret). */
/**
* **内置 DeepSeek 条目**的 encrypted ref(档案 87 的**语义重定义**,必须读这一条)。
*
* 老语义是「那一把启用的 key」—— 当时 `setCredentialKey` 会先 `SET enabled = 0` 全关,
* 所以 `enabled = 1` **最多一行**,不带 ORDER BY 也唯一。
* 用户口径改成「条目各自开关、都能同时启用」后,互斥被删(见 `setCredentialKey`)⇒
* `WHERE enabled = 1` 可能命中**多行**,而**没有 ORDER BY 就是"任取一条"** ——
* `auth.ts` 的 `keySourceOf()` 与 `server.ts` 的 `resolveApiKey()` 会因此**随机飘**。
*
* ⇒ 这里把语义钉死为:**已启用、且是内置 DeepSeek(`base_url` 为空)的最新一条**。
* 自定义厂家**不算**"自己的 DeepSeek key"(它们各自有自己的 ref 与 settings 段)。
* @returns 该 ref,或 `null`(用户没有任何启用的内置条目)。
*/
export function getEnabledCredentialKeyRef(db: Database, userId: string): string | null {
const row = prepare(db, 'SELECT secret_ref FROM credential_vault WHERE user_id = ? AND enabled = 1').get(userId) as
| { secret_ref: string }
| undefined
const row = prepare(
db,
"SELECT secret_ref FROM credential_vault WHERE user_id = ? AND enabled = 1 AND (base_url IS NULL OR base_url = '') ORDER BY updated_at DESC, id DESC LIMIT 1",
).get(userId) as { secret_ref: string } | undefined
return row?.secret_ref ?? null
}
/** Upsert a named key, disable the others, and enable this one. */
export function setCredentialKey(db: Database, userId: string, name: string, encryptedRef: string): CredentialKey {
/**
* Upsert a named key by `name` and enable it —— **不动**其它条目(档案 87)。
*
* ⚠️ 老行为是「先 `SET enabled = 0` 全关,再开这一个」(单选)。用户口径已改为
* 「条目各自开关、都能同时启用」,所以那一行**已删**:否则用户每加一把 key,
* 别的厂家就被静默关掉。要"只开这一个"请显式先关别的(`toggleCredentialKey`)。
* @param meta - 模型厂家元数据(route / endpoint / 协议 / 模型清单),见 {@link CredentialKeyMeta}。
*/
export function setCredentialKey(
db: Database,
userId: string,
name: string,
encryptedRef: string,
meta: CredentialKeyMeta = {},
): CredentialKey {
const route = meta.route ?? null
const baseUrl = meta.baseUrl ?? null
const api = meta.api ?? null
const models = meta.models ?? null
const id = db.transaction(() => {
prepare(db, 'UPDATE credential_vault SET enabled = 0 WHERE user_id = ?').run(userId)
const existing = prepare(db, 'SELECT id FROM credential_vault WHERE user_id = ? AND key_name = ?').get(
userId,
name,
) as { id: string } | undefined
if (existing !== undefined) {
prepare(db, 'UPDATE credential_vault SET secret_ref = ?, enabled = 1, updated_at = ? WHERE id = ?').run(
encryptedRef,
Date.now(),
existing.id,
)
prepare(
db,
'UPDATE credential_vault SET secret_ref = ?, route = ?, base_url = ?, api = ?, models = ?, enabled = 1, updated_at = ? WHERE id = ?',
).run(encryptedRef, route, baseUrl, api, models, Date.now(), existing.id)
return existing.id
}
const id = randomUUID()
const newId = randomUUID()
prepare(
db,
'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, enabled, updated_at) VALUES (?, ?, ?, ?, 1, ?)',
).run(id, userId, name, encryptedRef, Date.now())
return id
'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, route, base_url, api, models, enabled, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)',
).run(newId, userId, name, encryptedRef, route, baseUrl, api, models, Date.now())
return newId
})()
return { id, name, enabled: true, updatedAt: Date.now() }
return { id, name, enabled: true, updatedAt: Date.now(), route, baseUrl, api, models }
}
/** Enable one of a user's named keys (disabling the others). */
/**
* 启用某一个条目(档案 87:**改为非互斥**)。
*
* 老语义是「单选」:先 `SET enabled = 0` 把该用户所有条目关掉,再开这一个。
* 用户口径改成「各自开关、可同时启用」之后,那一步会**悄悄关掉别的已启用条目**
* (用户看不出为什么换了个厂家另一个就不生效了),所以这里退化成
* `toggleCredentialKey(id, true)` 的同义实现 —— **保留函数名只为接口兼容**。
*/
export function selectCredentialKey(db: Database, userId: string, id: string): boolean {
const ok = db.transaction(() => {
prepare(db, 'UPDATE credential_vault SET enabled = 0 WHERE user_id = ?').run(userId)
const info = prepare(db, 'UPDATE credential_vault SET enabled = 1 WHERE id = ? AND user_id = ?').run(id, userId)
return info.changes > 0
})()
return ok as boolean
return toggleCredentialKey(db, userId, id, true)
}
/**
* 打开/关闭**单个**条目(档案 87;用户口径:条目各自开关、可同时启用)。
* 与 `selectCredentialKey`(名字带"单选"但已改为非互斥)的差别:这里**只碰这一行**。
* @returns 是否命中了该用户下的这一行(false = 不存在或不属于他)。
*/
export function toggleCredentialKey(db: Database, userId: string, id: string, enabled: boolean): boolean {
const info = prepare(db, 'UPDATE credential_vault SET enabled = ?, updated_at = ? WHERE id = ? AND user_id = ?').run(
enabled ? 1 : 0,
Date.now(),
id,
userId,
)
return info.changes > 0
}
/** 该用户**所有已启用**的条目(含 route / base_url / api / models)—— spawn 时按它们写实例配置。 */
export function listEnabledCredentialKeys(db: Database, userId: string): CredentialKey[] {
const rows = prepare(
db,
`SELECT ${CREDENTIAL_COLS} FROM credential_vault WHERE user_id = ? AND enabled = 1 ORDER BY updated_at DESC`,
).all(userId) as CredentialRow[]
return rows.map(toCredentialKey)
}
/**
* 该用户**所有已启用**的条目 + 各自 encrypted ref —— **仅供落地层**(档案 87)。
* 与 `listEnabledCredentialKeys` 的差别只有一个:多返回 `secret_ref`。
* 单独一个函数是为了让"密文"这件事**不可能**顺着 `/api/me/keys` 漏出去。
*/
export function listCredentialLandingRows(db: Database, userId: string): CredentialLandingRow[] {
const rows = prepare(
db,
'SELECT key_name, route, base_url, api, models, secret_ref FROM credential_vault WHERE user_id = ? AND enabled = 1 ORDER BY updated_at DESC',
).all(userId) as Array<{
key_name: string
route: string | null
base_url: string | null
api: string | null
models: string | null
secret_ref: string
}>
return rows.map((r) => ({
name: r.key_name,
route: r.route,
baseUrl: r.base_url,
api: r.api,
models: r.models,
encryptedRef: r.secret_ref,
}))
}
/**
* 该用户是否启用「平台共享模型」(档案 87)—— **用户侧偏好**,开关它**不动** admin 的配置。
*
* 缺失行一律按 `true` 处理:V6 迁移给所有老用户填了默认 1,而"查不到这个人"时
* 也不该因为一个开关把共享 key 断掉(宁可多给,不可少给)。
*/
export function getSharedModelEnabled(db: Database, userId: string): boolean {
const row = prepare(db, 'SELECT shared_model_enabled FROM users WHERE id = ?').get(userId) as
| { shared_model_enabled: number }
| undefined
return row === undefined ? true : row.shared_model_enabled !== 0
}
/** 打开/关闭「平台共享模型」(档案 87)。@returns 是否命中该用户。 */
export function setSharedModelEnabled(db: Database, userId: string, enabled: boolean): boolean {
const info = prepare(db, 'UPDATE users SET shared_model_enabled = ? WHERE id = ?').run(enabled ? 1 : 0, userId)
return info.changes > 0
}
/** Delete a named key (by id, scoped to the user). */
+35
View File
@@ -258,6 +258,40 @@ CREATE TABLE IF NOT EXISTS business_plugins (
);
`
// v6: 用户自配「模型厂家」支持(档案 87)。
// 原先 `credential_vault` 只存一把 key(`key_name` 兼作展示名);用户要按**厂家**配模型,
// 平台还需要知道:**route**(= settings.yaml 里 `llm-pi-ai.providers` 的 dict 键)、
// **endpoint**(`baseURL`)、**协议**(`api`)、**模型清单**(`models`)—— spawn 时按这四样写
// `$DSH_HOME/settings.yaml` 的 `llm-pi-ai.providers.<route>` 与
// `$DSH_HOME/.credentials.yaml` 的 `refs.<REF>`。
// · `base_url` 为空 = **内置 DeepSeek**(只写 refs,不写 settings.yaml)。
// · REF 命名:内置 = `DEEPSEEK_API_KEY`;自定义 = `<ROUTE 大写化>_API_KEY`
// (须匹配 `@deepseek-ai/dsh-credentials` 的 `REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/`)。
// · `api` 的合法值只有三个(`dsh-llm-pi-ai` 的 `PROTOCOLS` 键,顺序即默认优先级):
// `openai-completions` / `openai-responses` / `anthropic-messages`。
// · ⚠️ 字段名是 **`api` 不是 `protocol`**;`apiKeyEnv`(不是 `apiKey`);且该 profile
// **不接受** `provider` / `maxRetries` / `maxRetryDelayMs`(会直接抛错)。
// 以上全部为 2026-09-13 读官方包 `[email protected]` 的 `lib/index.js`
// (`const NS = "llm-pi-ai"` / `profile` schema / `PROTOCOLS`)实测结论。
// 另:`users.shared_model_enabled` = 用户**要不要用平台共享模型**(admin 配的那把)——
// 属于用户侧偏好,开关它**不动** admin 的配置(用户口径:条目各自开关,都能同时启用;
// 具体用哪个模型是在 dsh 对话框的模型选择器里挑)。
const SQLITE_V6 = `
ALTER TABLE credential_vault ADD COLUMN route TEXT;
ALTER TABLE credential_vault ADD COLUMN base_url TEXT;
ALTER TABLE credential_vault ADD COLUMN api TEXT;
ALTER TABLE credential_vault ADD COLUMN models TEXT;
ALTER TABLE users ADD COLUMN shared_model_enabled INTEGER NOT NULL DEFAULT 1;
`
const PG_V6 = `
ALTER TABLE credential_vault ADD COLUMN route TEXT;
ALTER TABLE credential_vault ADD COLUMN base_url TEXT;
ALTER TABLE credential_vault ADD COLUMN api TEXT;
ALTER TABLE credential_vault ADD COLUMN models TEXT;
ALTER TABLE users ADD COLUMN shared_model_enabled INTEGER NOT NULL DEFAULT 1;
`
interface Migration {
version: number
name: string
@@ -271,6 +305,7 @@ const MIGRATIONS: readonly Migration[] = [
{ version: 3, name: 'per-user uid', sqlite: SQLITE_V3, pg: PG_V3 },
{ version: 4, name: 'instance desired state', sqlite: SQLITE_V4, pg: PG_V4 },
{ version: 5, name: 'business plugin candidate pool', sqlite: SQLITE_V5, pg: PG_V5 },
{ version: 6, name: 'user model providers', sqlite: SQLITE_V6, pg: PG_V6 },
]
/** Apply unapplied SQLite migrations inside a single transaction. */
+34 -2
View File
@@ -39,9 +39,12 @@ import {
getEnabledCredentialKeyRef as getEnabledCredentialKeyRefSync,
getEnabledPluginIds as getEnabledPluginIdsSync,
getOrCreateWorkspace as getOrCreateWorkspaceSync,
getSharedModelEnabled as getSharedModelEnabledSync,
listBusinessPlugins as listBusinessPluginsSync,
listCredentialKeys as listCredentialKeysSync,
listCredentialLandingRows as listCredentialLandingRowsSync,
listDomains as listDomainsSync,
listEnabledCredentialKeys as listEnabledCredentialKeysSync,
listInstancesByRole as listInstancesByRoleSync,
listPublicUsers as listPublicUsersSync,
listUsersWithoutUid as listUsersWithoutUidSync,
@@ -50,8 +53,10 @@ import {
setDomainVerified as setDomainVerifiedSync,
setFolderPlugins as setFolderPluginsSync,
setInstanceStatus as setInstanceStatusSync,
setSharedModelEnabled as setSharedModelEnabledSync,
setUserRole as setUserRoleSync,
setUserUid as setUserUidSync,
toggleCredentialKey as toggleCredentialKeySync,
upsertBusinessPlugin as upsertBusinessPluginSync,
upsertDomain as upsertDomainSync,
upsertInstance as upsertInstanceSync,
@@ -59,6 +64,8 @@ import {
import type {
BusinessPlugin,
CredentialKey,
CredentialKeyMeta,
CredentialLandingRow,
CreateSessionInput,
CreateUserInput,
Domain,
@@ -229,13 +236,26 @@ export class SqliteAdapter implements DbAdapter {
return listCredentialKeysSync(this.db, userId)
}
async listEnabledCredentialKeys(userId: string): Promise<CredentialKey[]> {
return listEnabledCredentialKeysSync(this.db, userId)
}
async listCredentialLandingRows(userId: string): Promise<CredentialLandingRow[]> {
return listCredentialLandingRowsSync(this.db, userId)
}
async getEnabledCredentialKeyRef(userId: string): Promise<string | null> {
return getEnabledCredentialKeyRefSync(this.db, userId)
}
async setCredentialKey(userId: string, name: string, encryptedRef: string): Promise<CredentialKey> {
async setCredentialKey(
userId: string,
name: string,
encryptedRef: string,
meta?: CredentialKeyMeta,
): Promise<CredentialKey> {
try {
return setCredentialKeySync(this.db, userId, name, encryptedRef)
return setCredentialKeySync(this.db, userId, name, encryptedRef, meta)
} catch (e) {
mapSqliteError(e)
}
@@ -245,6 +265,18 @@ export class SqliteAdapter implements DbAdapter {
return selectCredentialKeySync(this.db, userId, id)
}
async toggleCredentialKey(userId: string, id: string, enabled: boolean): Promise<boolean> {
return toggleCredentialKeySync(this.db, userId, id, enabled)
}
async getSharedModelEnabled(userId: string): Promise<boolean> {
return getSharedModelEnabledSync(this.db, userId)
}
async setSharedModelEnabled(userId: string, enabled: boolean): Promise<boolean> {
return setSharedModelEnabledSync(this.db, userId, enabled)
}
async deleteCredentialKey(userId: string, id: string): Promise<boolean> {
return deleteCredentialKeySync(this.db, userId, id)
}
+52
View File
@@ -98,6 +98,58 @@ export interface CredentialKey {
name: string
enabled: boolean
updatedAt: number
/**
* settings.yaml 里 `llm-pi-ai.providers` 的 **dict 键**(档案 87)。内置 DeepSeek 条目
* 可用 `deepseek`;自定义厂家由用户给(平台按名字生成 slug 作为默认值)。
* ⚠️ 不是「厂家名」,而是**寻址键** —— 改名不影响它,所以平台把它显式存下来。
*/
route?: string | null
/**
* 自定义厂家的 endpoint(档案 87)。`null` = **内置 DeepSeek**(此时不写 settings.yaml,
* 只把 key 落到 `refs.DEEPSEEK_API_KEY`);非空 = 用户声明的 OpenAI 兼容网关,
* 平台会写 `settings.yaml` 的 `llm-pi-ai.providers.<route>`(`baseURL` + `api` + `models`)。
*/
baseUrl?: string | null
/**
* 该 route 的**线协议**(档案 87)—— settings.yaml 里字段名是 **`api`**。
* 合法值只有 `openai-completions` / `openai-responses` / `anthropic-messages`
* (官方 `dsh-llm-pi-ai` 的 `PROTOCOLS` 键;空 = 取默认 `openai-completions`)。
*/
api?: string | null
/** 该厂家下的模型 id 清单(JSON 数组字符串;自定义厂家必填,内置厂家可为空)。 */
models?: string | null
}
/**
* 写入一条凭据时可带的**模型厂家元数据**(档案 87)。
* 全部可空:只给 `name` + `encryptedRef` 时就是老语义的「内置 DeepSeek 那一把 key」。
*/
export interface CredentialKeyMeta {
/** settings.yaml 里 `llm-pi-ai.providers` 的 dict 键;空 = 内置 DeepSeek。 */
route?: string | null
/** 自定义厂家的 endpoint;空 = 内置(只写 `.credentials.yaml`,不写 settings.yaml)。 */
baseUrl?: string | null
/** 线协议(settings.yaml 的 `api`);空 = 官方默认 `openai-completions`。 */
api?: string | null
/** 模型 id 的 JSON 数组字符串。 */
models?: string | null
}
/**
* **落地层专用**:一条已启用条目 + 它的 encrypted ref(档案 87)。
*
* 为什么单独一个类型:{@link CredentialKey} 会被 `/api/me/keys` **原样返回给浏览器**,
* 所以它刻意不含密文;而 `server.ts` 要把 key 写进实例的 `.credentials.yaml`,
* 必须要密文(用部署密钥解出来)。两件事的受众不同 ⇒ 两个类型,别合并。
*/
export interface CredentialLandingRow {
name: string
route: string | null
baseUrl: string | null
api: string | null
models: string | null
/** 部署密钥加密后的 ref(`decrypt()` 之后才是明文 key)。 */
encryptedRef: string
}
/** A business-plugin (系统外插件) candidate-pool row. `id` = bundle package name. */
+365
View File
@@ -0,0 +1,365 @@
/**
* 模型条目的**落地层**(档案 87):把平台凭据库里「已启用」的条目写进实例的两个配置文件。
*
* 为什么必须有这一层:官方「设置 → 模型」页在平台环境**必然报错** —— 该页要求 Host settings
* 镜像,而 `dsh-client-ui-settings` 的持久化判定是
* `isLoopback = transport.ownsHost || pageLocation === undefined || isLoopbackHostname(page)`,
* 平台是「浏览器经域名访问远程服务器」⇒ 三条皆不成立 ⇒ persistence 降级为 `memory`
* ⇒ `ensure()` 直接返回不读 ⇒ 页面必报「加载提供方目录失败」。详见
* `ensure-role-profile-patch.cjs` 的 `DISABLE_MODELS_BLOCK` / `ADMIN_MODELS_BLOCK` 注释。
* ⇒ 用户自配模型只能由**平台自己写文件**。
*
* 落点两处(字段名均为 2026-09-13 读官方包 `[email protected]` 实测,勿凭记忆改):
*
* 1. `$DSH_HOME/.credentials.yaml` → `refs.<REF>: '<key>'`
* (REF 须匹配 `@deepseek-ai/dsh-credentials` 的 `REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/`)
* 2. `$DSH_HOME/settings.yaml` → 顶层 `llm-pi-ai:` → `providers.<route>` →
* `apiKeyEnv` / `baseURL` / `api` / `models`
* ⚠️ 是 **`api`**(不是 `protocol`)、是 **`apiKeyEnv`**(不是 `apiKey`);
* 该 profile **不接受** `provider` / `maxRetries` / `maxRetryDelayMs`(会直接抛错);
* `api` 的合法值只有 `openai-completions` / `openai-responses` / `anthropic-messages`。
*
* 本模块**只做纯文本变换**(无 IO、无 db、无 crypto)⇒ 可以被
* `scripts/verify-model-landing.mjs` 用固定样例逐条断言。这类"改写别人家配置文件"的逻辑
* 最怕没有回归网:它错了不会报错,只会让实例静默少一个厂家。
*
* 安全约束(与既有 `ensureRefInCredentials` 同族,每条都是踩出来的):
* · 只认 `version: 1` 的凭据文档;认不出的布局**宁可不动**;
* · 平台**只管自己写过的**(`managed` 清单):用户自己放的 ref / 厂家段**绝不覆盖、绝不删**;
* · 要"删掉"时只删平台自己的:凭据=我们自己写的那一行,settings=我们自己那对标记之间;
* · 内联样式(`baseURL: ...`)而不是 JSON 块,避免把用户的其它字段卷进重排。
*
* @module dshs/web/model-landing
*/
/** 官方凭据 ref 名的语法(`dsh-credentials` 的 `REF_PATTERN`)。 */
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
/** 内置 DeepSeek 条目的 ref(官方与平台既有实现共同约定的名字)。 */
export const BUILTIN_REF = 'DEEPSEEK_API_KEY'
/** `settings.yaml` 里那个用户设置分区的名字(官方 `const NS = "llm-pi-ai"`)。 */
export const PI_AI_NS = 'llm-pi-ai'
/** 官方支持的线协议(`dsh-llm-pi-ai` 的 `PROTOCOLS` 键,顺序即默认优先级)。 */
export const PROTOCOLS = ['openai-completions', 'openai-responses', 'anthropic-messages'] as const
export type Protocol = (typeof PROTOCOLS)[number]
/** 一条「已启用」条目落地所需的全部信息(`value` 已是解密后的明文 key)。 */
export interface LandingEntry {
/** 展示名(只用于注释与日志)。 */
name: string
/** settings.yaml 的 `providers` dict 键;内置条目可为空。 */
route: string | null
/** 自定义厂家 endpoint;空 = 内置 DeepSeek。 */
baseUrl: string | null
/** 线协议;空 = `openai-completions`。 */
api: string | null
/** 模型 id 清单。 */
models: string[]
/** 解密后的 key 明文。 */
value: string
}
/** `reconcile*` 的返回:新文本 + 这一轮之后仍归平台管的键。 */
export interface ReconcileResult {
text: string
managed: string[]
}
/**
* 自定义厂家的 ref 名:`<ROUTE 大写、非字母数字折成 _>_API_KEY`。
* 结果**一定**匹配 `REF_PATTERN`(首字符强制成字母)—— 否则 dsh 解析凭据时会直接不认,
* 而且是静默的"这条 ref 不存在",排查成本极高。
*/
export function routeRef(route: string): string {
let up = (route ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '_').replace(/^_+|_+$/g, '')
if (up === '' || !/^[A-Z]/.test(up)) up = 'X' + up
return `${up}_API_KEY`
}
/** 该条目用哪个 ref:内置 ⇒ `DEEPSEEK_API_KEY`;自定义 ⇒ `routeRef(route)`。 */
export function refForEntry(entry: Pick<LandingEntry, 'route' | 'baseUrl'>): string {
return entry.baseUrl === null || entry.baseUrl === '' ? BUILTIN_REF : routeRef(entry.route ?? 'custom')
}
/** 协议取值规范化:认不出的一律回落官方默认(第一个),不抛错、不写坏配置。 */
export function normalizeProtocol(api: string | null | undefined): Protocol {
return (PROTOCOLS as readonly string[]).includes(api ?? '') ? (api as Protocol) : PROTOCOLS[0]
}
/** YAML 单引号标量转义(`'` → `''`)—— 避免 key 里的引号把文档弄坏。 */
function yamlSingle(value: string): string {
return `'${value.replace(/'/g, "''")}'`
}
function escapeRe(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* 把「已启用条目」对账进 `.credentials.yaml`。
*
* @param text - 现有文件内容(空串 = 文件不存在)。
* @param desired - 期望存在的 `{ ref, value }`。
* @param managed - **平台自己写过**的 ref 清单(上一次的返回值)。只有这里的 ref 允许被改写/删除。
* @returns 新文本 + 新的 managed 清单。认不出的布局会原样返回(宁可不动)。
*/
export function reconcileCredentials(
text: string,
desired: readonly { ref: string; value: string }[],
managed: readonly string[] = [],
): ReconcileResult {
const owned = new Set(managed)
const wanted = new Map(desired.map((d) => [d.ref, d.value]))
// 文件不存在 / 空 ⇒ 从零建一个最小合法文档(与既有 ensureRefInCredentials 同款)。
if (text.trim() === '') {
if (desired.length === 0) return { text, managed: [] }
const body = desired.map((d) => ` ${d.ref}: ${yamlSingle(d.value)}`).join('\n')
return { text: `version: 1\nrefs:\n${body}\n`, managed: desired.map((d) => d.ref) }
}
const lines = text.split('\n')
const insideRefs: boolean[] = []
let refsAt = -1
let versionAt = -1
let inside = false
for (let i = 0; i < lines.length; i++) {
const raw = lines[i]
const indented = /^[ \t]/.test(raw)
inside = indented ? inside : raw.trim() === 'refs:'
if (!indented && raw.trim() === 'refs:' && refsAt < 0) refsAt = i
if (!indented && versionAt < 0 && /^version:[ \t]*1[ \t]*$/.test(raw.trim())) versionAt = i
insideRefs.push(inside && indented)
}
const refAt = new Map<string, number>()
for (let i = 0; i < lines.length; i++) {
if (!insideRefs[i]) continue
const m = /^[ \t]+([A-Za-z_][A-Za-z0-9_]*)[ \t]*:/.exec(lines[i])
if (m !== null) refAt.set(m[1], i)
}
const drop = new Set<number>()
const kept: string[] = []
const setLine: Array<{ at: number; ref: string; value: string }> = []
const add: Array<{ ref: string; value: string }> = []
for (const ref of owned) {
if (wanted.has(ref)) {
kept.push(ref)
continue
}
const at = refAt.get(ref)
if (at !== undefined) drop.add(at) // 用户关掉了 ⇒ 把平台自己写的那行撤掉
}
for (const [ref, value] of wanted) {
const at = refAt.get(ref)
if (at === undefined) {
add.push({ ref, value })
kept.push(ref)
continue
}
if (!owned.has(ref)) continue // 文件里有、但不是平台写的 ⇒ 用户自己的,绝不碰
setLine.push({ at, ref, value })
kept.push(ref)
}
const inserted = new Map<number, string[]>()
if (add.length > 0) {
const anchor = refsAt >= 0 ? refsAt : versionAt
if (anchor < 0) return { text, managed: [...owned] } // 认不出布局 ⇒ 宁可不动
const body = add.map((d) => ` ${d.ref}: ${yamlSingle(d.value)}`)
inserted.set(anchor, refsAt >= 0 ? body : ['refs:', ...body])
}
const out: string[] = []
for (let i = 0; i < lines.length; i++) {
if (!drop.has(i)) {
const s = setLine.find((d) => d.at === i)
out.push(s === undefined ? lines[i] : ` ${s.ref}: ${yamlSingle(s.value)}`)
}
const extra = inserted.get(i)
if (extra !== undefined) out.push(...extra)
}
return { text: out.join('\n'), managed: [...new Set(kept)] }
}
/** 一条要写进 `settings.yaml` 的厂家声明。 */
export interface SettingsEntry {
route: string
apiKeyEnv: string
baseURL: string
api: Protocol
models: string[]
}
const markBegin = (route: string): string => ` # dshs:model-route ${route} begin`
const markEnd = (route: string): string => ` # dshs:model-route ${route} end`
/**
* 把厂家声明对账进 `settings.yaml`。
*
* 用**成对标记**夹住平台自己写的那个 route 段:① 不解析别人的 YAML(不重排、不丢注释);
* ② "关掉某个厂家"就是删掉自己那对标记之间的内容 ⇒ 精确可控。
* 代价:若 dsh 或用户某次把文件整体重写、标记丢了,就再也删不掉那个 route
* (但"已存在"检查仍会拦住重复写入,所以最坏是留一条模型清单里的僵尸厂家,不会写坏配置)。
*
* @param text - 现有文件内容(空串 = 文件不存在)。
* @param desired - 期望存在的厂家声明。
* @param managed - 平台自己写过的 route 清单。
*/
export function reconcileSettings(
text: string,
desired: readonly SettingsEntry[],
managed: readonly string[] = [],
): ReconcileResult {
const owned = new Set(managed)
const wanted = new Map(desired.map((d) => [d.route, d]))
// ① 先删:不再需要的、且是我们自己写的块。
const lines = text === '' ? [] : text.split('\n')
const kept: string[] = []
const present = new Set<string>()
for (let i = 0; i < lines.length; i++) {
const b = /^[ \t]*# dshs:model-route ([^\s]+) begin[ \t]*$/.exec(lines[i])
if (b === null) {
// 非标记行里如果已经有 ` <route>:`(可能是用户/官方自己写的)⇒ 记为"已存在",不重复写。
const k = /^[ \t]{4}([^\s:#][^\s:]*)[ \t]*:[ \t]*$/.exec(lines[i])
if (k !== null) present.add(k[1])
kept.push(lines[i])
continue
}
const route = b[1]
const endRe = new RegExp(`^[ \\t]*# dshs:model-route ${escapeRe(route)} end[ \\t]*$`)
let end = -1
for (let j = i + 1; j < lines.length; j++) {
if (endRe.test(lines[j])) {
end = j
break
}
}
if (wanted.has(route) || !owned.has(route)) {
// 还要留着(或不是我们的,不该动)⇒ 原样搬过去。
present.add(route)
if (end < 0) kept.push(lines[i])
else {
kept.push(...lines.slice(i, end + 1))
i = end
}
continue
}
// 用户已关掉这个厂家 ⇒ 整块丢掉(这就是"删")。
i = end < 0 ? i : end
}
const add = desired.filter((d) => !present.has(d.route))
const newOwned = [...new Set([...owned].filter((r) => wanted.has(r) || present.has(r)))]
if (add.length === 0) return { text: kept.join('\n'), managed: newOwned }
// ② 找 `llm-pi-ai:` → `providers:` 链,缺什么补什么,然后在 `providers:` 之后插入。
// ⚠️ 这里**不能**用"只看顶层行"的循环:`providers:` 本身就是缩进 2 的(它是
// `llm-pi-ai:` 的子键)。第一版就是这么写的 ⇒ 找不到既有 providers ⇒ 又补一行
// ` providers:` ⇒ 文档里出现**两个**同键(回归 [8]/[9] 当场抓到)。
let nsAt = -1
for (let i = 0; i < kept.length; i++) {
if (/^[ \t]/.test(kept[i])) continue
if (kept[i].trim() === `${PI_AI_NS}:`) {
nsAt = i
break
}
}
let provAt = -1
if (nsAt >= 0) {
for (let i = nsAt + 1; i < kept.length; i++) {
const raw = kept[i]
if (!/^[ \t]/.test(raw)) {
if (raw.trim() !== '') break // 撞到下一个顶层键 ⇒ 该分区到此为止
continue // 分区里的空行不算结束
}
if (raw.trim() === 'providers:') {
provAt = i
break
}
}
}
const blocks = add.flatMap((d) => [
markBegin(d.route),
` ${d.route}:`,
` apiKeyEnv: ${d.apiKeyEnv}`,
` baseURL: ${d.baseURL}`,
` api: ${d.api}`,
' models:',
...d.models.map((m) => ` - id: ${m}`),
markEnd(d.route),
])
const out = [...kept]
if (provAt >= 0) {
out.splice(provAt + 1, 0, ...blocks)
} else if (nsAt >= 0) {
out.splice(nsAt + 1, 0, ' providers:', ...blocks)
} else {
// 整个 `llm-pi-ai:` 分区都不在 ⇒ 追加到文件末尾(顶层键,顺序无关)。
if (out.length > 0 && out[out.length - 1] === '') out.splice(out.length - 1, 0, `${PI_AI_NS}:`, ' providers:', ...blocks, '')
else out.push(`${PI_AI_NS}:`, ' providers:', ...blocks)
}
return { text: out.join('\n'), managed: [...new Set([...newOwned, ...add.map((d) => d.route)])] }
}
/**
* 读出 `.credentials.yaml` 里某个 ref 的**当前值**(没有该 ref 时 `null`)。
*
* 用途只有一个:**一次性交接**(档案 87)。老实现把平台共享 key 直接写进
* `refs.DEEPSEEK_API_KEY`,但那时没有托管清单 ⇒ 新逻辑会把它当成"用户自己写的"而永不清理
* ⇒ 用户关掉共享开关后那个 key 仍然留在文件里("关掉即生效"就不成立)。
* 所以首次运行时要**认领**该 ref,但只在那行确实等于平台共享 key 的明文时才认领
* —— 否则就是用户自己配的,绝不碰。
* @param text - `.credentials.yaml` 内容。
* @param ref - 要读的 ref 名。
* @returns 去掉引号后的值,或 `null`。
*/
export function readRefValue(text: string, ref: string): string | null {
if (text === '') return null
const lines = text.split('\n')
let inside = false
for (const raw of lines) {
const indented = /^[ \t]/.test(raw)
inside = indented ? inside : raw.trim() === 'refs:'
if (!inside || !indented) continue
const m = new RegExp(`^[ \\t]+${ref}[ \\t]*:[ \\t]*(.*)$`).exec(raw)
if (m === null) continue
const v = m[1].trim()
if (v.length >= 2 && ((v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"')))) {
return v.slice(1, -1).replace(/''/g, "'")
}
return v
}
return null
}
/**
* 把 `models` 列(JSON 数组字符串)解析成 id 清单。
* 宽容:非数组、非字符串项、超量一律丢弃 —— 这条路上宁可少写一个模型,
* 也不能让一个坏值把整个 `settings.yaml` 变成 dsh 拒绝加载的文档。
*/
export function parseModels(json: string | null | undefined, limit = 50): string[] {
if (json === null || json === undefined) return []
try {
const raw: unknown = JSON.parse(json)
if (!Array.isArray(raw)) return []
const out: string[] = []
for (const item of raw) {
if (typeof item !== 'string') continue
const v = item.trim()
if (v === '' || v.length > 128) continue
if (!out.includes(v)) out.push(v)
if (out.length >= limit) break
}
return out
} catch {
return []
}
}
+165
View File
@@ -0,0 +1,165 @@
/**
* Admin 视角的「用户服务 / 工作区文件」路由(档案 86)。
*
* 背景:平台所有服务与文件 API 都是 `request.user.id` 语义(`desktop.ts` 头注释更明确写着
* "one user can never address another user's files")⇒ admin 在「设置 → 系统管理 → 服务管理」
* 里**只能管自己**。用户要求「admin 要能管所有用户的服务」。
*
* 做法:**不动既有路由的语义**(避免把越界风险塞进普通用户路径),另开一组
* `/api/admin/users/:id/...`,全部 `requireAdmin`,把 `request.user.id` 换成路径参数。
* 底层 `UserFs` / `Spawner` 本来就都接受 `userId` 首参 ⇒ 零新增能力面;
* 启动/状态复用 `dsh.ts` 导出的 `launchForUser` / `statusForUser`(一份实现,两处入口)。
*
* ⚠️ R5 权限影响评估:新增的是 **admin 对任意用户**的
* ① 浏览 / 新建 / 上传其工作区文件 —— 仍限定在该用户 ws 根内(`UserFs` 自带逃逸防护,
* 越界即 `bad_path`)
* ② 启停其 DSH 实例 —— 与用户自己点「启动 / 停止」同一条 `supervisor` 路径
* 这与 `requireAdmin` 既有职能(审批 / 禁用 / 删除用户)同级;服务器层面 admin 本就能读
* `/var/lib/dshs/users/**`。**不扩大普通用户的能力面** —— 这些前缀下没有任何 `requireAuth` 版本。
* @module dshs/web/routes/admin-user-ops
*/
import type { FastifyPluginAsync, FastifyReply } from 'fastify'
import { requireAdmin } from '../middleware/authn.js'
import { sendFsError } from './desktop.js'
import { AlreadyRunningError, CrashBreakerOpenError } from '../../supervisor/orchestrator.js'
import { dshUrl, launchForUser, sendBreakerOpen, statusForUser } from './dsh.js'
// 与 desktop.ts / dsh.ts 的同名 schema 同形(那两处未导出,这里按同一形状内联)。
const createSchema = {
body: {
type: 'object',
required: ['path', 'name', 'type'],
additionalProperties: false,
properties: {
path: { type: 'string', maxLength: 512 },
name: { type: 'string', maxLength: 255 },
type: { type: 'string', enum: ['file', 'dir'] },
},
},
} as const
const uploadSchema = {
body: {
type: 'object',
required: ['path', 'name', 'data'],
additionalProperties: false,
properties: {
path: { type: 'string', maxLength: 512 },
name: { type: 'string', maxLength: 255 },
data: { type: 'string' },
},
},
} as const
const launchSchema = {
body: {
type: 'object',
required: ['folder'],
additionalProperties: false,
properties: { folder: { type: 'string', maxLength: 512 } },
},
} as const
export const adminUserOpsRoutes: FastifyPluginAsync = async (app) => {
/**
* 解析 `:id` 指向的用户;不存在则回 404 并返回 `undefined`。
* 每个路由都过这一关 —— 防 `:id` 乱填导致 `UserFs` 在错误根上操作。
*/
async function targetOr404(id: string, reply: FastifyReply) {
const user = await app.db.findUserById(id)
if (user === undefined) {
reply.code(404).send({ error: 'not_found' })
return undefined
}
return user
}
// ── 工作区文件 ──────────────────────────────────────────────────────────────
app.get('/api/admin/users/:id/fs/tree', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
const { path = '' } = request.query as { path?: string }
if ((await targetOr404(id, reply)) === undefined) return
try {
return { path, entries: await app.userFs.listDir(id, path) }
} catch (err) {
return sendFsError(reply, err)
}
})
app.post(
'/api/admin/users/:id/fs/create',
{ preHandler: requireAdmin, schema: createSchema },
async (request, reply) => {
const { id } = request.params as { id: string }
const { path, name, type } = request.body as { path: string; name: string; type: 'file' | 'dir' }
if ((await targetOr404(id, reply)) === undefined) return
try {
return { ok: true, name: await app.userFs.createEntry(id, path, name, type), type }
} catch (err) {
return sendFsError(reply, err)
}
},
)
app.post(
'/api/admin/users/:id/fs/upload',
{ preHandler: requireAdmin, schema: uploadSchema },
async (request, reply) => {
const { id } = request.params as { id: string }
const { path, name, data } = request.body as { path: string; name: string; data: string }
if ((await targetOr404(id, reply)) === undefined) return
let buf: Buffer
try {
buf = Buffer.from(data, 'base64')
} catch {
return reply.code(400).send({ error: 'bad_data' })
}
try {
return { ok: true, name: await app.userFs.upload(id, path, name, buf) }
} catch (err) {
return sendFsError(reply, err)
}
},
)
// ── 实例启停与状态 ─────────────────────────────────────────────────────────
app.get('/api/admin/users/:id/dsh/status', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
const user = await targetOr404(id, reply)
if (user === undefined) return
return statusForUser(app, user)
})
app.post(
'/api/admin/users/:id/dsh/launch',
{ preHandler: requireAdmin, schema: launchSchema },
async (request, reply) => {
const { id } = request.params as { id: string }
const { folder } = request.body as { folder: string }
const user = await targetOr404(id, reply)
if (user === undefined) return
let instance
try {
instance = await launchForUser(app, id, folder)
} catch (err) {
if (err instanceof AlreadyRunningError) return reply.code(409).send({ error: 'already_running' })
if (err instanceof CrashBreakerOpenError) return sendBreakerOpen(reply, err)
return sendFsError(reply, err)
}
if (instance === null) return reply.code(400).send({ error: 'not_a_folder' })
return {
instance: { id: instance.id, port: instance.port, status: instance.status, launchToken: instance.launchToken },
// ⚠️ 打开的是**该用户**实例的带 token URL —— admin 用它即可直接进去看(同 `dshUrl` 语义)
url: dshUrl(app.config.baseDomain, user, instance.launchToken),
}
},
)
app.post('/api/admin/users/:id/dsh/stop', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
if ((await targetOr404(id, reply)) === undefined) return
await app.supervisor.stop(id)
return { ok: true }
})
}
+124 -25
View File
@@ -10,6 +10,7 @@ import { requireAuth } from '../middleware/authn.js'
import { homeRoot, userRoot } from '../../fs/workspace.js'
import { deriveKey, encrypt } from '../../crypto.js'
import { toPublicUser } from '../../db/types.js'
import { PROTOCOLS } from '../model-landing.js'
import {
clearSessionCookie,
hashPassword,
@@ -128,41 +129,79 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
properties: {
name: { type: 'string', minLength: 1, maxLength: 32 },
apiKey: { type: 'string', minLength: 1, maxLength: 256 },
// 档案 87:自定义厂家三件套 —— **都不给**就是老语义的「内置 DeepSeek 那一把 key」。
route: { type: 'string', minLength: 1, maxLength: 40 },
baseUrl: { type: 'string', maxLength: 300 },
api: { type: 'string', minLength: 1, maxLength: 40 },
models: { type: 'array', maxItems: 50, items: { type: 'string', minLength: 1, maxLength: 128 } },
},
},
} as const
// ---- 模型密钥:**两层并存**(档案 85 · 2026-09-13,用户要求「配置模型密钥开放给用户自己配」)--
// ① **用户自己的 key** —— 任何登录用户都能管理**自己那一格**(自配自用);
// ② **平台共享 key**(管理员设置的)—— 用户侧**只读可见**:没自配的人默认就用它。
// 两层互相独立、互不覆盖:`server.ts` 的 `resolveApiKey(userId)` 先取 ①,取不到才回落 ②。
// ⚠️ `DEEPSEEK_API_KEY` 是 **spawn 时注入 env 的快照** ⇒ 换 key 后必须重启实例才生效:
// admin 改的 key 就是共享 key ⇒ `restartAllMains()`(所有仍在回落的用户都得刷新);
// 其他人改自己的 ⇒ 只 `restartMain(自己)`,不动任何人。
const toggleSchema = {
body: {
type: 'object',
required: ['enabled'],
additionalProperties: false,
properties: { enabled: { type: 'boolean' } },
},
} as const
// ---- 模型厂家与密钥(档案 87 · 2026-09-13 第三轮口径)--------------------------
// 用户口径(**已定,不得再拿去当选择题**):
// ① 条目**各自开关、可同时启用**(不再互斥);
// ② admin 配的**平台共享模型也列入**列表,用户可开关(`users.shared_model_enabled`);
// ③ 具体用哪个模型**在 dsh 对话框的模型选择器里选** —— 平台只负责把「已启用」的都配好。
// ⇒ 因此**不再有**"当前生效的那一把"这种概念:`keySourceOf` 只回答"有没有自己的内置
// DeepSeek key",供界面文案用;真正生效的是 spawn 时的落地结果(`server.ts`)。
// ⚠️ 落地发生在 **spawn** 时 ⇒ 改完必须**重启实例**才生效,这也是这几条路由最后都要
// `refreshAfterKeyChange` 的原因。
/** 展示名 → route 的默认值:英文/数字折成小写短横线;纯中文名折不出东西 ⇒ `provider`。 */
function slugify(name: string): string {
const s = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
return s === '' || !/^[a-z0-9]/.test(s) ? 'provider' : s
}
/** 该用户当前**实际生效**的密钥来源。 */
async function keySourceOf(userId: string): Promise<'own' | 'shared' | 'none'> {
if ((await app.db.getEnabledCredentialKeyRef(userId)) !== null) return 'own'
// 关掉了共享开关的人**就是** none —— 这正是验收③要的语义。
if (!(await app.db.getSharedModelEnabled(userId))) return 'none'
const admins = (await app.db.listPublicUsers()).filter((u) => u.role === 'admin')
if (admins.length === 0) return 'none'
return (await app.db.getEnabledCredentialKeyRef(admins[0].id)) !== null ? 'shared' : 'none'
}
/** 平台共享密钥的**非敏感**信息(名字 / 归属;绝不返回密钥本身)。 */
async function sharedKeyInfo(
userId: string,
): Promise<{ available: boolean; name: string | null; owner: string | null; ownerIsMe: boolean }> {
/** 平台共享模型的**非敏感**信息(名字 / 归属 / 条数;绝不返回密钥本身)。 */
async function sharedKeyInfo(userId: string): Promise<{
available: boolean
name: string | null
owner: string | null
ownerIsMe: boolean
enabled: boolean
count: number
}> {
const admins = (await app.db.listPublicUsers()).filter((u) => u.role === 'admin')
if (admins.length === 0) return { available: false, name: null, owner: null, ownerIsMe: false }
const keys = await app.db.listCredentialKeys(admins[0].id)
const on = keys.find((k) => k.enabled)
// `ownerIsMe`:admin 看的是**自己**配的那把 ⇒ 前端文案要区分「我配的共享 key」与「别人配的」。
const enabled = await app.db.getSharedModelEnabled(userId)
if (admins.length === 0) return { available: false, name: null, owner: null, ownerIsMe: false, enabled, count: 0 }
// 档案 87:共享**不再假设只有一把** —— admin 也能配多条(与用户侧同一套口径)。
const keys = await app.db.listEnabledCredentialKeys(admins[0].id)
// `ownerIsMe`:admin 看的是**自己**配的那些 ⇒ 前端文案要区分「我配的」与「别人配的」。
return {
available: on !== undefined,
name: on?.name ?? null,
available: keys.length > 0,
name: keys[0]?.name ?? null,
owner: admins[0].username,
ownerIsMe: admins[0].id === userId,
enabled,
count: keys.length,
}
}
/** 换 key 后的刷新:admin 动的是共享 key ⇒ 广播重启;其他人只重启自己。 */
/** 改动后的刷新:admin 动的是共享内容 ⇒ 广播重启;其他人只重启自己。 */
async function refreshAfterKeyChange(userId: string, role: string): Promise<void> {
if (role === 'admin') await app.supervisor.restartAllMains()
else await app.supervisor.restartMain(userId)
@@ -172,28 +211,88 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
keys: await app.db.listCredentialKeys(request.user!.id),
effective: await keySourceOf(request.user!.id),
shared: await sharedKeyInfo(request.user!.id),
// 档 87:把「共享开关」与「协议枚举」一并给出,免得前端各写一份常量然后漂掉。
sharedModelEnabled: await app.db.getSharedModelEnabled(request.user!.id),
protocols: [...PROTOCOLS],
}))
app.post('/api/me/keys', { preHandler: requireAuth, schema: keyAddSchema }, async (request, reply) => {
const { name, apiKey } = request.body as { name: string; apiKey: string }
const cleanName = name.trim()
if (!/^[A-Za-z0-9\-_ .]{1,32}$/.test(cleanName)) {
const body = request.body as {
name: string
apiKey: string
route?: string
baseUrl?: string
api?: string
models?: string[]
}
const cleanName = body.name.trim()
// 展示名**允许中文**(寻址用的是 route),但仍拒掉控制字符,免得污染日志与界面。
// eslint-disable-next-line no-control-regex
if (cleanName === '' || /[\u0000-\u001f\u007f]/.test(cleanName)) {
return reply.code(400).send({ error: 'invalid_name' })
}
// Header-safe charset only: reject spaces, quotes, non-ASCII, etc.
if (!/^[A-Za-z0-9\-_.]{1,256}$/.test(apiKey)) {
if (!/^[A-Za-z0-9\-_.]{1,256}$/.test(body.apiKey)) {
return reply.code(400).send({ error: 'invalid_api_key' })
}
const baseUrl = (body.baseUrl ?? '').trim()
const isCustom = baseUrl !== ''
let route: string | null = null
let api: string | null = null
let models: string | null = null
if (isCustom) {
if (!/^https?:\/\/\S{1,280}$/.test(baseUrl)) return reply.code(400).send({ error: 'invalid_base_url' })
route = (body.route ?? '').trim().toLowerCase() || slugify(cleanName)
if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(route)) return reply.code(400).send({ error: 'invalid_route' })
if (body.api !== undefined && !(PROTOCOLS as readonly string[]).includes(body.api)) {
return reply.code(400).send({ error: 'invalid_api' })
}
api = body.api ?? null
const ids = (body.models ?? []).map((m) => m.trim()).filter((m) => m !== '')
if (ids.length === 0) return reply.code(400).send({ error: 'models_required' })
models = JSON.stringify(ids.slice(0, 50))
// route 是 settings.yaml 里的 dict 键 ⇒ 同一个用户下撞键 = 后写入的会覆盖前者,静默失效。
const existing = await app.db.listCredentialKeys(request.user!.id)
if (existing.some((k) => k.route === route && k.name !== cleanName)) {
return reply.code(409).send({ error: 'route_taken' })
}
}
const key = await app.db.setCredentialKey(
request.user!.id,
cleanName,
encrypt(apiKey, deriveKey(app.config.encryptionSecret)),
encrypt(body.apiKey, deriveKey(app.config.encryptionSecret)),
{ route, baseUrl: isCustom ? baseUrl : null, api, models },
)
await app.db.audit(request.user!.id, 'set_api_key', JSON.stringify({ name: cleanName }))
await app.db.audit(request.user!.id, 'set_api_key', JSON.stringify({ name: cleanName, route, custom: isCustom }))
await refreshAfterKeyChange(request.user!.id, request.user!.role)
return { key }
})
/** 开/关**单个**条目(档案 87 口径①:不互斥、可同时启用)。 */
app.post('/api/me/keys/:id/toggle', { preHandler: requireAuth, schema: toggleSchema }, async (request, reply) => {
const { id } = request.params as { id: string }
const { enabled } = request.body as { enabled: boolean }
if (!(await app.db.toggleCredentialKey(request.user!.id, id, enabled))) {
return reply.code(404).send({ error: 'not_found' })
}
await refreshAfterKeyChange(request.user!.id, request.user!.role)
return { ok: true }
})
/** 平台共享模型的开关(档案 87 口径②)—— 只动**自己**的偏好,不碰 admin 的配置。 */
app.post('/api/me/models/shared', { preHandler: requireAuth, schema: toggleSchema }, async (request, reply) => {
const { enabled } = request.body as { enabled: boolean }
if (!(await app.db.setSharedModelEnabled(request.user!.id, enabled))) {
return reply.code(404).send({ error: 'not_found' })
}
await refreshAfterKeyChange(request.user!.id, request.user!.role)
return { ok: true }
})
/**
* @deprecated 档案 87 起语义已变成"启用这一个、**不关**别的"(与 `toggle(true)` 同义)。
* 保留路由只为老客户端不 404;新前端不该再用它。
*/
app.post('/api/me/keys/:id/select', { preHandler: requireAuth }, async (request, reply) => {
const { id } = request.params as { id: string }
if (!(await app.db.selectCredentialKey(request.user!.id, id))) return reply.code(404).send({ error: 'not_found' })
@@ -204,7 +303,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
app.delete('/api/me/keys/:id', { preHandler: requireAuth }, async (request, reply) => {
const { id } = request.params as { id: string }
if (!(await app.db.deleteCredentialKey(request.user!.id, id))) return reply.code(404).send({ error: 'not_found' })
// 删掉自己最后一把 ⇒ 自动回落到平台共享密钥(这是"两层"应有的语义,不需要额外开关)
// 删掉的是自己配的 ⇒ 落地时自然回落到「平台共享模型」(前提是共享开关开着)。
await refreshAfterKeyChange(request.user!.id, request.user!.role)
return { ok: true }
})
+77 -58
View File
@@ -6,7 +6,8 @@
* @module dshs/web/routes/dsh
*/
import type { FastifyPluginAsync, FastifyReply } from 'fastify'
import type { FastifyInstance, FastifyPluginAsync, FastifyReply } from 'fastify'
import type { Instance } from '../../supervisor/spawner.js'
import { requireAuth } from '../middleware/authn.js'
import { sendFsError } from './desktop.js'
import { AlreadyRunningError, CrashBreakerOpenError } from '../../supervisor/orchestrator.js'
@@ -33,7 +34,7 @@ const restartSchema = {
},
} as const
function alive(status: string | undefined): boolean {
export function alive(status: string | undefined): boolean {
// 'failed' = 崩溃熔断后停止自动重启(档案 20),同样不可复用。
return status !== undefined && status !== 'crashed' && status !== 'stopped' && status !== 'failed'
}
@@ -45,7 +46,7 @@ function alive(status: string | undefined): boolean {
* 客户端应展示「稍后重试」。`retryAfterMs` 供前端提示具体等待时长;
* `opens` 是累计熔断次数(同一实例反复崩 → 该值递增,可据此判断"该找人了")。
*/
function sendBreakerOpen(reply: FastifyReply, err: CrashBreakerOpenError): FastifyReply {
export function sendBreakerOpen(reply: FastifyReply, err: CrashBreakerOpenError): FastifyReply {
return reply.code(503).send({
error: 'instance_circuit_open',
opens: err.opens,
@@ -54,50 +55,92 @@ function sendBreakerOpen(reply: FastifyReply, err: CrashBreakerOpenError): Fasti
})
}
function dshUrl(baseDomain: string, user: { id: string; username: string }, token?: string): string {
export function dshUrl(baseDomain: string, user: { id: string; username: string }, token?: string): string {
const sub = subdomainForUser(baseDomain, user.username)
const base = sub !== null ? `https://${sub}/` : `/u/${user.id}/dsh/`
return token !== undefined && token !== '' ? `${base}?token=${encodeURIComponent(token)}` : base
}
/**
* 启动**任意用户**实例的共用主体(档案 86):`POST /api/dsh/launch`(自己)与
* `POST /api/admin/users/:id/dsh/launch`(admin 替别人)都走它 —— 避免"两处副本必然漂"。
*
* 返回 `null` 表示目标路径不是文件夹(调用方回 400 `not_a_folder`);
* `fs.resolvePath` 的越界错误原样抛出(调用方走 `sendFsError`);
* `AlreadyRunningError` / `CrashBreakerOpenError` 也原样抛出(调用方映射 409 / 503)。
*
* ⚠️ `userId` 是**被操作的那个用户**,不是调用者 —— 调用方负责鉴权(自己的 id 或 admin)。
*/
export async function launchForUser(
app: FastifyInstance,
userId: string,
folder: string,
): Promise<Instance | null> {
const fs = app.userFs
const folderAbs = fs.resolvePath(userId, folder)
if (!(await fs.isDirectory(userId, folder))) return null
// Per-folder plugin selection → cordis patch. Rendered here but *not* written:
// the spawner decides where it lands (a file under local, a ConfigMap under k8s).
let patch: string | undefined
if (app.config.enablePatch) {
const workspace = await app.db.findWorkspaceByPath(userId, folder)
// Only inject plugins the user still has installed; a stale selection for a
// since-removed bundle would otherwise fail to resolve in the child DSH.
const installed = new Set((await fs.listInstalledPlugins(userId)).map((plugin) => plugin.id))
const enabled = (workspace === undefined ? [] : await app.db.getEnabledPluginIds(workspace.id)).filter((id) =>
installed.has(id),
)
patch = renderPatch(enabled)
}
return app.supervisor.launch(userId, folderAbs, patch)
}
/**
* 某用户实例的**观测面**(`GET /api/dsh/status` 与 admin 视角共用;档案 86)。
* 归档口径见档案 20 / 78 / 84 —— 只此一份,别再复制出第二份。
*/
export async function statusForUser(app: FastifyInstance, user: { id: string; username: string }) {
const { main, watchdog } = await app.supervisor.status(user.id)
return {
running: alive(main?.status),
instance: main
? {
id: main.id,
port: main.port,
status: main.status,
exitCode: main.exitCode,
lastError: main.lastError,
// 观测面(档案 20 · A2):自动重启次数 + 最近崩溃时间
restarts: main.restarts ?? 0,
lastCrashedAt: main.lastCrashedAt ?? null,
}
: null,
watchdog: watchdog ? { id: watchdog.id, status: watchdog.status, exitCode: watchdog.exitCode } : null,
// 观测面(档案 78):熔断状态 —— 非 null 即"该用户正被冷却",供门户/排查直接看到
breaker: app.supervisor.breakerInfo?.(user.id) ?? null,
// 观测面(档案 84):本实例**真实**内存配额(= instanceMemMb() 的结果,与 spawn 同源)
quota: app.supervisor.quotaInfo?.(user.id) ?? null,
url: dshUrl(app.config.baseDomain, user, main?.launchToken),
}
}
export const dshRoutes: FastifyPluginAsync = async (app) => {
app.post('/api/dsh/launch', { preHandler: requireAuth, schema: launchSchema }, async (request, reply) => {
const { folder } = request.body as { folder: string }
const user = request.user!
const fs = app.userFs
let folderAbs: string
let instance: Instance | null
try {
folderAbs = fs.resolvePath(user.id, folder)
if (!(await fs.isDirectory(user.id, folder))) return reply.code(400).send({ error: 'not_a_folder' })
} catch (err) {
return sendFsError(reply, err)
}
// Per-folder plugin selection → cordis patch. Rendered here but *not*
// written: the spawner decides where it lands (a file under local, a
// ConfigMap under k8s, where the control plane has no user volume).
let patch: string | undefined
if (app.config.enablePatch) {
const workspace = await app.db.findWorkspaceByPath(user.id, folder)
// Only inject plugins the user still has installed; a stale selection for
// a since-removed bundle would otherwise fail to resolve in the child DSH.
const installed = new Set((await fs.listInstalledPlugins(user.id)).map((plugin) => plugin.id))
const enabled = (workspace === undefined ? [] : await app.db.getEnabledPluginIds(workspace.id))
.filter((id) => installed.has(id))
patch = renderPatch(enabled)
}
try {
const instance = await app.supervisor.launch(user.id, folderAbs, patch)
return {
instance: { id: instance.id, port: instance.port, status: instance.status, launchToken: instance.launchToken },
url: dshUrl(app.config.baseDomain, user, instance.launchToken),
}
instance = await launchForUser(app, user.id, folder)
} catch (err) {
if (err instanceof AlreadyRunningError) return reply.code(409).send({ error: 'already_running' })
// 档案 78:熔断冷却期内的启动被拒(用户选文件夹也会走到这里)
if (err instanceof CrashBreakerOpenError) return sendBreakerOpen(reply, err)
throw err
return sendFsError(reply, err) // 路径越界等 → 400(非 UserFsError 会被原样抛出)
}
if (instance === null) return reply.code(400).send({ error: 'not_a_folder' })
return {
instance: { id: instance.id, port: instance.port, status: instance.status, launchToken: instance.launchToken },
url: dshUrl(app.config.baseDomain, user, instance.launchToken),
}
})
@@ -157,31 +200,7 @@ export const dshRoutes: FastifyPluginAsync = async (app) => {
}
})
app.get('/api/dsh/status', { preHandler: requireAuth }, async (request) => {
const { main, watchdog } = await app.supervisor.status(request.user!.id)
return {
running: alive(main?.status),
instance: main
? {
id: main.id,
port: main.port,
status: main.status,
exitCode: main.exitCode,
lastError: main.lastError,
// 观测面(档案 20 · A2):自动重启次数 + 最近崩溃时间
restarts: main.restarts ?? 0,
lastCrashedAt: main.lastCrashedAt ?? null,
}
: null,
watchdog: watchdog ? { id: watchdog.id, status: watchdog.status, exitCode: watchdog.exitCode } : null,
// 观测面(档案 78):熔断状态 —— 非 null 即"该用户正被冷却",供门户/排查直接看到
breaker: app.supervisor.breakerInfo?.(request.user!.id) ?? null,
// 观测面(档案 84):本实例**真实**内存配额(= instanceMemMb() 的结果,与 spawn 同源)。
// 实例内「功能管理」读它显示真值;读不到(老平台/ k8s 模式)时前端才退回保守估算。
quota: app.supervisor.quotaInfo?.(request.user!.id) ?? null,
url: dshUrl(app.config.baseDomain, request.user!, main?.launchToken),
}
})
app.get('/api/dsh/status', { preHandler: requireAuth }, async (request) => statusForUser(app, request.user!))
// 登录直达(方案 05,2026-09-09;admin 亦直达 —— 2026-09-09 决策②补充):
// 所有已放行角色(admin / active)统一:已运行实例复用其 launchToken URL,
+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)