平台共享模型改为「管理员逐用户授权」+ 品牌中文名改「能力网络」+ 修掉跨机模型落地/语言偏好静默失效
三条线合并入库(同一次部署批次,源码与生产此前已一致):
一、档案 138 · 平台共享模型:管理员逐用户授权(默认关闭)
用户口径原文:「admin 设置的共享模型,需要 admin 在用户列表中开启(新增选项,默认关闭),
用户才能在会话中使用(以及在设置的模型设置页面展示)」。
· DB 迁移 v11:新增 users.shared_model_granted(DEFAULT 0 = 默认关闭)。
⚠️ 刻意**不**复用 v6 的 shared_model_enabled —— 那是用户侧偏好(用户能自己关,默认 1),
而本需求要的是**管理员门禁**;共用一列则用户点一下就给自己授权,门禁形同不存在。
生效 = granted ∧ enabled(server.ts#sharedLandingRows)。
· 新路由 POST /api/admin/users/:id/models/shared(requireAdmin)+ 审计 shared_model_grant
+ **尽力而为**重启该用户实例(租约被占/实例未运行都不算失败)。
· admin 用户列表新增「共享模型」列;用户侧 /api/me/keys 增 shared.granted / sharedModelGranted;
插件 0.3.24:未授权时「平台共享模型」整块不渲染。
二、顺带修掉一个既有真缺陷:平台侧写用户 home 必须走 UserFs(用户卷在 worker 上)
landModels 原先 join(owner.home_dir, …) + 本机 fs ⇒ 对「实例不在控制面本机」的用户
读到空串(**不报错**)⇒ 模型落地一直是**静默空操作**(托管清单还被清空)
——即档案 87 的模型设置页对 guest 这类用户**从未生效**。
· UserFs 新增 readHomeFile/writeHomeFile + 文件名白名单(settings.yaml / .credentials.yaml)
· worker agent 新增 /fs/home-read /fs/home-write(只认白名单裸文件名)
· landModels 改走 userFs(与"文件面"同一份按归属路由 ⇒ 落地与实例必然同机)
· 同轮把 /api/me/locale(档案 102 语言偏好)也改成同一套(原先同样失效)
· home-files.ts 抽出 backupHomeFile(备份留平台侧,命名规则逐字不变)
三、档案 139 · 品牌中文名:能力枢纽 → 能力网络(其他语言仍 CapabilityNet)
落点四处:i18n 中文词条 / admin.html 顶栏 / favicon.svg 的 title+aria-label / design.css 注释;
test/i18n-brand.test.mjs 期望值同步。档案 137 顶部加"后续"指针,不改历史。
验证(全部真机实测):
· 红腿:未授权 → 106 上 guest 的 .credentials.yaml refs 变空(共享 key 被撤)
· 绿腿:授权 → key 回来 + 托管清单恢复 ["DEEPSEEK_API_KEY"]
· admin 列表带出 sharedModelGranted;用户侧 granted 随授权翻面(true/shared ↔ false/none)
· 开关两次均 200(不再假失败);插件实装 0.3.24 且含 gating 字符串
· 语言偏好:106 上 settings.yaml 出现 locale.preference=en(属主=实例属主,既有段逐字保留)
· 本机 npm test 226 tests / 225 pass / 0 fail / 1 skipped;四个 verify 脚本全绿
部署:47 推 51 个 lib 产物、106 推 12 个(lib/ 是 gitignore ⇒ 回滚点物化在
/opt/dsh/backups/seq138b-20260919-125345/,逐文件对账 0 不一致;先 106 后 47);
插件 business-plugins 0.3.24(两机 artifacts 与本机 pack md5 一致)。
This commit is contained in:
1 parent
971ccc3703
commit
c2b7c5ef71
30 files changed
+770
-70
No files matched your search
@@ -115,6 +115,12 @@ export interface DbAdapter {
|
||||
/** 档案 86:用户是否使用「平台共享模型」(admin 配的那把)—— 用户侧偏好。 */
|
||||
getSharedModelEnabled(userId: string): Promise<boolean>
|
||||
setSharedModelEnabled(userId: string, enabled: boolean): Promise<boolean>
|
||||
/**
|
||||
* 档案 138(v11):**管理员**是否已给该用户开启「平台共享模型」—— 逐用户门禁,默认关闭。
|
||||
* 生效 = 本项 ∧ `getSharedModelEnabled`(缺一不给)。
|
||||
*/
|
||||
getSharedModelGranted(userId: string): Promise<boolean>
|
||||
setSharedModelGranted(userId: string, granted: 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>
|
||||
|
||||
+23
-2
@@ -54,7 +54,11 @@ import {
|
||||
// both backends. This is process-global and idempotent.
|
||||
types.setTypeParser(20, (value: string) => Number(value))
|
||||
|
||||
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid, email'
|
||||
// ⚠️ **本文件有一份自己的列清单**(PG 方言的查询都直接用它)—— ⛔ 与 `repo.ts` 的
|
||||
// `USER_COLS` 是**两份**,加列时**两处都要改**(档案 138 实测踩到:只改了 repo.ts
|
||||
// ⇒ PG 下 `listPublicUsers` 读不到 `shared_model_granted`,admin 用户列表恒显示"未开启")。
|
||||
const USER_COLS =
|
||||
'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid, email, shared_model_granted'
|
||||
const EMAIL_CODE_COLS =
|
||||
'id, email, purpose, code_hash, status, attempts, ip, username, reason, created_at, expires_at, consumed_at'
|
||||
const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at'
|
||||
@@ -137,6 +141,8 @@ export class PgAdapter implements DbAdapter {
|
||||
approved_by: null,
|
||||
uid,
|
||||
email: input.email ?? null,
|
||||
// v11:新用户未授权平台共享模型(默认关闭),见 repo.ts 同处注释。
|
||||
shared_model_granted: false,
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
@@ -310,7 +316,7 @@ export class PgAdapter implements DbAdapter {
|
||||
async findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined> {
|
||||
const { rows } = await this.pool.query(
|
||||
`SELECT u.id, u.username, u.pass_hash, u.role, u.home_dir, u.api_key_ref, u.created_at, u.approved_by, u.uid,
|
||||
s.expires_at
|
||||
u.shared_model_granted, u.email, s.expires_at
|
||||
FROM sessions s JOIN users u ON s.user_id = u.id
|
||||
WHERE s.token_hash = $1`,
|
||||
[tokenHash],
|
||||
@@ -589,6 +595,21 @@ export class PgAdapter implements DbAdapter {
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
/** 管理员是否已给该用户开启「平台共享模型」(档案 138 · v11)。缺失行按 `false`(失败关闭)。 */
|
||||
async getSharedModelGranted(userId: string): Promise<boolean> {
|
||||
const { rows } = await this.pool.query('SELECT shared_model_granted FROM users WHERE id = $1', [userId])
|
||||
const row = rows[0] as { shared_model_granted: number } | undefined
|
||||
return row === undefined ? false : Number(row.shared_model_granted) !== 0
|
||||
}
|
||||
|
||||
async setSharedModelGranted(userId: string, granted: boolean): Promise<boolean> {
|
||||
const result = await this.pool.query('UPDATE users SET shared_model_granted = $1 WHERE id = $2', [
|
||||
granted ? 1 : 0,
|
||||
userId,
|
||||
])
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
async deleteCredentialKey(userId: string, id: string): Promise<boolean> {
|
||||
const result = await this.pool.query('DELETE FROM credential_vault WHERE id = $1 AND user_id = $2', [id, userId])
|
||||
return (result.rowCount ?? 0) > 0
|
||||
|
||||
+26
-2
@@ -49,7 +49,8 @@ import {
|
||||
type Workspace,
|
||||
} from './types.js'
|
||||
|
||||
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid, email'
|
||||
const USER_COLS =
|
||||
'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid, email, shared_model_granted'
|
||||
const EMAIL_CODE_COLS =
|
||||
'id, email, purpose, code_hash, status, attempts, ip, username, reason, created_at, expires_at, consumed_at'
|
||||
const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at'
|
||||
@@ -78,6 +79,9 @@ export function createUser(db: Database, input: CreateUserInput, baseUid: number
|
||||
approved_by: null,
|
||||
uid,
|
||||
email: input.email ?? null,
|
||||
// v11:新用户**未授权**平台共享模型(默认关闭)—— 与列默认值一致,这里显式写出来
|
||||
// 是为了让"新用户拿到什么"在这一个地方就能读全,不必再去翻迁移 SQL。
|
||||
shared_model_granted: false,
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -481,6 +485,26 @@ export function setSharedModelEnabled(db: Database, userId: string, enabled: boo
|
||||
return info.changes > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* **管理员**是否已给该用户开启「平台共享模型」(档案 138 · v11)—— 逐用户门禁。
|
||||
*
|
||||
* 缺失行按 **`false`**(= 未授权):这与 `getSharedModelEnabled` 的"缺失按 true"**故意相反**,
|
||||
* 因为两者方向不同 —— 用户偏好缺失时"多给"是无害的,而门禁缺失时"多给"就等于把门打开了。
|
||||
* 门禁类判据一律**失败关闭**。
|
||||
*/
|
||||
export function getSharedModelGranted(db: Database, userId: string): boolean {
|
||||
const row = prepare(db, 'SELECT shared_model_granted FROM users WHERE id = ?').get(userId) as
|
||||
| { shared_model_granted: number }
|
||||
| undefined
|
||||
return row === undefined ? false : row.shared_model_granted !== 0
|
||||
}
|
||||
|
||||
/** 设置「平台共享模型」的管理员授权(档案 138)。@returns 是否命中该用户。 */
|
||||
export function setSharedModelGranted(db: Database, userId: string, granted: boolean): boolean {
|
||||
const info = prepare(db, 'UPDATE users SET shared_model_granted = ? WHERE id = ?').run(granted ? 1 : 0, userId)
|
||||
return info.changes > 0
|
||||
}
|
||||
|
||||
/** Delete a named key (by id, scoped to the user). */
|
||||
export function deleteCredentialKey(db: Database, userId: string, id: string): boolean {
|
||||
const info = prepare(db, 'DELETE FROM credential_vault WHERE id = ? AND user_id = ?').run(id, userId)
|
||||
@@ -530,7 +554,7 @@ export function findSessionWithUser(db: Database, tokenHash: string): SessionUse
|
||||
const row = prepare(
|
||||
db,
|
||||
`SELECT u.id, u.username, u.pass_hash, u.role, u.home_dir, u.api_key_ref, u.created_at, u.approved_by, u.uid,
|
||||
s.expires_at
|
||||
u.shared_model_granted, u.email, s.expires_at
|
||||
FROM sessions s JOIN users u ON s.user_id = u.id
|
||||
WHERE s.token_hash = ?`,
|
||||
).get(tokenHash) as Record<string, unknown> | undefined
|
||||
|
||||
@@ -442,6 +442,30 @@ CREATE INDEX IF NOT EXISTS idx_email_codes_email ON email_codes (email, purpose,
|
||||
CREATE INDEX IF NOT EXISTS idx_email_codes_ip ON email_codes (ip, created_at);
|
||||
`
|
||||
|
||||
// v11: **平台共享模型改为「管理员逐用户授权」**(2026-09-19 用户口径)。
|
||||
//
|
||||
// 口径原文:「admin 设置的共享模型,需要 admin 在用户列表中开启(新增选项,默认关闭),
|
||||
// 用户才能在会话中使用(以及在设置的模型设置页面展示)」。
|
||||
//
|
||||
// 为什么**新开一列**而不是把 v6 的 `shared_model_enabled` 改成默认 0:
|
||||
// · v6 那一列的语义是**用户侧偏好**(用户能在「设置 → 模型设置」里自己关掉,默认开)——
|
||||
// 需求要的是**管理员门禁**,两者是**不同的人、不同的意图**;
|
||||
// · 若共用一列,用户在自己的设置里点一下就能把自己"授权"了 ⇒ 门禁形同不存在;
|
||||
// · 故:`shared_model_granted` = **管理员授权**(本列,`DEFAULT 0` = 默认关闭,
|
||||
// 用户自己改不了);`shared_model_enabled` = **用户偏好**(不变)。
|
||||
// · **生效 = granted AND enabled**(`server.ts#sharedLandingRows`)。
|
||||
//
|
||||
// ⚠️ `DEFAULT 0` 让**存量行也一并变 0**(PG/SQLite 加列都用默认值回填)⇒ 迁移后
|
||||
// **所有既有用户都处于"未授权"**,需要 admin 在用户列表里逐个开启。这正是"默认关闭"的字面语义,
|
||||
// 也是这条门禁第一次生效的可见证据(验收见档案 138)。
|
||||
const SQLITE_V11 = `
|
||||
ALTER TABLE users ADD COLUMN shared_model_granted INTEGER NOT NULL DEFAULT 0;
|
||||
`
|
||||
|
||||
const PG_V11 = `
|
||||
ALTER TABLE users ADD COLUMN shared_model_granted INTEGER NOT NULL DEFAULT 0;
|
||||
`
|
||||
|
||||
interface Migration {
|
||||
version: number
|
||||
name: string
|
||||
@@ -460,6 +484,7 @@ const MIGRATIONS: readonly Migration[] = [
|
||||
{ version: 8, name: 'host reachability via (覆盖网络 S2)', sqlite: SQLITE_V8, pg: PG_V8 },
|
||||
{ version: 9, name: 'host network id (覆盖网络 P0-1)', sqlite: SQLITE_V9, pg: PG_V9 },
|
||||
{ version: 10, name: 'user email + email verification codes', sqlite: SQLITE_V10, pg: PG_V10 },
|
||||
{ version: 11, name: 'shared model admin grant (per-user, default off)', sqlite: SQLITE_V11, pg: PG_V11 },
|
||||
]
|
||||
|
||||
/** Apply unapplied SQLite migrations inside a single transaction. */
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
getEnabledPluginIds as getEnabledPluginIdsSync,
|
||||
getOrCreateWorkspace as getOrCreateWorkspaceSync,
|
||||
getSharedModelEnabled as getSharedModelEnabledSync,
|
||||
getSharedModelGranted as getSharedModelGrantedSync,
|
||||
latestSentEmailCode as latestSentEmailCodeSync,
|
||||
listBusinessPlugins as listBusinessPluginsSync,
|
||||
listCredentialKeys as listCredentialKeysSync,
|
||||
@@ -61,6 +62,7 @@ import {
|
||||
setFolderPlugins as setFolderPluginsSync,
|
||||
setInstanceStatus as setInstanceStatusSync,
|
||||
setSharedModelEnabled as setSharedModelEnabledSync,
|
||||
setSharedModelGranted as setSharedModelGrantedSync,
|
||||
setUserRole as setUserRoleSync,
|
||||
setUserUid as setUserUidSync,
|
||||
toggleCredentialKey as toggleCredentialKeySync,
|
||||
@@ -331,6 +333,14 @@ export class SqliteAdapter implements DbAdapter {
|
||||
return setSharedModelEnabledSync(this.db, userId, enabled)
|
||||
}
|
||||
|
||||
async getSharedModelGranted(userId: string): Promise<boolean> {
|
||||
return getSharedModelGrantedSync(this.db, userId)
|
||||
}
|
||||
|
||||
async setSharedModelGranted(userId: string, granted: boolean): Promise<boolean> {
|
||||
return setSharedModelGrantedSync(this.db, userId, granted)
|
||||
}
|
||||
|
||||
async deleteCredentialKey(userId: string, id: string): Promise<boolean> {
|
||||
return deleteCredentialKeySync(this.db, userId, id)
|
||||
}
|
||||
|
||||
+20
-1
@@ -25,6 +25,14 @@ export interface User {
|
||||
uid: number | null
|
||||
/** Registration e-mail (v10); null for the legacy rows created before v10. */
|
||||
email: string | null
|
||||
/**
|
||||
* **管理员**是否已给该用户开启「平台共享模型」(v11,**默认 false**)。
|
||||
*
|
||||
* 与 `shared_model_enabled`(用户侧偏好,默认 true)是**两回事**:本列是门禁(只有 admin
|
||||
* 能在用户列表里改),两者**同时为真**才真正把 admin 配的模型落到该用户实例上。
|
||||
* 详见 `schema.ts` 的 `SQLITE_V11` 注释。
|
||||
*/
|
||||
shared_model_granted: boolean
|
||||
}
|
||||
|
||||
/** One `email_codes` row: a sent code *or* a rejected/failed send attempt. */
|
||||
@@ -52,6 +60,8 @@ export interface PublicUser {
|
||||
username: string
|
||||
role: UserRole
|
||||
createdAt: number
|
||||
/** v11:管理员是否已为该用户开启「平台共享模型」(admin 用户列表要显示它)。 */
|
||||
sharedModelGranted: boolean
|
||||
}
|
||||
|
||||
/** A persisted login session (token stored only as its hash). */
|
||||
@@ -269,7 +279,13 @@ export interface UpsertDshInstanceInput {
|
||||
}
|
||||
|
||||
export function toPublicUser(user: User): PublicUser {
|
||||
return { id: user.id, username: user.username, role: user.role, createdAt: user.created_at }
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
createdAt: user.created_at,
|
||||
sharedModelGranted: user.shared_model_granted,
|
||||
}
|
||||
}
|
||||
|
||||
// Row mappers. Shared by both adapters — they read the same column names, so the
|
||||
@@ -288,6 +304,9 @@ export function toUser(row: Record<string, unknown>): User {
|
||||
approved_by: (row.approved_by as string | null) ?? null,
|
||||
uid: (row.uid as number | null) ?? null,
|
||||
email: (row.email as string | null) ?? null,
|
||||
// ⚠️ 用 `?? 0`:`findSessionWithUser` 走的是自己的列清单,未取该列时这里是 `undefined`
|
||||
// —— 若写成 `!== 0`,`undefined !== 0` 会得到 `true`(把没授权的人当成已授权)。
|
||||
shared_model_granted: Number(row.shared_model_granted ?? 0) !== 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+35
-2
@@ -7,12 +7,12 @@
|
||||
*/
|
||||
|
||||
import { chownSync, chmodSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { chmod, chown, mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { basename, join } from 'node:path'
|
||||
import { PathEscapeError, resolveWithinRoot, safeFilename } from '../web/middleware/fs-guard.js'
|
||||
import { listInstalledPlugins, type PluginInfo } from './plugins.js'
|
||||
import { UserFsError, type UserFs } from './user-fs.js'
|
||||
import { UserFsError, isHomeFileName, type HomeFileName, type UserFs } from './user-fs.js'
|
||||
import { ensureDir, handoffPath, homeRoot, listDir, rejectSymlinkEscape, workspaceRoot, type FsEntry } from './workspace.js'
|
||||
|
||||
/** Resolve a user id to that user's data root (`<dataRoot>/users/<id>`, or a
|
||||
@@ -141,6 +141,39 @@ export class LocalUserFs implements UserFs {
|
||||
await writeFile(handoffPath(root), content)
|
||||
}
|
||||
|
||||
/**
|
||||
* 读 home 下的平台托管配置文件(档案 138)。
|
||||
* 名字走**白名单**(只允许固定文件名)⇒ 不可能拼出 `../` 之类的越界路径。
|
||||
*/
|
||||
async readHomeFile(userId: string, name: HomeFileName): Promise<string | null> {
|
||||
if (!isHomeFileName(name)) throw new UserFsError('bad_path')
|
||||
try {
|
||||
return await readFile(join(homeRoot(this.rootFor(userId)), name), 'utf8')
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写 home 下的平台托管配置文件:`0600` + chown 给 **home 属主**。
|
||||
* chown 不能省 —— 实例以 `dsh-<uid>` 身份读它(见 `home-files.ts` 头注释① ②)。
|
||||
*/
|
||||
async writeHomeFile(userId: string, name: HomeFileName, text: string): Promise<void> {
|
||||
if (!isHomeFileName(name)) throw new UserFsError('bad_path')
|
||||
const home = homeRoot(this.rootFor(userId))
|
||||
ensureDir(home)
|
||||
const file = join(home, name)
|
||||
await writeFile(file, text, { mode: 0o600 })
|
||||
try {
|
||||
const st = await stat(home)
|
||||
await chown(file, st.uid, st.gid)
|
||||
await chmod(file, 0o600)
|
||||
} catch {
|
||||
/* chown 失败(非 root 运行等)不阻断 —— 与 home-files.ts 同款 */
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a workspace-relative path, self-healing the root the way the
|
||||
* pre-seam routes did (every one of them called `ensureWorkspaceRoot` first). */
|
||||
private resolve(userId: string, relPath: string): string {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { AGENT_TOKEN_HEADER } from '../worker/agent.js'
|
||||
import { PathEscapeError, resolveWithinRoot } from '../web/middleware/fs-guard.js'
|
||||
import type { PluginInfo } from './plugins.js'
|
||||
import { UserFsError, isUserFsErrorCode, type UserFs } from './user-fs.js'
|
||||
import { UserFsError, isUserFsErrorCode, type HomeFileName, type UserFs } from './user-fs.js'
|
||||
import type { FsEntry } from './workspace.js'
|
||||
import { userRoot, workspaceRoot } from './workspace.js'
|
||||
|
||||
@@ -201,6 +201,20 @@ export class RemoteUserFs implements UserFs {
|
||||
await this.post('/fs/handoff', { userId, content })
|
||||
}
|
||||
|
||||
/**
|
||||
* 读用户 home 下的平台托管配置文件(档案 138)—— 走 agent 的 `/fs/home-read`,
|
||||
* 目标是 `hostIdFor(userId)` 钉住的那台机(与实例同机,见 `user-fs.ts#readHomeFile` 注释)。
|
||||
* 文件不存在 ⇒ `null`。
|
||||
*/
|
||||
async readHomeFile(userId: string, name: HomeFileName): Promise<string | null> {
|
||||
const out = await this.post<{ text: string | null }>('/fs/home-read', { userId, name })
|
||||
return out.text
|
||||
}
|
||||
|
||||
async writeHomeFile(userId: string, name: HomeFileName, text: string): Promise<void> {
|
||||
await this.post('/fs/home-write', { userId, name, text })
|
||||
}
|
||||
|
||||
/** 探测 worker 的 dataRoot(用于启动时的基线一致性检查,见 `server.ts`)。 */
|
||||
async probeWorkerRoot(): Promise<string | undefined> {
|
||||
try {
|
||||
|
||||
@@ -90,4 +90,39 @@ export interface UserFs {
|
||||
listInstalledPlugins(userId: string): Promise<PluginInfo[]>
|
||||
/** Write the post-restart command handoff the watchdog reads. */
|
||||
writeHandoff(userId: string, content: string): Promise<void>
|
||||
/**
|
||||
* 读用户 **home**(`$DSH_HOME`)下的**平台托管配置文件**(档案 138)。
|
||||
*
|
||||
* 为什么必须走本 seam 而不是直接 `fs.readFile(home_dir)`:**用户卷跟着实例走**
|
||||
* —— 实例在 worker 上时,`home/` 就在那台机器上。平台侧直接 `readFile` 只会读到
|
||||
* 自己盘上一个**不存在的路径**(返回空串、不报错)⇒ 落地层静默变成空操作
|
||||
* (2026-09-19 实测:托管清单被清空、目标文件一个字节没动)。
|
||||
* 归属的钉法与本 seam 的其它方法**完全一致**(`hostIdFor` 的粘性选机,见 `server.ts`
|
||||
* 「文件写到 A、实例起在 B」那段注释)⇒ 落地与实例必然同机。
|
||||
*
|
||||
* `name` 只接受 {@link HOME_FILE_NAMES} 里的**固定文件名**(⛔ 不收路径):
|
||||
* 这几个是平台自己写的配置,用户的其它文件不归平台碰。
|
||||
* 文件不存在 ⇒ `null`(**不是**抛错:首次落地就该从"没有文件"开始)。
|
||||
*/
|
||||
readHomeFile(userId: string, name: HomeFileName): Promise<string | null>
|
||||
/**
|
||||
* 写用户 home 下的平台托管配置文件。
|
||||
* ⚠️ 与 `writeHomeFile`(本地版)同一组约束:`0600` + **chown 给 home 属主**
|
||||
* —— 实例以 `dsh-<uid>` 身份跑,root 写的 0600 文件它**读不了**(档案 43 / R10 同族)。
|
||||
*/
|
||||
writeHomeFile(userId: string, name: HomeFileName, text: string): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台托管的 home 配置文件名白名单(⛔ 只许这些**裸文件名**,不许带路径)。
|
||||
*
|
||||
* - `settings.yaml` —— dsh 自己的设置(平台只写 `llm-pi-ai.providers.*` 与 `locale.*`);
|
||||
* - `.credentials.yaml` —— dsh 的凭据 refs(平台只写自己 refs 段)。
|
||||
*/
|
||||
export const HOME_FILE_NAMES = ['settings.yaml', '.credentials.yaml'] as const
|
||||
export type HomeFileName = (typeof HOME_FILE_NAMES)[number]
|
||||
|
||||
/** 白名单校验(两端都调一次:agent 侧拦住非法入参,本地实现拦住越界调用)。 */
|
||||
export function isHomeFileName(name: unknown): name is HomeFileName {
|
||||
return typeof name === 'string' && (HOME_FILE_NAMES as readonly string[]).includes(name)
|
||||
}
|
||||
+23
-11
@@ -32,17 +32,7 @@ export async function readTextOrEmpty(file: string): Promise<string> {
|
||||
* 实例以 dsh-<uid> 身份运行,root 写的 600 文件它读不了 ⇒ 最后一步不能省。
|
||||
*/
|
||||
export async function writeHomeFile(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 backupHomeFile(homeDir, file, text)
|
||||
await writeFile(file, text, { mode: 0o600 })
|
||||
try {
|
||||
const st = await stat(homeDir)
|
||||
@@ -51,3 +41,25 @@ export async function writeHomeFile(homeDir: string, file: string, text: string)
|
||||
/* chown 失败(非 root 运行等)不阻断 */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 只做**备份**那一步(写进平台备份目录),**不碰用户文件**。
|
||||
*
|
||||
* 为什么单独抽出来(档案 138):用户卷可能**不在本机**(实例在 worker 上)⇒ 写入必须走
|
||||
* `UserFs`(会按归属路由到那台机),而备份是**平台自己**的副本 —— 落在控制面的
|
||||
* `/opt/dsh/backups` 正合适,也不该为了备份再往远端开一条通道。
|
||||
* 备份的命名规则与 {@link writeHomeFile} 的①步**逐字一致**(⛔ 别各写一套)。
|
||||
*/
|
||||
export async function backupHomeFile(homeDir: string, fileOrName: 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(fileOrName).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 {
|
||||
/* 备份失败不阻断 */
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,16 @@ import { userRoot } from '../../fs/workspace.js'
|
||||
const ENSURE_BIZ_PLUGINS =
|
||||
process.env.DSH_ENSURE_BIZ_PLUGINS ?? '/opt/dshs/scripts/ensure-biz-plugins.cjs'
|
||||
|
||||
/** 档案 138:「平台共享模型」逐用户授权的入参(只有开关本身)。 */
|
||||
const sharedModelSchema = {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['enabled'],
|
||||
additionalProperties: false,
|
||||
properties: { enabled: { type: 'boolean' } },
|
||||
},
|
||||
} as const
|
||||
|
||||
export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
// 档案 28:存储用量面板(读取维护脚本生成的快照文件,避免每次请求都 du)
|
||||
app.get('/api/admin/storage', { preHandler: requireAdmin }, async () => {
|
||||
@@ -66,6 +76,50 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
/**
|
||||
* **逐个用户**开启/关闭「平台共享模型」(档案 138 · 2026-09-19 用户口径)。
|
||||
*
|
||||
* 口径原文:「admin 设置的共享模型,需要 admin 在用户列表中开启(新增选项,**默认关闭**),
|
||||
* 用户才能在会话中使用(以及在设置的模型设置页面展示)」。
|
||||
*
|
||||
* 为什么放在 admin 路由而不是复用 `/api/me/models/shared`:那一条写的是**用户偏好**
|
||||
* (`shared_model_enabled`,用户自己在设置页开关);本条的写入目标是**管理员授权**
|
||||
* (`shared_model_granted`,默认 0)—— 两列、两个主体,⛔ 不能共用一个写入口,
|
||||
* 否则用户点一下就等于给自己授权了(门禁失效)。
|
||||
*
|
||||
* ⚠️ 落地发生在 **spawn 时**(`server.ts#landModels`)⇒ 改完必须让目标用户的实例重启
|
||||
* 才能看到变化。这里直接调 `restartMain(id)`:**只影响这一个用户**;他没在跑就是空操作
|
||||
* (下次启动自然按新授权落地)。这与用户自己改条目的行为一致(档案 87 §四)。
|
||||
*/
|
||||
app.post(
|
||||
'/api/admin/users/:id/models/shared',
|
||||
{ preHandler: requireAdmin, schema: sharedModelSchema },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const { enabled } = request.body as { enabled: boolean }
|
||||
const user = await app.db.findUserById(id)
|
||||
if (user === undefined) return reply.code(404).send({ error: 'not_found' })
|
||||
if (!(await app.db.setSharedModelGranted(id, enabled))) return reply.code(404).send({ error: 'not_found' })
|
||||
await app.db.audit(
|
||||
request.user?.id ?? null,
|
||||
'shared_model_grant',
|
||||
JSON.stringify({ userId: id, username: user.username, enabled }),
|
||||
)
|
||||
// 重启目标实例是**尽力而为**:授权已经落库了,它才是这次操作的真结果。
|
||||
// ⚠️ 两种"没重启"都不算失败:① 实例没在跑(`restartMain` 返 undefined)——
|
||||
// 下次启动自然按新授权落地;② 归属租约被别的持有者占着(抛 LeaseBusyError)
|
||||
// —— 那不是本次授权的问题,⛔ 不能把它报成"开启失败"(2026-09-19 实测:
|
||||
// 报 500 时 admin 界面显示"操作失败",而库里其实已经改对了)。
|
||||
let restarted = false
|
||||
try {
|
||||
restarted = (await app.supervisor.restartMain(id)) !== undefined
|
||||
} catch (err) {
|
||||
app.log.warn({ err, userId: id }, '共享模型授权已落库,但重启目标实例失败(下次启动生效)')
|
||||
}
|
||||
return { ok: true, sharedModelGranted: enabled, restarted }
|
||||
},
|
||||
)
|
||||
|
||||
// 永久删除用户:admin 不可删;删除 = 停实例 → DB 事务清全部关联行 →
|
||||
// 删数据目录(users/<id>/)→ 删 provision 创建的 OS 账号 dsh-<short>。
|
||||
app.delete('/api/admin/users/:id', { preHandler: requireAdmin }, async (request, reply) => {
|
||||
|
||||
+41
-8
@@ -12,7 +12,7 @@ import { deriveKey, encrypt } from '../../crypto.js'
|
||||
import { toPublicUser } from '../../db/types.js'
|
||||
import { catalogDiagnostics, isCatalogProvider, isCnProvider, listCatalogProviders } from '../model-catalog.js'
|
||||
import { PROTOCOLS } from '../model-landing.js'
|
||||
import { readTextOrEmpty, writeHomeFile } from '../home-files.js'
|
||||
import { backupHomeFile } from '../home-files.js'
|
||||
import { isLocaleId, reconcileLocalePreference } from '../locale-pref.js'
|
||||
import {
|
||||
captchaActive,
|
||||
@@ -380,23 +380,37 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
if ((await app.db.getEnabledCredentialKeyRef(userId)) !== null) return 'own'
|
||||
// 关掉了共享开关的人**就是** none —— 这正是验收③要的语义。
|
||||
if (!(await app.db.getSharedModelEnabled(userId))) return 'none'
|
||||
// 档案 138:**管理员没授权 ⇒ 就是 none**(门禁先于内容;用户自己的条目不受影响,
|
||||
// 因为上面那一步已经判过了)。
|
||||
if (!(await app.db.getSharedModelGranted(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'
|
||||
}
|
||||
|
||||
/** 平台共享模型的**非敏感**信息(名字 / 归属 / 条数;绝不返回密钥本身)。 */
|
||||
/**
|
||||
* 平台共享模型的**非敏感**信息(名字 / 归属 / 条数;绝不返回密钥本身)。
|
||||
*
|
||||
* `granted`(档案 138)= **管理员有没有给这个人开**;前端据它决定**要不要渲染**整个
|
||||
* 「平台共享模型」区块 —— 未授权时连块都不出现(用户口径:开了才"在使用 + 在设置页展示")。
|
||||
* ⚠️ 未授权时**仍然返回** `owner` / `count` 等字段(不额外做信息收窄),因为这条接口
|
||||
* 只有**本人或 admin** 能看,且这些是"平台有哪些共享模型"这种非敏感目录信息。
|
||||
*/
|
||||
async function sharedKeyInfo(userId: string): Promise<{
|
||||
available: boolean
|
||||
name: string | null
|
||||
owner: string | null
|
||||
ownerIsMe: boolean
|
||||
enabled: boolean
|
||||
granted: boolean
|
||||
count: number
|
||||
}> {
|
||||
const admins = (await app.db.listPublicUsers()).filter((u) => u.role === 'admin')
|
||||
const enabled = await app.db.getSharedModelEnabled(userId)
|
||||
if (admins.length === 0) return { available: false, name: null, owner: null, ownerIsMe: false, enabled, count: 0 }
|
||||
const granted = await app.db.getSharedModelGranted(userId)
|
||||
if (admins.length === 0) {
|
||||
return { available: false, name: null, owner: null, ownerIsMe: false, enabled, granted, count: 0 }
|
||||
}
|
||||
// 档案 87:共享**不再假设只有一把** —— admin 也能配多条(与用户侧同一套口径)。
|
||||
const keys = await app.db.listEnabledCredentialKeys(admins[0].id)
|
||||
// `ownerIsMe`:admin 看的是**自己**配的那些 ⇒ 前端文案要区分「我配的」与「别人配的」。
|
||||
@@ -406,6 +420,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
owner: admins[0].username,
|
||||
ownerIsMe: admins[0].id === userId,
|
||||
enabled,
|
||||
granted,
|
||||
count: keys.length,
|
||||
}
|
||||
}
|
||||
@@ -418,9 +433,12 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/me/keys', { preHandler: requireAuth }, async (request) => ({
|
||||
keys: await app.db.listCredentialKeys(request.user!.id),
|
||||
effective: await keySourceOf(request.user!.id),
|
||||
// `shared.granted`(档案 138)= admin 是否已授权;`shared.enabled` = 用户自己的偏好。
|
||||
shared: await sharedKeyInfo(request.user!.id),
|
||||
// 档 87:把「共享开关」与「协议枚举」一并给出,免得前端各写一份常量然后漂掉。
|
||||
sharedModelEnabled: await app.db.getSharedModelEnabled(request.user!.id),
|
||||
// 档 138:管理员授权(前端只在 true 时渲染「平台共享模型」区块)。
|
||||
sharedModelGranted: await app.db.getSharedModelGranted(request.user!.id),
|
||||
protocols: [...PROTOCOLS],
|
||||
}))
|
||||
|
||||
@@ -536,7 +554,15 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
/** 平台共享模型的开关(档案 87 口径②)—— 只动**自己**的偏好,不碰 admin 的配置。 */
|
||||
/**
|
||||
* 平台共享模型的开关(档案 87 口径② + 档案 138)—— 只动**自己**的偏好,不碰 admin 的配置。
|
||||
*
|
||||
* ⚠️ 语义边界(别混):本路由写的是 `shared_model_enabled` = **用户偏好**("我要不要用");
|
||||
* **能否用**由 admin 在用户列表里写的 `shared_model_granted` 先定(默认关闭)。
|
||||
* 两者是**与**关系 ⇒ 用户把开关打开、但 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))) {
|
||||
@@ -558,15 +584,22 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
* 本路由**替官方把它自己的设置写进它自己的文件**(顶层 `locale: → preference:`),
|
||||
* ⇒ 官方语义不破(实例启动时读自己的设置即生效)+ 用户选择跨页面 / 跨重启保留。
|
||||
* ⚠️ 写文件沿用 `model-landing` 那套(备份到平台目录 + chown 给 home 属主),见 `home-files.ts`。
|
||||
* 🔴 但**读写必须走 `app.userFs`**(档案 138 §五):用户卷跟着实例走 —— 实例在 worker 上时
|
||||
* `home/` 就在那台机,直接 `join(home_dir, …)` + 本机 fs 只会读到自己盘上一个不存在的路径
|
||||
* (**空串、不报错**)⇒ 这条路由对"实例不在控制面本机"的用户**静默失效**(语言选择永远存不上)。
|
||||
*/
|
||||
app.post('/api/me/locale', { preHandler: requireAuth, schema: localeSchema }, async (request, reply) => {
|
||||
const { locale } = request.body as { locale: string }
|
||||
if (!isLocaleId(locale)) return reply.code(400).send({ error: 'invalid_locale' })
|
||||
const homeDir = homeRoot(userRoot(app.config.dataRoot, request.user!.id))
|
||||
const file = join(homeDir, 'settings.yaml')
|
||||
const text = await readTextOrEmpty(file)
|
||||
const userId = request.user!.id
|
||||
const text = (await app.userFs.readHomeFile(userId, 'settings.yaml')) ?? ''
|
||||
const next = reconcileLocalePreference(text, locale)
|
||||
if (next.changed) await writeHomeFile(homeDir, file, next.text)
|
||||
if (next.changed) {
|
||||
// 备份放**平台侧**(控制面的备份目录),写入走 `userFs`(可能落到远端那台机)——
|
||||
// 与 `server.ts#landModels` 同一套口径,⛔ 别在这里自己拼绝对路径。
|
||||
await backupHomeFile(homeRoot(userRoot(app.config.dataRoot, userId)), 'settings.yaml', text)
|
||||
await app.userFs.writeHomeFile(userId, 'settings.yaml', next.text)
|
||||
}
|
||||
return { ok: true, locale, changed: next.changed }
|
||||
})
|
||||
|
||||
|
||||
+50
-10
@@ -52,7 +52,7 @@ import {
|
||||
refForEntry,
|
||||
type SettingsEntry,
|
||||
} from './model-landing.js'
|
||||
import { readTextOrEmpty, writeHomeFile } from './home-files.js'
|
||||
import { backupHomeFile } from './home-files.js'
|
||||
import { rateLimit } from './middleware/rate-limit.js'
|
||||
import { authRoutes } from './routes/auth.js'
|
||||
import { adminRoutes } from './routes/admin.js'
|
||||
@@ -115,11 +115,16 @@ 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)
|
||||
// ── 模型条目落地(档案 87)──────────────────────────────────────────────────
|
||||
// ── 模型条目落地(档案 87 + 138)────────────────────────────────────────────
|
||||
// 用户口径(2026-09-13 定):条目**各自开关、可同时启用**;admin 配的**平台共享模型
|
||||
// **也列入**、用户可开关(`users.shared_model_enabled`);平台只负责把「**已启用**」
|
||||
// 的都配好 —— 具体用哪个模型在 dsh 对话框的模型选择器里挑。
|
||||
//
|
||||
// 🔴 2026-09-19 追加门禁(档案 138):平台共享模型**不再是"人人默认可用"** ——
|
||||
// admin 必须在**用户列表**里逐个开启(`users.shared_model_granted`,v11,默认关闭),
|
||||
// 用户才能用(也才会在「设置 → 模型设置」里看到那一块)。
|
||||
// ⇒ 落地判据 = `granted ∧ enabled`(两个开关分属**不同的人**:前者 admin,后者用户)。
|
||||
//
|
||||
// 为什么必须由平台写文件:官方「设置 → 模型」页在平台环境**必然报错**(该页要 Host
|
||||
// settings 镜像,而平台是浏览器经域名访问远程服务器 ⇒ `isLoopback=false` ⇒ persistence
|
||||
// 降级 `memory` ⇒ 页面报「加载提供方目录失败」)。详见 `ensure-role-profile-patch.cjs`。
|
||||
@@ -160,8 +165,17 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
|
||||
// `readTextOrEmpty` / `writeHomeFile` 已抽到 `./home-files.js`(2026-09-15:语言偏好
|
||||
// 持久化也要用同一套「写 home 文件」语义 —— 与其复制一份,不如共用;约束见该模块头注释)。
|
||||
|
||||
/** 平台共享条目(admin 配的、已启用的那些)—— 只在该用户开关打开、且他不是那个 admin 时纳入。 */
|
||||
/**
|
||||
* 平台共享条目(admin 配的、已启用的那些)—— 三条件**全满足**才纳入:
|
||||
* ① **管理员已授权**该用户(档案 138 · v11,默认关闭,只能由 admin 在用户列表里开)
|
||||
* ② **用户自己没有关掉**(档案 87 的用户侧偏好)
|
||||
* ③ 该用户**不是那个 admin 本人**(admin 用的是他自己配的,再回落一次等于重复)
|
||||
*
|
||||
* ①② 是**两个不同的人的两个开关**,缺一不给 —— 判据是"生效 = 门禁 ∧ 偏好",
|
||||
* ⛔ 别把任意一个当成"可以覆盖另一个"。
|
||||
*/
|
||||
const sharedLandingRows = async (userId: string): Promise<CredentialLandingRow[]> => {
|
||||
if (!(await db.getSharedModelGranted(userId))) return []
|
||||
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 []
|
||||
@@ -213,10 +227,14 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
// 🔴 读写**必须走 `userFs`**(档案 138)—— 用户卷跟着实例走:实例在 worker 上时
|
||||
// `home/` 就在那台机。原先这里直接 `join(owner.home_dir, …)` + 本机 fs ⇒
|
||||
// 控制面读到自己盘上一个不存在的路径(**空串、不报错**)⇒ 落地静默变成空操作:
|
||||
// 托管清单被清空、目标文件一个字节没动(2026-09-19 实测,guest 就是这种用户)。
|
||||
// `userFs` 的归属与本 seam 其它方法**同一份**粘性选机(`hostIdForFile`)⇒
|
||||
// "落地与实例同机"由这条路由保证,⛔ 别在这里自己拼路径。
|
||||
const credText = (await userFs.readHomeFile(userId, '.credentials.yaml')) ?? ''
|
||||
const setText = (await userFs.readHomeFile(userId, 'settings.yaml')) ?? ''
|
||||
// 一次性交接(档案 87):老实现把平台共享 key 写进 `refs.DEEPSEEK_API_KEY` 时没有托管清单,
|
||||
// 新逻辑会把它当成"用户自己写的" ⇒ 关掉共享开关后那行仍留着("关掉即生效"不成立)。
|
||||
// 首次运行(没有任何清单)且**文件里那行确实等于平台共享 key 明文**时,认领它;
|
||||
@@ -230,12 +248,28 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
|
||||
}
|
||||
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)
|
||||
// 备份在**平台侧**做(旧文本就在手上),写入走 `userFs`(可能落到远端那台机)。
|
||||
if (nextCred.text !== credText) {
|
||||
await backupHomeFile(owner.home_dir, '.credentials.yaml', credText)
|
||||
await userFs.writeHomeFile(userId, '.credentials.yaml', nextCred.text)
|
||||
}
|
||||
if (nextSet.text !== setText) {
|
||||
await backupHomeFile(owner.home_dir, 'settings.yaml', setText)
|
||||
await userFs.writeHomeFile(userId, 'settings.yaml', nextSet.text)
|
||||
}
|
||||
await writeManaged(userId, { refs: nextCred.managed, routes: nextSet.managed })
|
||||
}
|
||||
|
||||
/** 保底:平台共享的那把内置 DeepSeek key 明文 —— 只在写配置失败退回 env 注入时才用。 */
|
||||
/**
|
||||
* 平台共享的那把内置 DeepSeek key 明文。
|
||||
*
|
||||
* 🔴 **本函数刻意不判门禁**(档案 138 踩过):它有两个用途,其中一个**不该**被判。
|
||||
* ① 「一次性交接」用它**认领**老实现写下的那一行(判断"这行是不是平台自己写的")
|
||||
* —— 这是**归属判据**,与"该用户现在有没有授权"无关。若在这里判门禁,被撤销授权的
|
||||
* 用户反而**认领不出来** ⇒ 那行永远删不掉 ⇒ "关掉即生效"不成立(红腿实测到了)。
|
||||
* ② 写配置失败时退回 env 注入的保底值 —— 这一路**必须**判门禁,判在**调用点**
|
||||
* (`resolveApiKey` 里),⛔ 别挪回这里。
|
||||
*/
|
||||
const sharedDeepseekKey = async (): Promise<string | null> => {
|
||||
const admins = (await db.listPublicUsers()).filter((u) => u.role === 'admin')
|
||||
if (admins.length === 0) return null
|
||||
@@ -260,6 +294,12 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
|
||||
return null
|
||||
} catch (err) {
|
||||
console.error('model landing failed, falling back to env injection', err)
|
||||
// 🔴 **门禁判在这里**(不能挪进 `sharedDeepseekKey`):这是"落地失败"的应急路,
|
||||
// 不判就等于给未授权用户在异常路径上开门 —— 门禁类判据一律失败关闭。
|
||||
if (!(await db.getSharedModelGranted(userId))) return null
|
||||
if (!(await db.getSharedModelEnabled(userId))) return null
|
||||
const admins = (await db.listPublicUsers()).filter((u) => u.role === 'admin')
|
||||
if (admins.length === 0 || admins[0].id === userId) return null
|
||||
return await sharedDeepseekKey()
|
||||
}
|
||||
}
|
||||
|
||||
+30
-1
@@ -25,7 +25,7 @@ import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify'
|
||||
import type { ServerConfig } from '../config.js'
|
||||
import { LocalUserFs } from '../fs/local-user-fs.js'
|
||||
import { userRoot } from '../fs/workspace.js'
|
||||
import { isUserFsErrorCode, UserFsError } from '../fs/user-fs.js'
|
||||
import { isHomeFileName, isUserFsErrorCode, UserFsError, type HomeFileName } from '../fs/user-fs.js'
|
||||
import { hashUid } from '../isolation.js'
|
||||
import { LocalSpawner } from '../supervisor/orchestrator.js'
|
||||
import { normalizeTunnelTarget, SshTunnel, type WorkerTunnel } from './tunnel.js'
|
||||
@@ -585,6 +585,35 @@ export function buildWorkerAgent(
|
||||
})) ?? reply
|
||||
})
|
||||
|
||||
/**
|
||||
* **home 下的平台托管配置文件**读(档案 138)—— 为什么必须由 worker 提供:
|
||||
* 用户卷(含 `home/`)在**本机**,控制面(Manager)隔着网络 ⇒ 它直接读只会读到
|
||||
* 自己盘上一个不存在的路径(返回空串、还不报错 ⇒ 静默空操作)。
|
||||
*
|
||||
* ⛔ 只收**裸文件名**且必须在白名单里(`settings.yaml` / `.credentials.yaml`):
|
||||
* 这是"平台写自己的两个配置文件",⛔ 不是"给远端一个任意文件读接口"。
|
||||
* 越界/非法名一律 400 `bad_path`。
|
||||
*/
|
||||
app.post('/fs/home-read', async (request, reply) => {
|
||||
const body = request.body as { userId?: string; name?: unknown }
|
||||
if (body.userId === undefined) return reply.code(400).send({ error: 'userId is required' })
|
||||
if (!isHomeFileName(body.name)) return reply.code(400).send({ error: 'bad_path' })
|
||||
const out = await fsCall(reply, () => userFs.readHomeFile(body.userId as string, body.name as HomeFileName))
|
||||
return out === undefined ? reply : { text: out }
|
||||
})
|
||||
|
||||
app.post('/fs/home-write', async (request, reply) => {
|
||||
const body = request.body as { userId?: string; name?: unknown; text?: unknown }
|
||||
if (body.userId === undefined || typeof body.text !== 'string') {
|
||||
return reply.code(400).send({ error: 'userId and text are required' })
|
||||
}
|
||||
if (!isHomeFileName(body.name)) return reply.code(400).send({ error: 'bad_path' })
|
||||
return (await fsCall(reply, async () => {
|
||||
await userFs.writeHomeFile(body.userId as string, body.name as HomeFileName, body.text as string)
|
||||
return { ok: true }
|
||||
})) ?? reply
|
||||
})
|
||||
|
||||
/** 本机 dataRoot(Manager 的 RemoteUserFs 用它做 `resolvePath` 的路径数学)。 */
|
||||
app.get('/fs/root', async () => ({ dataRoot: config.dataRoot }))
|
||||
|
||||
|
||||
Reference in new issue
Block a user