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

+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,