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:
1 parent
eb50ca2d5b
commit
918f1d3a51
20 files changed
+2073
-276
No files matched your search
+17
-1
@@ -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
@@ -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
@@ -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). */
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user