feat(keys): 模型密钥开放给用户自配 —— 放开官方「模型」页 + 平台改预置凭据
用户要求「把配置模型密钥开放给用户自己配」且「界面交互和官方一模一样」⇒ 不仿制,直接放开官方 ui-settings-models 页(可选厂家:DeepSeek 内置 + 自定义 OpenAI 兼容网关含 baseURL/模型)。 - ensure-role-profile-patch.cjs:不再对普通用户禁用 ui-settings-models(保留 plugins/inventory/cordis 禁用);--force 时能把「还禁着 models」的旧块升级。 - src/web/server.ts:resolveApiKey 不再注入 DEEPSEEK_API_KEY env,改为把「平台共享密钥」预置进 $DSH_HOME/.credentials.yaml 的 refs 段(新增 ensureRefInCredentials:只在无该 ref 时写 / 只在 version:1 上插入 / 备份落平台目录 / 写完 chown 给实例 uid / 失败退回 env)。真因:dsh 凭据解析里 env 优先级最高,且 dsh-credentials-local.write() 的 assertUnshadowed() 会让用户在模型页保存直接报错 ⇒ 注入 env 等于锁死用户自配。 - src/web/routes/auth.ts:/api/me/keys 由 requireAdmin 放开为 requireAuth(平台侧密钥 API 保留,UI 不再暴露)。 - poc/business-plugins 0.3.4→0.3.8:「系统管理」对齐门户 6 个功能页(同名同构,PA_PAGES 逐函数移植 portal)/ 官方插件列表高度改为「离弹窗底部约 100px」/ 撤掉自造的「我的密钥」分区(改由官方模型页承担)。 - web/portal.html:keys 页语义改名「平台共享密钥(未自配密钥的用户默认使用;仅管理员可改)」。scripts/verify-platform-admin-section.mjs:断言同步升级(含 zh/en 词典键集一致性、内联 HTML class 扫描)。
This commit is contained in:
1 parent
f6d4ad9b15
commit
eb50ca2d5b
7 files changed
+1739
-471
No files matched your search
@@ -2,9 +2,16 @@
|
||||
/**
|
||||
* ensure-role-profile-patch.cjs — 按角色给 dsh profile 注入 cordis patch。
|
||||
*
|
||||
* 背景(2026-09-09):普通用户在设置面板不应看到「模型」分区(模型 KEY 由管理员
|
||||
* 经门户统一管控,档案 03;dsh 官方 web profile 的模型 provider 目录在此环境不可用,
|
||||
* 且避免普通用户误配自用 key 绕过统一 key)。cordis patch 支持对 client 插件行
|
||||
* 背景:
|
||||
* · 2026-09-09:普通用户在设置面板不应看到「模型」分区(模型 KEY 由管理员经门户统一
|
||||
* 管控,档案 03;且避免普通用户误配自用 key 绕过统一 key)。
|
||||
* · **2026-09-13(档案 86):「模型」分区对普通用户放开** —— 用户要求把配置模型密钥
|
||||
* 开放给用户自己配,且「界面交互和官方一模一样」(⇒ 直接用官方页,不仿制)。
|
||||
* 实测:官方页在本环境**可用**(`/api/session/modelCatalog` 返回 200,"provider 目录
|
||||
* 不可用"的旧判断已不成立)。配套改平台注入:用户自配 ⇒ 不注入共享 env
|
||||
* (`src/web/server.ts` `userHasOwnKey()`,否则 env 优先级会静默盖掉用户配的 key)。
|
||||
* 仍禁用:plugins / plugin-inventory / cordis(骨架插件禁任一都可能搞坏实例)。
|
||||
* cordis patch 支持对 client 插件行
|
||||
* `disabled: true`(dsh-app-boot applyEntryPatches:非 insert patch 按 id 合入
|
||||
* overrides)——生效位置 = profile 层 `cordis.patch.yml`(实例启动时打包 client
|
||||
* bundle,改后必须重启实例才生效;patchReload:live 对 client 增减不生效,已实测)。
|
||||
@@ -27,13 +34,14 @@ const MARK = '# dshs role patch'
|
||||
// --force:已由本脚本管理但内容落后(缺新版禁用项)时,整体升级为当前块。
|
||||
const FORCE = process.argv.includes('--force')
|
||||
const DISABLE_MODELS_BLOCK = [
|
||||
'# dshs role patch: 普通用户隐藏模型分区 + 收归核心插件开关(档案 15)',
|
||||
'# dshs role patch: 收归核心插件开关(档案 15)',
|
||||
'# admin 保留;由 ensure-role-profile-patch.cjs 管理,勿手改',
|
||||
'# 148 个 @deepseek-ai 官方插件均为运行骨架,用户禁用任一都可能搞坏实例,',
|
||||
'# 故插件栏 / 插件清单 / cordis 面板只对 admin 开放;ui-skill、ui-permission 保留给用户。',
|
||||
'- id: ui-settings-models',
|
||||
' name: "@deepseek-ai/dsh-client-ui-settings-models"',
|
||||
' disabled: true',
|
||||
'#',
|
||||
'# ⚠️ ui-settings-models **自 2026-09-13 起不再禁用**(档案 86):用户要自助配置模型厂家与',
|
||||
'# key(「界面交互和官方一模一样」)。配套:平台注入策略改为「用户自配则不注入共享 env」,',
|
||||
'# 使模型页配的 key 真能生效 —— 见 src/web/server.ts 的 userHasOwnKey()。',
|
||||
'- id: ui-settings-plugins',
|
||||
' name: "@deepseek-ai/dsh-client-ui-settings-plugins"',
|
||||
' disabled: true',
|
||||
@@ -60,13 +68,17 @@ function ensureUserPatch(user) {
|
||||
return { user: user.username, action: 'NO_PROFILE', detail: 'profile 尚未创建(用户未首登 spawn);请先登录一次再跑' }
|
||||
}
|
||||
const current = readFileSync(patchPath, 'utf8')
|
||||
if (current.includes(MARK)) {
|
||||
if (FORCE && !current.includes('ui-settings-plugins')) {
|
||||
writeFileSync(patchPath, DISABLE_MODELS_BLOCK, 'utf8')
|
||||
return { user: user.username, action: 'upgraded', detail: '已升级为新版禁用块(含核心插件开关收归)' }
|
||||
if (current.includes(MARK)) {
|
||||
// 需要升级的两种旧态:① 缺「插件开关收归」;② **还禁着 ui-settings-models**
|
||||
// (2026-09-13 之前写入的块 —— 那一版把「模型」分区也对用户藏了,现已放开,见档案 86)。
|
||||
const legacy =
|
||||
!current.includes('ui-settings-plugins') || /^-\s*id:\s*ui-settings-models\s*$/m.test(current)
|
||||
if (FORCE && legacy) {
|
||||
writeFileSync(patchPath, DISABLE_MODELS_BLOCK, 'utf8')
|
||||
return { user: user.username, action: 'upgraded', detail: '已升级(放开「模型」分区 + 保留核心插件开关收归)' }
|
||||
}
|
||||
return { user: user.username, action: 'skip', detail: '已由本脚本管理' }
|
||||
}
|
||||
return { user: user.username, action: 'skip', detail: '已由本脚本管理' }
|
||||
}
|
||||
if (!isEmptyPatch(current)) return { user: user.username, action: 'skip', detail: '用户已定制 cordis.patch.yml,不覆盖' }
|
||||
writeFileSync(patchPath, DISABLE_MODELS_BLOCK, 'utf8')
|
||||
return { user: user.username, action: 'wrote', detail: '已写入 disable models patch' }
|
||||
|
||||
+1443
-377
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@dsh-local/business-plugins",
|
||||
"version": "0.3.3",
|
||||
"description": "功能管理(原「功能插件」)section for dsh web profile — v0.3.3(2026-09-13):「系统管理」全面改为**照抄门户 web/portal.html 的实际组件**(用户反馈「点击卡片后的弹窗样式还是没变,要用之前门户的里面的对应样式」)—— 卡片=.nav-card(r10 / 18px 16px / hover 上浮 -2px + 蓝边 + 0 4px 12px rgba(47,111,237,.12))、计数=.pg-tab .pg-cnt 胶囊、**5 个弹窗全部**改为 .table-wrap + table.tbl(th 灰底 8px 10px/600、td 7px 10px、行 hover 浅蓝、空态居中 40px)、徽章=.badge 四色、按钮=.btn-sm(danger 红字红边)、弹窗外壳=r10 白底 + 门户家族阴影、标题/副标题=.page-title/.page-sub。⚠️ 上一版只有「用户管理」一个弹窗是表格,另外 4 个仍是裸 div 行 —— 这是「看着没变」的真因。|v0.2.8(2026-09-13):**所有弹窗改页内弹窗** —— 弃用 window.confirm,改为 fixed 遮罩 + 居中面板 + 点遮罩/✕ 关闭(视觉与交互对齐 MCN 工作台 dsh-plugin-mcn 的 modalTitle/modalText/modalBtns 三段结构);**超限时弹窗内红底警告块**(标题 + 说明 + 内存数字),确认按钮转危险色,未点确认不发请求。v0.2.7(档案 75 维度 B 落地 · 2026-09-13):卡片新增「预估内存 ≈ N MiB」徽章(按实测成本分色:≥60 红 / ≥30 黄)、卡片加高(minHeight 112 + padding 14/16)便于四层信息排布;**列表上方新增「内存预估」状态条** —— 实心段=当前已启用插件预估占用、半透明段=本次勾选增量、超单实例上限(384 MiB)整条转红并给文字警告,确认弹窗也带上内存预估。口径 = 空载基线 285 MiB + 逐插件实测加载成本(隔离 cgroup 的 rss 增量),**是预估值不是实时读数**。候选池列表 + 搜索/状态徽章 + 批量启用/禁用 + 确认弹窗 + 重启后自动刷新。v0.2.4(档案 67):对齐 06-工作台UI规范 —— **信息层次反转**(主视觉改为插件用途说明,插件名/版本退为副行小字;中文说明由候选池入库时优先取官方目录)、字号阶梯 14/13/12、徽章与按钮改规范量级、空态改「标题 + 说明」、新增列表行 hover 反馈。v0.2.2:分区名由「功能插件」改为「功能管理」。v0.2.0:配色改用官方 --dsw-* design token(跟随 dsh 主题);文案走官方 locale(zh/en)。",
|
||||
"version": "0.3.8",
|
||||
"description": "功能管理(原「功能插件」)section for dsh web profile — v0.3.8(2026-09-13):**撤掉「我的密钥」分区** —— 模型配置统一走**官方「设置 → 模型」页**(用户要求「界面交互和官方一模一样」⇒ 不仿制、直接放开官方页,见档案 86)。平台侧同步两处:① `ensure-role-profile-patch.cjs` 不再禁用 `ui-settings-models`(实测官方页在本环境可用:`/api/session/modelCatalog` 返回 200);② `src/web/server.ts` 的 `resolveApiKey()` 改为「用户已自配 ⇒ **不注入共享 env**」——因为 dsh 凭据解析里 `inherited process environment` **优先级最高**,不这样做用户配的 key 会被静默盖掉。|v0.3.7(2026-09-13 · 原拟 0.3.6,因并行会话已用同号 0.3.6 铺发过「内存条」版本 ⇒ 提升为 0.3.7):① ~~新增「我的密钥」分区~~(**已于 0.3.8 撤除**,原因是两套入口会互相干扰) —— 用户自助配置自己的模型密钥(自配自用),不配置就用「平台共享密钥」(管理员配置);页面分两段:我的密钥(添加 / 启用 / 删除)+ 当前生效(明示在用谁的 key,并提示改 key 只重启自己的实例、不影响他人)。配套后端:`/api/me/keys` 由 requireAdmin 放开为 requireAuth,`resolveApiKey(userId)` 改为「先取自己的 → 取不到回落管理员的共享 key」两级;门户「密钥管理」文案同步改为「平台共享密钥」。② **内存条改读平台真实配额,消灭「388 MiB 误报」**(用户问「实例内存大小是不是调整过了,为什么功能设置中还是显示 388 多」)。根因:本插件自建了**第二套**内存模型(基线 285 / univer 64 / mcn 39,并把 clamp 下限 384 当硬上限判超限),而平台编排器自 R1 起为「按插件集合推导」(base 160 / univer 512 / mcn 128 / clamp 384–1024),两处从未对齐 ⇒ 全勾显示 388 并报「⚠ 将超出上限」,真值却是 672(guest)/ 384(admin)。本轮:① 常量与规则逐项对齐编排器,并新增 `scripts/verify-mem-model.mjs` 在 `npm run verify` 里交叉断言(漂了就构建失败);② 优先读 `/api/dsh/status` 新增的 `quota{memMb,heapMb}`(平台**实际使用**的值,与 spawn 同源)直接显示「实际配额 · V8 堆」;③ 超限判断改为「原始需求 > 硬顶 1024」;④ 未进成本表的插件不再显示 ≈0 MiB 徽章。|v0.3.5(2026-09-13):**插件管理 →「官方推荐插件」列表高度拉高**(用户要求「高度可以增加,距离底部 100px 就行」)—— 该表 max-height 由固定 420px 改为 `max(280px, min(calc(92vh - 470px), 580px))`(类 `.pa-plist`),按弹窗高度反推、使列表底部**距弹窗底部约 100px**;下限 280px 防小屏压扁,上限 580px 对应弹窗触顶 1040px。|v0.3.4(2026-09-13):**「系统管理」全面对齐门户 web/portal.html 的 6 个功能页**(用户要求「点开弹窗里面展示的内容,要和 portal 点击功能后那种详细的表格和功能一致」)—— 分区首页 = 门户 renderHome(「服务」「管理」两组 + .nav-card 三行卡片);点开每一项 = 门户**那一页的完整页面**:服务管理(文件树 + 启动/停止/打开 DSH + 新建文件夹/上传)、密钥管理(全局 API 密钥增删启用)、用户管理(审批/禁用/恢复/删除,需输入用户名二次确认)、技能管理(.zip 上传/替换/删除)、插件管理(官方推荐插件 + 手动添加/管理双页签、搜索/分类/只看可导入/重新拉取/批量导入、.tgz 投放含 P0 扫描与显式信任)、运行环境(版本表 + 漂移提示 + 目录/体积 + 折叠说明);表格列 / 按钮 / 文案逐字一致(`PA_PAGES` 逐函数移植 portal 的 renderXxx/initXxx,只改 3 处:局部 `$` 查询器、跨子域 `paReq`、去掉 hash 路由);弹窗宽度对齐门户功能页 min(1440px,96vw)、高 92vh。写操作就地调平台 admin API(跨子域 + credentials:include,CORS 白名单已含 GET/POST/DELETE)。**已删除**旧版自造的 5 项只读摘要(用户管理 / 候选池 / 运行时 / 存储 / 当前实例)。⚠️ 顺带修掉门户 `readAsBase64()` 缺 await 导致上传恒传空数据的缺陷(弹窗侧已修正,门户侧待同修)。|v0.3.3(2026-09-13):「系统管理」视觉层照抄门户组件(.nav-card / .pg-cnt / .table-wrap + table.tbl / .badge / .btn-sm / .page-title)。|v0.2.8(2026-09-13):所有弹窗改页内弹窗(弃用 window.confirm)。|v0.2.7(档案 75):卡片加内存预估徽章 + 列表上方内存预估状态条。|v0.2.4(档案 67):对齐 06-工作台UI规范(信息层次反转 / 字号阶梯 / 行 hover)。|v0.2.2:分区名由「功能插件」改为「功能管理」。|v0.2.0:配色改用官方 --dsw-* design token(跟Line truncated
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* verify-platform-admin-section.mjs —— R2「平台管理」分区的**无浏览器**渲染验收(档案 82)
|
||||
* verify-platform-admin-section.mjs —— 「系统管理」分区的**无浏览器**渲染验收(档案 82)
|
||||
*
|
||||
* 为什么需要:本机 `agent-browser` 的 daemon 起不来(2026-09-13),浏览器截图验收受阻;
|
||||
* 而 `06-工作台UI规范 §7.3` 三段式在 bundle **按需动态加载** 时也拿不到带 rev 的完整 URL。
|
||||
* 做法:打桩 `react` / `react/jsx-runtime` + 最小 hooks 循环 + **真执行** client.js,
|
||||
* 断言分区**被注册**、**admin 渲染出 6 张卡**、**非 admin 被门禁挡住**。
|
||||
* 断言分区被注册、admin 渲染出门户同款 **6 个功能页**、非 admin 被门禁挡住、
|
||||
* 以及 zh/en 词典**键集一一对应**。
|
||||
*
|
||||
* 第 3 轮(2026-09-13):口径从「5 项只读摘要」改为「门户 6 个功能页同名同构」,
|
||||
* 因此断言同步换成门户的功能名与各页表头。
|
||||
*
|
||||
* 用法:node scripts/verify-platform-admin-section.mjs 退出码 0=全绿 / 1=有失败
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -20,7 +25,7 @@ const ok = (name, cond, extra = "") => { console.log((cond ? " ✓ " : " ✗ "
|
||||
|
||||
let slots = [], cursor = 0, dirty = false;
|
||||
const React = {
|
||||
useState(init) { const i = cursor++; if (!(i in slots)) slots[i] = init; return [slots[i], (v) => { slots[i] = typeof v === "function" ? v(slots[i]) : v; dirty = true; }]; },
|
||||
useState(init) { const i = cursor++; if (!(i in slots)) slots[i] = typeof init === "function" ? init() : init; return [slots[i], (v) => { slots[i] = typeof v === "function" ? v(slots[i]) : v; dirty = true; }]; },
|
||||
useEffect(fn) { const i = cursor++; if (!slots[i]) { slots[i] = 1; fn(); } },
|
||||
};
|
||||
const jsx = (type, props) => ({ type, props: props || {} });
|
||||
@@ -31,19 +36,31 @@ const win = {
|
||||
__ModuleLoader__: { load: (d) => { def = d; } },
|
||||
location: { hostname: "admin.alotbuy.com", protocol: "https:", origin: "https://admin.alotbuy.com" },
|
||||
open: (u) => { globalThis.__OPENED = u; },
|
||||
document: { createElement: () => ({ setAttribute() {}, remove() {}, textContent: "" }), head: { appendChild() {} } },
|
||||
document: {
|
||||
createElement: () => {
|
||||
const o = { setAttribute() {}, remove() {} };
|
||||
Object.defineProperty(o, "textContent", { set(v) { CSS += v; }, get() { return ""; } });
|
||||
return o;
|
||||
},
|
||||
head: { appendChild() {} },
|
||||
},
|
||||
};
|
||||
let ROLE = "admin";
|
||||
let CSS = "";
|
||||
const DATA = {
|
||||
"/api/auth/me": null, // 由 fetch 桩按 ROLE 生成
|
||||
"/api/dsh/status": { running: true, instance: { port: 43095, restarts: 2 }, breaker: null },
|
||||
"/api/admin/users": { users: [{ role: "admin" }, { role: "active" }, { role: "pending" }, { role: "disabled" }] },
|
||||
"/api/admin/storage": { generatedAt: 1, users: [{}, {}] },
|
||||
"/api/plugins/business": { plugins: [{}, {}, {}] },
|
||||
"/api/admin/runtime": { items: [{}, {}, {}, {}, {}] },
|
||||
"/api/admin/users": { users: [{ id: 1, username: "a", role: "admin", createdAt: 1 }, { id: 2, username: "b", role: "active", createdAt: 2 }] },
|
||||
"/api/admin/storage": { generatedAt: 1, users: [] },
|
||||
"/api/plugins/business": { plugins: [{ id: "p1", name: "x", version: "1.0.0" }] },
|
||||
"/api/admin/runtime": { items: [{ name: "node", group: "g", kind: "k", version: "22", source: "s", script: "sc", removable: false }], note: "n", drift: [], runtimeDir: { path: "/p", bytes: 1, installedAt: 1 }, manifest: "m", baselineScript: "b" },
|
||||
// 档案 85 ·「我的密钥」:两层密钥(我自己的 + 平台共享)
|
||||
"/api/me/keys": { keys: [{ id: "k1", name: "my-key", enabled: true, updatedAt: 1 }], effective: "own", shared: { available: true, name: "shared-key", owner: "admin" } },
|
||||
};
|
||||
const sandbox = {
|
||||
console, setTimeout, clearTimeout,
|
||||
JSON, Object, Promise, Buffer, URL, globalThis: undefined,
|
||||
Math, Date, encodeURIComponent, decodeURIComponent,
|
||||
window: win, document: win.document,
|
||||
fetch: async (url) => {
|
||||
const p = String(url).replace("https://alotbuy.com", "");
|
||||
@@ -57,13 +74,15 @@ vm.createContext(sandbox);
|
||||
vm.runInContext(src, sandbox);
|
||||
const mod = def.factory(sandbox.require);
|
||||
|
||||
let DICT = {}, regs = [];
|
||||
let DICT = {}, ZH = {}, EN = {}, regs = [];
|
||||
const ctx = {
|
||||
effect: (fn, name) => { try { fn(); } catch (e) { console.log(" effect 抛错:", name, e.message); } },
|
||||
locale: { register: (ns, d) => { DICT = Object.assign({}, dICT_zh(d)); }, bind: () => (k) => DICT[k] ?? k },
|
||||
locale: {
|
||||
register: (ns, d) => { ZH = d.zh || {}; EN = d.en || {}; DICT = Object.assign({}, ZH); },
|
||||
bind: () => (k) => DICT[k] ?? k,
|
||||
},
|
||||
slots: { inject: (n, fn) => fn(), register: (meta, Comp) => { regs.push({ meta, Comp }); return () => {}; } },
|
||||
};
|
||||
function dICT_zh(d) { return d.zh || {}; }
|
||||
const inj = Array.isArray(mod.inject) ? mod.inject : [];
|
||||
console.log(" inject 声明:", JSON.stringify(inj));
|
||||
mod.apply(ctx);
|
||||
@@ -72,33 +91,37 @@ await new Promise((r) => setTimeout(r, 80));
|
||||
console.log(" 已注册 section:", regs.map(r => r.meta.id + " / order=" + r.meta.order + " / label=" + r.meta.label()).join(" | "));
|
||||
|
||||
function text(n, out = [], depth = 0) {
|
||||
if (n == null || depth > 20) return out; // 表格是「容器>table>tbody>tr>td>文本」6 层,旧上限 12 会把行文本截掉
|
||||
if (n == null || depth > 24) return out;
|
||||
if (typeof n === "string" || typeof n === "number") { out.push(String(n)); return out; }
|
||||
if (Array.isArray(n)) { n.forEach(x => text(x, out, depth + 1)); return out; }
|
||||
if (typeof n === "object") {
|
||||
if (typeof n.type === "function") { text(n.type(n.props || {}), out, depth + 1); return out; }
|
||||
if (n.props) text(n.props.children, out, depth + 1);
|
||||
if (n.props) {
|
||||
// 第 3 轮:功能页正文走 `dangerouslySetInnerHTML`(门户原文),必须单独收集
|
||||
const dsi = n.props.dangerouslySetInnerHTML;
|
||||
if (dsi && dsi.__html) out.push(dsi.__html);
|
||||
text(n.props.children, out, depth + 1);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
async function render(Comp) {
|
||||
cursor = 0; slots = [];
|
||||
let tree = Comp();
|
||||
for (let i = 0; i < 6; i++) { await new Promise(r => setTimeout(r, 30)); if (!dirty) break; dirty = false; cursor = 0; tree = Comp(); }
|
||||
for (let i = 0; i < 8; i++) { await new Promise(r => setTimeout(r, 30)); if (!dirty) break; dirty = false; cursor = 0; tree = Comp(); }
|
||||
return tree;
|
||||
}
|
||||
/** 首帧渲染完成后改一个 state(patch 直接写 slots),再渲染一次 —— 用来把弹窗「点开」。 */
|
||||
/** 首帧渲染完成后改一个 state(patch 直接写 slots),再渲染一次 —— 用来把某个功能页「点开」。 */
|
||||
async function renderWith(Comp, patch) {
|
||||
let tree = await render(Comp);
|
||||
patch();
|
||||
cursor = 0; dirty = false;
|
||||
tree = Comp();
|
||||
for (let i = 0; i < 6; i++) { await new Promise(r => setTimeout(r, 30)); if (!dirty) break; dirty = false; cursor = 0; tree = Comp(); }
|
||||
for (let i = 0; i < 8; i++) { await new Promise(r => setTimeout(r, 30)); if (!dirty) break; dirty = false; cursor = 0; tree = Comp(); }
|
||||
return tree;
|
||||
}
|
||||
/** 收集整棵树上的 className(用来断言「用的是门户那套 class」) */
|
||||
function classes(n, out = [], depth = 0) {
|
||||
if (n == null || depth > 14) return out;
|
||||
if (n == null || depth > 16) return out;
|
||||
if (Array.isArray(n)) { n.forEach(x => classes(x, out, depth + 1)); return out; }
|
||||
if (typeof n === "object") {
|
||||
if (typeof n.type === "function") { classes(n.type(n.props || {}), out, depth + 1); return out; }
|
||||
@@ -109,73 +132,122 @@ function classes(n, out = [], depth = 0) {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
for (const { meta, Comp } of regs.filter(r => r.meta.id === "platform-admin")) {
|
||||
for (const role of ["admin", "active"]) {
|
||||
ROLE = role;
|
||||
delete globalThis.__OPENED;
|
||||
const tree = await render(Comp);
|
||||
const t = text(tree).join(" | ");
|
||||
console.log("\n ── role=" + role + " ──");
|
||||
console.log(" 渲染文本:", t.slice(0, 300));
|
||||
console.log(" 含「平台管理」:", t.includes("平台管理"), "| 含 6 张卡:", ["当前实例","熔断","用户","存储","候选池","运行时"].every(k => t.includes(k)));
|
||||
if (role === "active") console.log(" 含仅管理员说明:", t.includes("仅对管理员显示"));
|
||||
}
|
||||
/**
|
||||
* 收集整棵树的 class —— **同时扫 React 节点与内联 HTML**。
|
||||
* 第 3 轮起功能页正文走 `dangerouslySetInnerHTML`(门户原文),class 只存在于字符串里,
|
||||
* 只看 React 节点会漏掉全部表格 / 工具条 / 页签类名。
|
||||
*/
|
||||
function clsAll(tree) {
|
||||
const out = new Set(classes(tree));
|
||||
const s = text(tree).join(" ");
|
||||
const re = /class="([^"]*)"/g;
|
||||
let m;
|
||||
while ((m = re.exec(s))) m[1].split(/\s+/).forEach((c) => { if (c) out.add(c); });
|
||||
return [...out];
|
||||
}
|
||||
|
||||
// 注:apply 里「仅 admin 注册」是异步判角色后才 inject;本脚本打桩的是同步路径,
|
||||
// 因此这里断言「两个分区都可注册」+ 内容层门禁。注册条件由 R2 的运行时口径另验。
|
||||
// ── 词典(第 3 轮新增断言):zh / en 键集必须一一对应 ─────────────────────────────
|
||||
{
|
||||
const zk = Object.keys(ZH).sort(), ek = Object.keys(EN).sort();
|
||||
const onlyZh = zk.filter(k => !(k in EN));
|
||||
const onlyEn = ek.filter(k => !(k in ZH));
|
||||
ok("词典:zh/en 键集一一对应", onlyZh.length === 0 && onlyEn.length === 0,
|
||||
`zh ${zk.length} / en ${ek.length}` + (onlyZh.length ? " | 仅 zh: " + onlyZh.join(",") : "") + (onlyEn.length ? " | 仅 en: " + onlyEn.join(",") : ""));
|
||||
const paKeys = zk.filter(k => k.startsWith("pa."));
|
||||
ok("词典:pa.* 词条齐备(门户 6 页文案)", paKeys.length >= 120, "-> " + paKeys.length + " 条");
|
||||
const ph = Object.entries(ZH).filter(([k, v]) => String(v).includes("{")).map(([k]) => k).sort();
|
||||
const phEn = Object.entries(EN).filter(([k, v]) => String(v).includes("{")).map(([k]) => k).sort();
|
||||
ok("词典:占位符键在两个语言里一致", ph.join(",") === phEn.join(","), "-> " + ph.join(","));
|
||||
}
|
||||
|
||||
// ── 注册与门禁 ────────────────────────────────────────────────────────────────
|
||||
ok("admin 视角下注册了两个 settings.section", regs.length === 2, "-> " + regs.map(r => r.meta.id).join(", "));
|
||||
const pa = regs.find(r => r.meta.id === "platform-admin");
|
||||
ok("存在 platform-admin 分区", !!pa);
|
||||
if (pa) ok("其 order = 102", pa.meta.order === 102);
|
||||
ROLE = "admin"; delete globalThis.__OPENED;
|
||||
|
||||
const PORTAL_ITEMS = ["服务管理", "密钥管理", "用户管理", "技能管理", "插件管理", "运行环境"];
|
||||
ROLE = "admin";
|
||||
const tAdmin = text(await render(pa.Comp)).join(" | ");
|
||||
ok("admin:含分区标题「系统管理」", tAdmin.includes("系统管理"));
|
||||
ok("admin:5 个管理项齐全", ["用户管理","候选池","运行时","存储","当前实例"].every(k => tAdmin.includes(k)));
|
||||
ok("admin:不再是只读(提示改为弹窗内管理)", tAdmin.includes("弹窗"));
|
||||
ok("admin:6 个功能项与门户同名", PORTAL_ITEMS.every(k => tAdmin.includes(k)),
|
||||
"-> " + PORTAL_ITEMS.filter(k => !tAdmin.includes(k)).join(",") || "");
|
||||
ok("admin:首页两组标题(服务 / 管理)", tAdmin.includes("服务") && tAdmin.includes("管理"));
|
||||
ok("admin:不再出现旧的「候选池 / 运行时 / 当前实例」自造项名",
|
||||
!["候选池", "当前实例"].some(k => tAdmin.includes(k)));
|
||||
|
||||
ROLE = "active";
|
||||
const tUser = text(await render(pa.Comp)).join(" | ");
|
||||
ok("非 admin:被门禁挡住", tUser.includes("仅对管理员显示"));
|
||||
ok("非 admin:不出现任何管理项", !["候选池","运行时"].some(k => tUser.includes(k)));
|
||||
// ── 关键:**非 admin 时「系统管理」压根不注册**(2026-09-13 用户指出普通用户不该看到这一栏)
|
||||
ok("非 admin:不出现任何功能项", !PORTAL_ITEMS.some(k => tUser.includes(k)));
|
||||
|
||||
// ── 非 admin:不注册「系统管理」(模型配置走官方「设置 → 模型」页,平台不再自带入口)──
|
||||
{
|
||||
regs = []; ROLE = "active";
|
||||
mod.apply(ctx);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
ok("非 admin 视角下只注册 1 个分区(不含系统管理)",
|
||||
regs.length === 1 && regs[0].meta.id === "business-plugins",
|
||||
"-> " + regs.map(r => r.meta.id).join(", "));
|
||||
const ids = regs.map(r => r.meta.id).sort();
|
||||
ok("非 admin 视角下只注册 1 个分区(功能管理;不含系统管理)",
|
||||
ids.length === 1 && ids[0] === "business-plugins",
|
||||
"-> " + ids.join(", "));
|
||||
regs = []; ROLE = "admin";
|
||||
}
|
||||
// ── 视觉层(2026-09-13 第 2 轮):卡片网格 + **5 个弹窗全部**走门户 `portal.html` 的表格样式 ──
|
||||
|
||||
// ── 首页视觉:门户 `.nav-grid` / `.nav-card` 家族 ─────────────────────────────
|
||||
{
|
||||
ROLE = "admin";
|
||||
const clsHome = classes(await render(pa.Comp));
|
||||
ok("首页:标题/副标题取门户 .page-title/.page-sub 值", clsHome.includes("pa-hd") && clsHome.includes("pa-sub"));
|
||||
ok("首页:分组标题取门户 .home-section-title 值", clsHome.includes("pa-sec"));
|
||||
ok("首页:卡片网格取门户 .nav-grid 值", clsHome.includes("pa-grid"));
|
||||
ok("首页:卡片取门户 .nav-card 值(.pa-card)", clsHome.includes("pa-card"));
|
||||
ok("首页:计数胶囊取门户 .pg-cnt 值(.pa-cnt)", clsHome.includes("pa-cnt"));
|
||||
ok("首页:标题/副标题取门户 .page-title/.page-sub 值", clsHome.includes("pa-hd") && clsHome.includes("pa-sub"));
|
||||
|
||||
// 逐个点开 5 个管理项,**每一个**都必须是「门户 .table-wrap + table.tbl」而不是裸 div 列表
|
||||
const ITEM_IDS = ["users", "plugins", "runtime", "storage", "instance"];
|
||||
const bad = [];
|
||||
for (const which of ITEM_IDS) {
|
||||
const cls = classes(await renderWith(pa.Comp, () => { slots[2] = which; }));
|
||||
const good = cls.includes("pa-overlay") && cls.includes("pa-modal") && cls.includes("pa-wrap") && cls.includes("pa-tbl")
|
||||
&& cls.includes("pa-card-h") && cls.includes("pa-sm");
|
||||
if (!good) bad.push(which + "(" + cls.filter(c => c.startsWith("pa-")).join("/") + ")");
|
||||
// ── 6 个功能页逐个点开:**弹窗外壳 + 门户该页的表格/操作** ──────────────────
|
||||
const EXPECT = {
|
||||
files: ["服务管理", "启动 DSH", "新建文件夹", "上传文件", "修改时间", "根"],
|
||||
keys: ["密钥管理", "全局 API 密钥", "sk-..."],
|
||||
users: ["用户管理", "注册时间", "操作"],
|
||||
skills: ["技能管理", "共享技能", "更新时间", "上传 / 替换"],
|
||||
plugins: ["插件管理", "官方推荐插件", "手动添加 / 管理", "导入到平台", "下载量", "投放时间"],
|
||||
// runtime 的表格由 init 动态生成(门户同构),静态 html 只有容器 ⇒ 这里只查标题与副标题
|
||||
runtime: ["运行环境", "实例共享的运行时与工具"],
|
||||
};
|
||||
const badShell = [], badBody = [];
|
||||
for (const [key, wants] of Object.entries(EXPECT)) {
|
||||
const tree = await renderWith(pa.Comp, () => { slots[1] = key; });
|
||||
const cls = classes(tree);
|
||||
const shell = cls.includes("pa-overlay") && cls.includes("pa-modal") && cls.includes("pa-wide")
|
||||
&& cls.includes("pa-mhead") && cls.includes("pa-mbody") && cls.includes("pa-phead") && cls.includes("pa-sm");
|
||||
if (!shell) badShell.push(key + "(" + cls.filter(c => c.startsWith("pa-")).join("/") + ")");
|
||||
const body = text(tree).join(" | ");
|
||||
const miss = wants.filter(w => !body.includes(w));
|
||||
if (miss.length) badBody.push(key + " 缺 " + miss.join(","));
|
||||
}
|
||||
ok("5 个弹窗全部 = 门户弹窗外壳 + portal 表格 + .btn-sm", bad.length === 0, bad.length ? "-> " + bad.join(" ; ") : "-> users/plugins/runtime/storage/instance 均通过");
|
||||
ok("6 个功能页全部 = 门户弹窗外壳(遮罩/大面板/页头/滚动体)", badShell.length === 0,
|
||||
badShell.length ? "-> " + badShell.join(" ; ") : "-> files/keys/users/skills/plugins/runtime 均通过");
|
||||
ok("6 个功能页正文 = 门户该页的表格与操作", badBody.length === 0,
|
||||
badBody.length ? "-> " + badBody.join(" ; ") : "-> 表头 / 按钮 / 说明文本均在");
|
||||
|
||||
// 用户弹窗:角色徽章 + 危险操作按钮(门户 .badge / .btn-sm.danger)
|
||||
const clsUsers = classes(await renderWith(pa.Comp, () => { slots[2] = "users"; }));
|
||||
ok("用户弹窗:角色用门户 .badge 四色", clsUsers.filter(c => c === "pa-badge").length >= 4, "-> " + clsUsers.filter(c => c === "pa-badge").length + " 枚");
|
||||
ok("用户弹窗:删除按钮用门户 .btn-sm.danger", clsUsers.includes("danger"));
|
||||
// 插件页:双页签(门户 .pg-tabs)+ 门户表格类
|
||||
const clsPl = clsAll(await renderWith(pa.Comp, () => { slots[1] = "plugins"; }));
|
||||
ok("插件页:双页签取门户 .pg-tabs/.pg-tab 值", clsPl.includes("pa-tabs") && clsPl.includes("pa-tab"));
|
||||
ok("插件页:页签计数胶囊取门户 .pg-cnt 值", clsPl.includes("pa-tcnt"));
|
||||
// 导入方式徽章出现在 init 动态生成的行里(静态 html 无)⇒ 改断言 CSS 已注入门户 .badge 对应值
|
||||
ok("门户组件 CSS 已注入(.badge / .table-wrap / .btn-sm / .pg-tabs 家族)",
|
||||
[".pa-badge", ".pa-wrap", ".pa-sm", ".pa-tabs", ".pa-box", ".pa-btn", ".pa-plist"].every(c => CSS.includes(c)));
|
||||
|
||||
// 实例弹窗:键值对表格,值列不是裸文本
|
||||
const tInst = text(await renderWith(pa.Comp, () => { slots[2] = "instance"; })).join(" | ");
|
||||
ok("实例弹窗:键值齐全", ["端口", "自动重启", "熔断"].every(k => tInst.includes(k)));
|
||||
slots[2] = null; cursor = 0; dirty = false;
|
||||
// 用户页:表格 + 徽章 + 危险按钮
|
||||
const clsU = clsAll(await renderWith(pa.Comp, () => { slots[1] = "users"; }));
|
||||
ok("用户页:门户表格(.pa-wrap + .pa-tbl)", clsU.includes("pa-wrap") && clsU.includes("pa-tbl"));
|
||||
ok("用户页:角色徽章四色文案齐备(词典)",
|
||||
["pa.role.admin", "pa.role.active", "pa.role.pending", "pa.role.disabled"].every(k => ZH[k]));
|
||||
|
||||
// 服务页:门户 .dsh-bar / .pathbar / .table-wrap
|
||||
const clsF = clsAll(await renderWith(pa.Comp, () => { slots[1] = "files"; }));
|
||||
ok("服务页:门户 .dsh-bar / .pathbar 取值", clsF.includes("pa-dshbar") && clsF.includes("pa-pathbar"));
|
||||
ok("服务页:门户 .table-wrap/table.tbl 取值", clsF.includes("pa-wrap") && clsF.includes("pa-tbl"));
|
||||
|
||||
slots[1] = null; cursor = 0; dirty = false;
|
||||
}
|
||||
console.log(failed === 0 ? "\n结论:全绿 ✅" : "\n结论:有 " + failed + " 项失败 ❌");
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
+47
-12
@@ -6,7 +6,7 @@
|
||||
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { requireAdmin, requireAuth } from '../middleware/authn.js'
|
||||
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'
|
||||
@@ -132,13 +132,49 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
},
|
||||
} as const
|
||||
|
||||
// ---- 统一 KEY:仅管理员可管理。全局只认 admin 的启用 key(见 server.ts resolveApiKey),
|
||||
// 普通用户不再有自配 key 的入口/能力;这些路由对非 admin 一律 403。 ----
|
||||
app.get('/api/me/keys', { preHandler: requireAdmin }, async (request) => ({
|
||||
// ---- 模型密钥:**两层并存**(档案 85 · 2026-09-13,用户要求「配置模型密钥开放给用户自己配」)--
|
||||
// ① **用户自己的 key** —— 任何登录用户都能管理**自己那一格**(自配自用);
|
||||
// ② **平台共享 key**(管理员设置的)—— 用户侧**只读可见**:没自配的人默认就用它。
|
||||
// 两层互相独立、互不覆盖:`server.ts` 的 `resolveApiKey(userId)` 先取 ①,取不到才回落 ②。
|
||||
// ⚠️ `DEEPSEEK_API_KEY` 是 **spawn 时注入 env 的快照** ⇒ 换 key 后必须重启实例才生效:
|
||||
// admin 改的 key 就是共享 key ⇒ `restartAllMains()`(所有仍在回落的用户都得刷新);
|
||||
// 其他人改自己的 ⇒ 只 `restartMain(自己)`,不动任何人。
|
||||
/** 该用户当前**实际生效**的密钥来源。 */
|
||||
async function keySourceOf(userId: string): Promise<'own' | 'shared' | 'none'> {
|
||||
if ((await app.db.getEnabledCredentialKeyRef(userId)) !== null) return 'own'
|
||||
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 }> {
|
||||
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」与「别人配的」。
|
||||
return {
|
||||
available: on !== undefined,
|
||||
name: on?.name ?? null,
|
||||
owner: admins[0].username,
|
||||
ownerIsMe: admins[0].id === userId,
|
||||
}
|
||||
}
|
||||
/** 换 key 后的刷新:admin 动的是共享 key ⇒ 广播重启;其他人只重启自己。 */
|
||||
async function refreshAfterKeyChange(userId: string, role: string): Promise<void> {
|
||||
if (role === 'admin') await app.supervisor.restartAllMains()
|
||||
else await app.supervisor.restartMain(userId)
|
||||
}
|
||||
|
||||
app.get('/api/me/keys', { preHandler: requireAuth }, async (request) => ({
|
||||
keys: await app.db.listCredentialKeys(request.user!.id),
|
||||
effective: await keySourceOf(request.user!.id),
|
||||
shared: await sharedKeyInfo(request.user!.id),
|
||||
}))
|
||||
|
||||
app.post('/api/me/keys', { preHandler: requireAdmin, schema: keyAddSchema }, async (request, reply) => {
|
||||
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)) {
|
||||
@@ -154,23 +190,22 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
encrypt(apiKey, deriveKey(app.config.encryptionSecret)),
|
||||
)
|
||||
await app.db.audit(request.user!.id, 'set_api_key', JSON.stringify({ name: cleanName }))
|
||||
// 统一 KEY:admin 换 key 后广播重启所有用户的实例(DEEPSEEK_API_KEY 是 spawn 时
|
||||
// 注入 env 的快照,不重启运行中的实例不会刷新)。
|
||||
await app.supervisor.restartAllMains()
|
||||
await refreshAfterKeyChange(request.user!.id, request.user!.role)
|
||||
return { key }
|
||||
})
|
||||
|
||||
app.post('/api/me/keys/:id/select', { preHandler: requireAdmin }, async (request, reply) => {
|
||||
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' })
|
||||
// 切换启用的全局 key 后同样广播刷新
|
||||
await app.supervisor.restartAllMains()
|
||||
await refreshAfterKeyChange(request.user!.id, request.user!.role)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.delete('/api/me/keys/:id', { preHandler: requireAdmin }, async (request, reply) => {
|
||||
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 }
|
||||
})
|
||||
}
|
||||
+88
-5
@@ -7,7 +7,9 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify'
|
||||
import fastifyStatic from '@fastify/static'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
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 { createUserFs } from '../fs/provider.js'
|
||||
@@ -63,18 +65,99 @@ 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)
|
||||
const resolveApiKey = async (_userId: string): Promise<string | null> => {
|
||||
// 统一 KEY 模式:所有用户共用管理员(admin)设置的启用 key,用户不可自配。
|
||||
// 忽略入参 userId——不管哪个用户 spawn,都注入同一把管理员 key。
|
||||
/**
|
||||
* 把「平台共享密钥」预置进用户的 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 = ''
|
||||
try {
|
||||
text = await readFile(file, 'utf8')
|
||||
} catch {
|
||||
text = '' // 文件不存在 ⇒ 从零创建一个最小合法文档
|
||||
}
|
||||
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",也不冒写坏凭据的风险)
|
||||
}
|
||||
// 备份落在**平台目录**(不进 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 {
|
||||
/* 备份失败不阻断 */
|
||||
}
|
||||
}
|
||||
await writeFile(file, next, { mode: 0o600 })
|
||||
// 实例以 dsh-<uid> 身份运行;root 写的 600 文件它读不了 ⇒ 交给该 home 的属主
|
||||
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> => {
|
||||
const owner = await db.findUserById(userId)
|
||||
if (owner === undefined) return 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 {
|
||||
return decrypt(ref, encryptionKey)
|
||||
shared = decrypt(ref, encryptionKey)
|
||||
} catch {
|
||||
return null // corrupt ref — treat as unset, let the admin re-enter it
|
||||
}
|
||||
if (shared === null) return null
|
||||
try {
|
||||
await ensureRefInCredentials(owner.home_dir, 'DEEPSEEK_API_KEY', shared)
|
||||
} catch (err) {
|
||||
// 写失败不能让实例起不来:退回老办法(注入 env)保底
|
||||
console.error('ensureRefInCredentials failed, falling back to env injection', err)
|
||||
return shared
|
||||
}
|
||||
return null
|
||||
}
|
||||
const resolveUid = async (userId: string): Promise<number> => {
|
||||
const user = await db.findUserById(userId)
|
||||
|
||||
+3
-3
@@ -165,7 +165,7 @@ function pageHead(title, sub) {
|
||||
function renderHome() {
|
||||
const sec1 = [
|
||||
{ ic: '🖥️', t: '服务管理', d: '文件树 + DSH 启动', hash: '#/files' },
|
||||
{ ic: '🔑', t: '密钥管理', d: '全局 API 密钥', hash: '#/keys' },
|
||||
{ ic: '🔑', t: '密钥管理', d: '平台共享密钥', hash: '#/keys' },
|
||||
]
|
||||
const sec2 = [
|
||||
{ ic: '👥', t: '用户管理', d: '审批 / 禁用 / 删除', hash: '#/users', admin: true },
|
||||
@@ -267,7 +267,7 @@ async function initFiles() {
|
||||
|
||||
/* ================= 密钥管理(#/keys) ================= */
|
||||
async function renderKeys() {
|
||||
return `${pageHead('密钥管理', '全局 API 密钥(所有用户共用,仅管理员可改)')}
|
||||
return `${pageHead('密钥管理', '平台共享密钥(未自配密钥的用户默认使用;仅管理员可改)')}
|
||||
<div class="card">
|
||||
<div id="keyList"></div>
|
||||
<div style="margin-top:14px;display:flex;gap:8px;align-items:center">
|
||||
@@ -282,7 +282,7 @@ async function initKeys() {
|
||||
const res = await fetch('/api/me/keys'); if (!res.ok) return
|
||||
const { keys } = await res.json()
|
||||
const list = $('keyList')
|
||||
if (keys.length === 0) { list.innerHTML = '<div class="empty">暂无全局密钥。添加后所有用户的 DSH 才能调用模型。</div>'; return }
|
||||
if (keys.length === 0) { list.innerHTML = '<div class="empty">暂无平台共享密钥。未自配密钥的用户将无法调用模型 —— 用户可在自己实例的「设置 → 我的密钥」里配置自己的密钥。</div>'; return }
|
||||
list.innerHTML = keys.map((k) => `<div class="key-row">
|
||||
<span class="dot ${k.enabled ? 'on' : 'off'}"></span><strong>${esc(k.name)}</strong><span style="flex:1"></span>
|
||||
${k.enabled ? '<span style="font-size:12px;color:#1a7f37">启用中</span>' : `<button class="btn-sm" data-sel="${k.id}">启用</button>`}
|
||||
|
||||
Reference in new issue
Block a user