diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3354dfa --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# 文档库并入后的行尾约定(原文档库是 * -text + autocrlf=false ⇒ 必须保持纯 LF) +dsh-server-docs/** -text diff --git a/dsh-server-docs/.gitattributes b/dsh-server-docs/.gitattributes new file mode 100644 index 0000000..3cbc73f --- /dev/null +++ b/dsh-server-docs/.gitattributes @@ -0,0 +1,2 @@ +* -text +* -crlf diff --git a/dsh-server-docs/.gitignore b/dsh-server-docs/.gitignore new file mode 100644 index 0000000..100f177 --- /dev/null +++ b/dsh-server-docs/.gitignore @@ -0,0 +1,15 @@ +.DS_Store +Thumbs.db +*.tmp + +# 编辑器/脚本备份(含服务器侧产生的 *.bak-) +*.bak-* +*.bak + +# 并行执行/占用锁(纯本机标记,不入库、不 scp —— 见证 2026-09-12) +交接单/.doing-* +交接单/.exec-lock + +# Python 字节码缓存(可再生,勿入库/勿同步) +__pycache__/ +*.pyc diff --git a/dsh-server-docs/01-规划与架构.md b/dsh-server-docs/01-规划与架构.md new file mode 100644 index 0000000..b4380ef --- /dev/null +++ b/dsh-server-docs/01-规划与架构.md @@ -0,0 +1,238 @@ +# dsh(DeepSeek Harness)多用户部署与配置改造方案 + +> ⚠️ **历史文档**(2026-09-08 成稿):本文记录**当时**的规划与架构。**当前部署以 `DEPLOY-本部署.md` 为准**;文中「Docker 容器 / `127.0.0.1:8900` / 旧域名 `dsh.alotbuy.com`」等已被 2026-09-08~11 的架构切换取代(见档案 05 / 22)。SSH 端口为 **22**(非文中笔误的 32022)。 + +| 项 | 值 | +|---|---| +| 版本 | v1.0(2026-09-08) | +| 适用对象 | 服务器 47.77.182.89(root SSH **端口 22**) | +| 软件 | @deepseek-ai/dsh 0.1.2-rc.1(镜像 dsh-web:0.1.2) | +| 状态 | **P0 迁移已执行(2026-09-08 17:30)**;**域名接入完成(2026-09-08 18:00)**;**架构切换为 dshs 多租户 + account 硬隔离(2026-09-08 18:30)** | +| 文档位置 | 本机 `D:\AI技能\aliyun-work-space\dsh-server-docs\`;服务器 `/opt/dsh/docs/` | + +--- + + +## 一、背景与目标 + +dsh Web UI 已单实例部署并跑通(外网 http://47.77.182.89/,模型对话正常)。需要从「单人单实例」升级为「公司多人使用」形态。 + +**目标(5 项)**: + +1. **多用户使用**:多个编导/账号运营各自登录使用; +2. **数据持久化**:会话、配置、凭据、工作区不随容器重建丢失; +3. **跨用户文件安全**:用户之间互相看不到对方的数据与工作区(严格隔离); +4. **便于使用/复制/恢复**:一个用户 = 一个主目录,备份恢复按目录粒度一键完成; +5. **登录方式可扩展**:前期用账号密码(注册审核制),后期可按需接入飞书 OAuth。 + +--- + + +## 二、现状盘点(2026-09-08 服务器实测) + +``` +/opt/dsh/ +├── backups/20260908_initial/ # 旧备份(已归档) +├── docs/dsh-improvement-plan.md # 本文档 +├── users/main/.dsh/ # ★ 迁移后:容器 /root/.dsh(原 data/) +├── users/main/workspaces/ # ★ 迁移后:容器 /root/workspaces(工作区卷,新约定) +├── .env # 系统模型 key(--env-file) +├── backup.sh / restore.sh # 备份恢复脚本 +└── dsh-web-0.1.2.tar # 镜像 tar(140MB) + +容器 dsh-web:Up,--network host,--restart always +挂载:/opt/dsh/users/main/.dsh → /root/.dsh + /opt/dsh/users/main/workspaces → /root/workspaces +环境:DEEPSEEK_API_KEY(--env-file /opt/dsh/.env) +端口:容器内 127.0.0.1:8900 → nginx 反代 80(Host/Origin 伪装 127.0.0.1) +访问:token 认证(docker logs 取)→ dsh-auth cookie(30 天) +``` + +已知问题:容器内工作区 `/root/maiyamcn` 已丢失(重建容器后被清掉,注册表残留失效路径)——**工作区必须卷化持久化**。迁移后此问题已解决,但 storages/workspace.json 中旧路径 `/root/maiyamcn` 记录仍在,需在 Web UI 删除该失效工作区入口。 + +--- + + +## 三、总体架构(目标态) + +**核心原则**: + +- **一个用户 = 一个主目录 + 一个实例**:`/opt/dsh/users//`,备份/复制/恢复只对这一个目录操作; +- **严格隔离**:每用户独立实例(容器),独立 `~/.dsh` 与工作区,互相不可见; +- **系统标准层(公共模型/插件/技能)只读共享**,用户增量放在个人层,同名覆盖系统层(dsh 原生分层机制,无需自研); +- **配置分层**:dsh 插件 bundle → profile 层 → home 级用户覆盖层 → `--patch`,天然支持「公司统一 + 个人覆盖」。 + +### 目录布局(目标态) + +``` +/opt/dsh/ +├── .env # 系统级模型 key(DEEPSEEK_API_KEY),容器 --env-file 注入 +├── std/ # ★ 系统标准层(只读,管理员维护) +│ ├── agents/skills/ # → 容器 /root/.agents/skills(rank400 兜底标准技能库) +│ │ └── mcn-video-script/ # 公司标准技能(SKILL.md + references,只读) +│ └── profile-skel/ # 新用户 profile 种子(含公共插件 bundles 声明) +├── users/ # ★ 每用户 = 一个主目录(实例隔离边界) +│ ├── main/ # ─ 管理员实例(由现有 data/ 迁移而来)─ +│ │ ├── .dsh/ # → 容器 /root/.dsh(DSH_HOME) +│ │ │ ├── profiles/web/ +│ │ │ │ ├── dsh.profile # bundles 有序清单(官方 + 公共插件) +│ │ │ │ ├── cordis.patch.yml # profile 层配置 +│ │ │ │ └── node_modules/ # 个人插件落点(dsh plugin add,只影响本用户) +│ │ │ ├── cordis.patch.yml # ★ home 级用户覆盖层(优先级高于 profile 层) +│ │ │ ├── .credentials.yaml # ★ 个人模型 key(用户在 Models 页填写) +│ │ │ ├── settings.yaml # 用户全局设置 +│ │ │ ├── skills/ # ★ 个人技能 rank300(可覆盖系统标准同名技能) +│ │ │ ├── sessions/ # 会话数据 +│ │ │ └── storages/ # 存储数据 +│ │ └── workspaces/ # → 容器 /root/workspaces +│ │ ├── maiyamcn/ # 工作区项目(=容器内 workspace 根) +│ │ │ ├── .dsh/skills/ # 项目级技能 rank100(最高优先,随项目分发) +│ │ │ ├── .agents/skills/ # 项目级技能备选 rank200 +│ │ │ ├── AGENTS.md # 项目指令 +│ │ │ └── ... # 项目文件/素材/脚本 +│ │ └── <其他项目>/ +│ ├── zhangsan/ # ─ 编导 A(与 main 完全同构)─ +│ │ ├── .dsh/ +│ │ └── workspaces/ +│ └── lisi/ # ─ 编导 B ─ +├── backups/ # 备份产物(backup.sh 打包 users/ → 时间戳 tar.gz) +│ └── 20260908_initial/ # 旧备份归档 +├── backup.sh # 一键备份 +└── restore.sh # 一键恢复 +``` + +### 运行拓扑(多用户) + +``` +镜像 dsh-web:0.1.2(官方 bundle + 公共插件 + std 标准技能内置/挂载) +├── dsh-web-main --network host → 8900 # 管理员 +├── dsh-web-zhangsan --network host → 8901 # 编导 A +└── dsh-web-lisi --network host → 8902 # 编导 B + nginx 外网按子域名/路径分流到 127.0.0.1:890x(沿用现有 Host/Origin 伪装模板) +``` + +--- + + +## 四、dsh 分层机制要点(方案依据) + +dsh 配置与插件天然分层,以下机制是方案成立的前提(无需自研): + +1. **配置/插件叠加顺序**(后层覆盖前层): + ``` + 插件 bundle 默认(官方/系统) + → profile 层 $DSH_HOME/profiles/web/cordis.patch.yml + → 用户层 $DSH_HOME/cordis.patch.yml + → 命令行 --patch(最高) + ``` +2. **SKILL 发现目录**(rank 越小优先级越高,同名高优先覆盖低优先): + + | rank | 目录 | 性质 | + |---|---|---| + | 100 | 工作区根 `/.dsh/skills/` | 项目级(随项目分发) | + | 200 | 工作区根 `/.agents/skills/` | 项目级备选 | + | 300 | `~/.dsh/skills/` | 用户个人 | + | 400 | `~/.agents/skills/` | 用户级全局(**系统标准库落点,只读兜底**) | + +3. **插件安装**:`dsh plugin --profile add <源>`(npm/GitHub/git/tarball/link),写入该 profile 的 bundles 层与 node_modules,**只影响该用户实例**。 +4. **模型 provider**:即插件;key 走 credentials service(UI Models 页 → `.credentials.yaml`)或环境变量(`DEEPSEEK_API_KEY`)。 + +--- + + +## 五、模型 / 插件 / SKILL 归属矩阵 + +| 能力 | 公共(系统层,管理员维护) | 个人(用户可改) | 项目(随工作区) | +|---|---|---|---| +| 模型 | `.env` 统一 key + profile 层默认 provider;**用户不自配模型**(见安全限制) | `.credentials.yaml` 个人 key(若开放) | — | +| 插件 | 镜像预置公共插件(官方 bundle 同级,人人可用,不可改) | `profiles/web/node_modules` + bundles 声明(`dsh plugin add`,需网络) | — | +| SKILL | `std/agents/skills`(rank400 只读兜底,**同名可被用户覆盖**) | `~/.dsh/skills`(rank300) | 工作区 `.dsh/skills`(100) / `.agents/skills`(200) | + +> 一句话:**公共层只读放低优先,个人层可写放高优先**——公司统一给兜底,个人自配天然覆盖,无需把标准配置复制 N 份。 + +--- + + +## 九、安全边界与已知限制 + +1. **dsh 禁 `--host 0.0.0.0`**(RCE 防护),容器内只监听 127.0.0.1 → 必须 `--network host` + nginx 反代; +2. **特权 API 强制 loopback-only**(settings/credentials.describe 等),即使 `--trusted-host` 也拒非本机访问 → nginx 必须把 `Host`/`Origin`/`Sec-Fetch-*` 头伪装为 `127.0.0.1:8900`(现有反代模板已含); +3. **Settings/Models 页前端 loopback 检测**:非本机浏览器地址访问时页面禁用(`settings are unavailable in this browser`)→ 外网用户**无法在 UI 自配模型**;普通用户模型一律管理员统一配置;要开放自配需对 web 前端打 `--patch` 放行(待验证项); +4. **插件 = 任意代码执行**:普通用户不开 `dsh plugin add` 权限;公共插件管理员审核后入镜像; +5. **token 认证**:每实例首次启动生成 token,nginx 后建议加一层 Basic Auth 或尽快接 dshs; +6. `.env` 含密钥,权限 600,不进备份包(整机迁移需单独携带或重设)。 + +--- + + +## 十三、架构切换:容器 → dshs 多租户 + account 硬隔离(2026-09-08 18:30) + +**决策**:去掉 Docker 单容器方案,全量迁移到 dshs 进程隔离模式(便于 dsh/插件升级迭代 = 一条 npm 命令);用户确认"立即全量迁移 main" + "account 账号级硬隔离"。 + +### 最终架构 + +``` +宿主机:Node v22.23.2 + 全局 dsh 0.1.2-rc.1(/usr/local/bin) +dshs(systemd,127.0.0.1:3080,account 模式) +├── /var/lib/dshs/ +│ ├── dshs.db # 用户/审核数据 +│ ├── secret.key # 每用户 key 库主密钥 +│ └── users// +│ ├── home/ # DSH_HOME(profiles/sessions/credentials)— 由原 .dsh 迁入 +│ ├── ws/ # 工作区(HOME 指向)— 由原 workspaces 迁入 +│ └── patches/ +nginx:dsh.alotbuy.com + *.dsh.alotbuy.com → 127.0.0.1:3080(端口动态分配由编排器管理) +``` + +### 关键文件清单 + +| 文件 | 作用 | +|------|------| +| `/etc/dshs.env` | BASE_DOMAIN=dsh.alotbuy.com / COOKIE_DOMAIN=.dsh.alotbuy.com / ISOLATION_MODE=account / BASE_UID=100000 / DSH_BIN=/usr/local/bin/dsh | +| `/etc/systemd/system/dshs.service` | 编排服务(ExecStart 带 `--db` 指向 dshs.db) | +| `/etc/systemd/system/dsh-provision.path` + `.service` | 监控新用户目录自动建 OS 账号 | +| `/usr/local/bin/provision-new-users.sh` | 建号脚本(用户名截断 20 字符,规避 Linux 32 字符限制;每次 chown 幂等) | + +### 已验证 + +- admin 登录 API ✅(role=admin) +- account 降权:dsh 可跑(0.1.2-rc.1)、读 /root Permission denied、读自己 home 正常 ✅ +- nginx 主域/子域 → 3080 路由 ✅(登录门户 HTML 返回) +- 备份/恢复脚本已切换新数据根(backup.sh 打包 /var/lib/dshs + 配置 + systemd 单元) + +### ⚠️ 未完成 / 待办 + +- [x] **HTTPS 通配证书**(2026-09-08 18:40 完成):certbot dns-cloudflare 签 `*.dsh.alotbuy.com`,nginx 443 + HTTP 跳转,全链路登录验证通过 +- [ ] **每用户 API Key**:dshs 为每用户独立 key 库(登录后桌面"管理密钥"填),原 .env 统一 key 的模型已不适用(迁移后 admin 需自填 key) +- [ ] 注册普通用户 → 审核 → 桌面/DSH 启动全链路验证(子域 404 需注册后确认) +- [ ] main 旧容器数据 /opt/dsh/users/main 已迁入 admin home,容器已停,可清理 +- [ ] 飞书 OAuth 扩展(预留) + +### 踩坑记录 + +1. **db 路径不一致**:bootstrap-admin 写入 `dshs.db`,但服务默认用 `dshs.db`(空)→ systemd ExecStart 必须显式 `--db` +2. **用户名超长**:`dsh-`+uuid=41 字符超 Linux 32 限制 → provision 脚本截断为 `dsh-` +3. **初始属主不一致**:bootstrap 建目录属主 100001,hash uid 为 114801 → 以 `uid-for-user` 输出为准 +4. **中间路径权限**:`/var/lib/dshs` 与 `/users` 需 755(root 700 会挡住降权账号穿透),用户目录本身 700 保持隔离 +5. **pkill 自杀坑**:`pkill -f 'lib/cli.js'` 会匹配到远程 shell 自身命令行 → 用 systemctl 管理 + +--- + +## 附一、机制索引(2026-09-11 补 · 档案 19 §D1) + +> 本文件正文停在 2026-09-08 的架构切换;此后新增/变更的机制以「档案」为准,此表为**快速索引**。 + +| 机制 | 生效位置 | 档案 | +|---|---|---| +| 访问入口:门户 `https://alotbuy.com`、用户 `<用户名>.alotbuy.com`(旧域 301) | nginx vhost + `BASE_DOMAIN/COOKIE_DOMAIN` | **22** | +| 多租户隔离 = account + bwrap(`--ro-bind /usr //lib64 /etc`、`--dev/--proc`、私有 `/tmp`、`--unshare-pid`、`setpriv` 改 uid) | `src/supervisor/orchestrator.ts#spawnAsUser` + systemd-run scope(512M/150%/TasksMax 128) | 16 | +| 出网护栏:封云元数据端点 `100.100.100.200` + 外联观测 | `/etc/nftables-dsh-egress.nft` + `dsh-egress.service` | 14 | +| 单活跃会话(last-wins)+ 常驻上限(idle-reap / LRU) | `orchestrator.ts`(`instanceIdleTtlSeconds`、`maxIdleInstances`) | 08 | +| 崩溃自愈:指数退避 + 窗口熔断 + 观测(`restarts`/`lastCrashedAt`) | `crash-policy.ts` + `orchestrator.scheduleCrashRestart` | **20** | +| 登录直达会话(enter 等 launch token;401 守卫;冷启动 404 修复) | `routes/dsh.ts`、`web/*.html` | 06 / 13 / 15 | +| 技能分层:共享只读层(bundledSkillDir)+ 个人层 API/页面 | 编排器 env 注入 + `routes/skills.ts` | 10 / 11 | +| 插件三层归属:自带插件(仅 admin)/ 功能插件投放(门户,仅 admin)/ 实例设置启停 | `routes/business-plugins.ts` + `plugins.html` + `@dsh-local/business-plugins` | 16 | +| 目录选择器收敛:仅见自有目录(含 admin) | `@dsh-local/workspace-scoped-picker` + profile patch(insert + disabled) | **18** | +| 角色化 profile patch(非 admin 隐藏模型分区等) | `ensure-role-profile-patch.cjs` → `/cordis.patch.yml` | 09 / 15 | +| nginx 侧关键配置:`proxy_buffering off`(流式)、`gzip_proxied any`(~930KB bundle 压缩)、`/assets/` 长缓存、CF 真实 IP 还原 | `vhost/nginx/{alotbuy.com.conf,dsh.alotbuy.com.conf}` | 21 / 22 | +| 证书:`alotbuy.com + *.alotbuy.com`(DNS-01 via `/etc/cloudflare.ini`,传播 60s) | certbot + `/etc/letsencrypt/live/alotbuy.com/` | 22 | diff --git a/dsh-server-docs/02-运维手册.md b/dsh-server-docs/02-运维手册.md new file mode 100644 index 0000000..bb465d3 --- /dev/null +++ b/dsh-server-docs/02-运维手册.md @@ -0,0 +1,335 @@ +> ⚠️ **历史章节**(2026-09-08):本章「dsh-web 容器 / /opt/dsh/data」形态已废弃(容器 2026-09-09 删除,架构切到 dshs 多租户)。保留作历史参考;**当前运维见 `DEPLOY-本部署.md`**。 + +## 六、单实例迁移执行步骤(从当前 data/ 形态 → users/main 形态) + +> ⚠️ 以下命令在服务器执行(root)。当前容器 `dsh-web` 以 `/opt/dsh/data` 为 `/root/.dsh`。 + +```bash +# ===== 1. 停容器 + 重组目录 ===== +docker stop dsh-web +mkdir -p /opt/dsh/backups +mv /opt/dsh/backup-20260908 /opt/dsh/backups/20260908_initial # 旧备份归档保留 + +mkdir -p /opt/dsh/users/main +mv /opt/dsh/data /opt/dsh/users/main/.dsh # data 落入主目录 +mkdir -p /opt/dsh/users/main/workspaces + +# ===== 2. 固化密钥 + 重建容器(挂载主目录内两点) ===== +DK=$(docker inspect dsh-web --format '{{range .Config.Env}}{{println .}}{{end}}' | grep '^DEEPSEEK_API_KEY=' | cut -d= -f2-) +printf 'DEEPSEEK_API_KEY=%s\n' "$DK" > /opt/dsh/.env +chmod 600 /opt/dsh/.env + +docker rm dsh-web +docker run -d --name dsh-web --restart always --network host \ + -v /opt/dsh/users/main/.dsh:/root/.dsh \ + -v /opt/dsh/users/main/workspaces:/root/workspaces \ + --env-file /opt/dsh/.env \ + dsh-web:0.1.2 dsh web --host 127.0.0.1 --port 8900 --no-open --trusted-host 47.77.182.89 + +# ===== 3. 生成备份/恢复脚本 ===== +cat > /opt/dsh/backup.sh <<'EOF' +#!/bin/bash +# 一键备份:打包 users/ 全部用户主目录 → backups/时间戳.tar.gz +set -euo pipefail +TS=$(date +%Y%m%d_%H%M%S) +OUT=/opt/dsh/backups/dsh-backup-${TS}.tar.gz +cd /opt/dsh +docker stop dsh-web 2>/dev/null || true +tar -czf "$OUT" users +docker start dsh-web 2>/dev/null || true +echo "备份完成: $OUT" +ls -lh "$OUT" +EOF + +cat > /opt/dsh/restore.sh <<'EOF' +#!/bin/bash +# 一键恢复:./restore.sh <备份tar.gz>(当前数据先挪走可回滚) +set -euo pipefail +TAR=${1:?用法: ./restore.sh /opt/dsh/backups/dsh-backup-xxx.tar.gz} +test -f "$TAR" || { echo "备份文件不存在: $TAR"; exit 1; } +TS=$(date +%Y%m%d_%H%M%S) +cd /opt/dsh +docker stop dsh-web 2>/dev/null || true +[ -d users ] && mv users users.old-$TS +tar -xzf "$TAR" +docker rm -f dsh-web 2>/dev/null || true +docker run -d --name dsh-web --restart always --network host \ + -v /opt/dsh/users/main/.dsh:/root/.dsh \ + -v /opt/dsh/users/main/workspaces:/root/workspaces \ + --env-file /opt/dsh/.env \ + dsh-web:0.1.2 dsh web --host 127.0.0.1 --port 8900 --no-open --trusted-host 47.77.182.89 +echo "恢复完成(旧数据在 users.old-$TS,确认无误后可删)" +EOF + +chmod +x /opt/dsh/backup.sh /opt/dsh/restore.sh + +# ===== 4. 验证 ===== +docker ps | grep dsh-web +curl -s -X POST http://127.0.0.1:8900/api/llm/listProviders \ + -H 'Content-Type: application/json' \ + -d '{"type":"client-request","rpcId":"v1","method":"llm/listProviders","payload":{"args":{}}}' +# 期望 {"ok":true,"value":[{"id":"deepseek-official",...}]} +/opt/dsh/backup.sh +``` + +--- + + +## 七、备份与恢复语义 + +- **备份 = 打包 `users/`**(全部用户主目录,一个 tar);`.env`(部署配置)与 `std/`(可再生成)不进备份; +- **恢复 = 解压 + 重建容器**(restore.sh 已固化容器参数); +- **复制/迁移用户**:拷 `/opt/dsh/users/` 到新机同路径即可; +- 多用户同构扩展:新用户增加 `users//`,脚本无需改动(tar 整体 users/)。 + +--- + + +## 八、多用户扩展(dshs 落地前的过渡方案) + +目标:**每用户独立实例 + 严格隔离**。新增一个用户 = 一套同构目录 + 一个容器 + nginx 分流: + +```bash +# 以新增 zhangsan 为例(示例脚本 add-user.sh) +UID_NEW=zhangsan +PORT=8901 +mkdir -p /opt/dsh/users/${UID_NEW}/.dsh /opt/dsh/users/${UID_NEW}/workspaces +# 从 main 复制 profile 骨架(不含凭据/会话),或从 std/profile-skel 初始化 +docker run -d --name dsh-web-${UID_NEW} --restart always --network host \ + -v /opt/dsh/users/${UID_NEW}/.dsh:/root/.dsh \ + -v /opt/dsh/users/${UID_NEW}/workspaces:/root/workspaces \ + --env-file /opt/dsh/.env \ + dsh-web:0.1.2 dsh web --host 127.0.0.1 --port ${PORT} --no-open --trusted-host 47.77.182.89 +# nginx:新 server/server_name 分流 → 127.0.0.1:${PORT},沿用 Host/Origin 伪装头模板 +``` + +> 说明:web profile 首次启动会自动初始化模板,新用户空 `.dsh` 直接起容器即可;系统标准技能层由 `std/` 挂载提供。 + +### 认证路线 + +| 阶段 | 方案 | 说明 | +|---|---|---| +| 当前 | dsh 自带 token(一次性) | 仅单人过渡 | +| 近期 | **dshs 密码制基座**(每用户独立 DSH 实例 + 注册审核) | 多租户严格隔离的正规托管方案 | +| 后期 | **飞书 OAuth 登录** | 替换/并存 dshs 密码认证,预留扩展点 | + +> 备选认知:`@xgone/dsh-remote` / `dsh-passwords` 类认证网关是共享单租户,无法满足严格隔离,不采用。 + +--- + + +## 十二、域名接入记录(2026-09-08) + +**域名**:`dsh.alotbuy.com`(Cloudflare 代理,海外服务器免备案) + +| 项 | 状态 | +|---|---| +| DNS | `dsh.alotbuy.com` → Cloudflare(172.67.194.206 / 104.21.44.42),CF 回源服务器 80 | +| **通配 `*.dsh.alotbuy.com`** | ⚠️ **未配置**——dshs 每用户子域路由的前提,落地前需在 CF 加 `*` A 记录 → 47.77.182.89 | +| HTTPS | Cloudflare 边缘证书(源站无 443,CF SSL 需为 Flexible 模式,实测已通) | +| 源站 nginx | 新建站点 `/www/server/panel/vhost/nginx/dsh.alotbuy.com.conf`(监听 80,反代 127.0.0.1:8900,Host 伪装 + 剥 Origin/Sec-Fetch,含 WebSocket 头) | +| 验证 | token 登录 303→cookie(host-only 绑 dsh.alotbuy.com)→ 首页 200(24KB)→ API `{"ok":true, deepseek-official}` ✅ | + +**访问方式**:`https://dsh.alotbuy.com/?token=`(浏览器打开一次即种 cookie,30 天有效) + +**踩坑记录**: +- Windows 原生 curl 写 `/tmp/` 路径失败导致 cookie 不落盘 → 必须用 `C:/...` 路径(历史已知坑,本次复现) +- 对根路径发无 cookie 裸 WS Upgrade 返回 502 为 dsh 正常拒绝(IP 站点同样 502),非配置问题 +- nginx 新站点需与 47.77.182.89 站同样伪装头(Host 127.0.0.1:8900 + 空 Origin/Sec-Fetch),否则特权 API 403 + + +## 十四、dsh.alotbuy.com 注册为宝塔面板反代站点(2026-09-08 18:46) + +**背景**:手写 conf(`/www/server/panel/vhost/nginx/dsh.alotbuy.com.conf`)在面板数据库无站点记录 → 面板重置/批量管理可能误伤。按用户偏好(能走 MCP 尽量走 MCP)用宝塔 MCP 完成注册。 + +**执行链(MCP 优先,全流程零中断)**: + +1. SSH 备份 + 移走手写 conf:`cp` 到 `/opt/dsh/docs/nginx-dsh.alotbuy.com.conf.bak-20260908`,`mv` 出 include 目录(不 reload,服务无感) +2. MCP `ProxyProjectCreate`:domains=`dsh.alotbuy.com`,proxy_pass=`http://127.0.0.1:3080` → 站点 id=2 入面板 DB +3. MCP `ProxyWriteConfig`:把完整精调 conf(HTTP→HTTPS 跳转 ×2 + 443 主域 SSL + 443 通配子域 SSL)覆盖写回同路径 +4. SSH `nginx -t` + reload + 本机 curl 验证 + +**⚠️ 关键坑**:面板反代项目 conf 写入路径与手写 conf 同名同路径(`/www/server/panel/vhost/nginx/<域名>.conf`)——直接 ProxyProjectCreate 会用面板模板覆盖,丢失 SSL/通配子域/301 等精调内容。**必须"先移走手写 conf → 创建 → ProxyWriteConfig 写回精调版"三步走**。 + +**验证结果**: + +| 检查项 | 结果 | +|--------|------| +| SiteList 面板 DB | 2 站点:47.77.182.89(id=1)+ dsh.alotbuy.com(id=2)✅ | +| nginx -t + reload | OK ✅ | +| https://dsh.alotbuy.com(外网直连源站) | 200 ✅ | +| https://*.dsh.alotbuy.com(通配子域,未注册) | 404(dshs 正常拒绝,代理/SSL 层通)✅ | +| http://dsh.alotbuy.com | 301 → https ✅ | +| conf 保留 | 4 段精调 server 块完整(certbot 证书路径原样)✅ | + +**遗留说明**:面板站点列表 SSL 状态显示 `enabled:false`——证书为 certbot 手动签发(面板不托管),面板只做站点/conf 管理;如需面板托管 SSL 需换面板 Let's Encrypt 流程(不建议,会与通配证书冲突)。 + + +## 十五、启动 DSH 报"已有运行中"+ 无法对话 排障(2026-09-08 19:00) + +**现象**:admin 登录桌面点"启动 DSH",提示"有运行中的 DSH 请先停止",无跳转,无法对话。 + +**根因一(P0,已修复):DB uid 与系统账号 uid 错配** +- 主 DB(`/var/lib/dshs/dshs.db`,服务 `--db` 显式指向)users.uid=**100001**(bootstrap 写入 `baseUid+rowid`) +- 但 OS 账号按 `hashUid` 建:`dsh-cce6d1cdb376430480f0` uid=**114801**(早期版本 provision 用 hash;后 provision 改为读 DB uid-for-user,但账号没重建) +- 结果:编排器 spawn 用 DB uid=100001 → `setpriv --reuid 100001` → 子进程以**不存在的 uid** 运行 → 打不开 `700 owner=114801` 的用户 home → `EACCES: mkdir .../home/profiles/web` 秒崩 +- 编排器 `spawn` 事件即置 status=running,子进程崩后自动重启循环 → 前端一直显示"运行中"→ 再次点击启动返回 409 already_running(即用户看到的提示) +- **修复**:`UPDATE users SET uid=114801 WHERE id='cce6d1cd-...'`(备份在 `/opt/dsh/docs/dshs.db.bak-20260908-uidfix`)+ `systemctl restart dshs` + provision 脚本 `uid-for-user` 补 `--db /var/lib/dshs/dshs.db`(消除默认库分裂) + +**根因二(上游集成缺口,未修复):dsh web 强制浏览器 token,编排器"打开"不带 token** +- dsh web(0.1.2-rc.1,`dsh-client-connection` BrowserAuth)强制:首次访问需 `?token=`(进程启动随机、打印到 stdout)换取签名 cookie(绑定 authority=`127.0.0.1:`,30 天) +- 上游 dshs 0.1.0:launch 返回 url **不带 token**,desktop.html "打开 DSH" 直接 `window.open(url)` → 首访 401 `dsh web authentication required`;且端口动态分配,实例重启换端口 → 旧 cookie 失效需重新带 token +- 已实测全链路可行:门户 sid + `?token=` → 303 种 dsh-auth cookie → 双 cookie 访问 `https://admin.dsh.alotbuy.com/` 200(24233B SPA)+ `/api/llm/listProviders` ok:true +- **修复方向(待用户定)**:A. 小改上游——编排器解析子进程 stdout 的 token,launch/status url 自动拼 `?token=`(改 src/supervisor/orchestrator.ts + routes/dsh.ts,tsc 可构建);B. 接受现状——每次启动后手动用 journald 里 token 开一次;C. 等上游修复 + +**排障经验**:`launch folder` 是相对 **ws/** 根的路径(空串=根);`/api/dsh/status` 返回 url 为子域;子域下所有路径(含 /api/dsh/status)都会被代理到实例,故探测实例要用 `/api/llm/*` 等实例 API;cookie 域 `.dsh.alotbuy.com`(sid)与 dsh-auth 双 cookie 缺一不可。 + + +## 附录 A:常用命令速查 + +```bash +# 备份 / 恢复 +/opt/dsh/backup.sh +/opt/dsh/restore.sh /opt/dsh/backups/dsh-backup-<时间戳>.tar.gz + +# 容器日志(看 token / 排障) +docker logs --tail 50 dsh-web + +# 进入容器 +docker exec -it dsh-web bash + +# 插件管理(profile web) +dsh plugin --profile web add +dsh plugin --profile web remove <包名> +dsh --profile web --dump-config # 打印生效插件树 + +# 模型 API 直测(服务器本机) +curl -s -X POST http://127.0.0.1:8900/api/llm/listProviders \ + -H 'Content-Type: application/json' \ + -d '{"type":"client-request","rpcId":"v1","method":"llm/listProviders","payload":{"args":{}}}' + +# 测试 session 生成(门户 API 直调,10 分钟) +node /opt/dshs/mksess.cjs + +# 普通用户角色化 profile patch(隐藏「模型」设置分区,档案 09;admin 不受影响) +node /opt/dshs/ensure-role-profile-patch.cjs [--restart] +# 新普通用户 SOP:注册→approve→首登一次(建 profile)→ 跑上命令(--restart 使已运行实例生效) +``` + + +## 附录 B:参考链接 + +- npm:https://www.npmjs.com/package/@deepseek-ai/dsh(0.1.2-rc.1) +- 官方仓库:https://github.com/deepseek-ai/deepseek-harness +- CLI 行为参考:apps/cli/reference/README.md(分层优先级/部署默认) +- dsh skills 安装与优先级:`~/.dsh/skills`、`~/.agents/skills`、项目级 `/.dsh/skills`(社区文档,见当日讨论记录) +- 社区多租户方案调研(dshs / dsh-remote / dsh-passwords / dsh-im):见当日讨论记录 + +--- + +## 附录 C:2026-09-11 增补 + +> **【历史 vs 现行 · 2026-09-11 档案 53】** 正文各章(六~十五)写于 **2026-09-08/09**,属**当时的事实轨迹**(含旧域名 `dsh.alotbuy.com`、旧目录形态 `data/` `main`);**现行状态以本附录 C 与 `DEPLOY-本部署.md` 为准**,正文与附录冲突时**一律以附录为准**。 + + +### C.1 域名与证书(档案 22) + +| 项 | 值 | +|---|---| +| 门户 / 用户实例 | `https://alotbuy.com` / `https://<用户名>.alotbuy.com`(旧域 `dsh.alotbuy.com` 及子域 **301**,含用户名映射) | +| 环境变量 | `DSHS_BASE_DOMAIN=alotbuy.com`、`COOKIE_DOMAIN=.alotbuy.com`(备份 `/etc/dshs.env.bak-*`) | +| 证书 | `/etc/letsencrypt/live/alotbuy.com/`(`alotbuy.com + *.alotbuy.com`,DNS-01);旧域证书仅用于 301 | +| **签发/续期必须带传播等待** | `certbot … --dns-cloudflare-propagation-seconds 60`(默认 10 s 会失败;renewal conf 已写死该值) | +| 切换脚本 | `/opt/dsh/switch-domain-alotbuy.sh`(改 env → drain → 重启,幂等) | +| Cloudflare | token 仅 DNS 权限;回源实测走 **443**;`*.alotbuy.com` 为共享通配(gao/mao/work 在用,勿改) | + +### C.2 nginx 关键配置(改了容易被面板覆盖,务必记住) + +| 指令 | 作用 | 失效症状 | +|---|---|---| +| `proxy_buffering off;` | 让流式响应逐块下发 | 「看不到流式输出,结果一次性出现」 | +| `gzip_proxied any;` | 压缩反代响应(那个 ~930 KB 插件 bundle) | 刷新载入极慢 | +| `location ~* ^/assets/ { expires 30d; }` | 哈希静态资源长缓存 | 每次刷新重下 ~1.2 MB | +| `set_real_ip_from ` + `real_ip_header CF-Connecting-IP;` | 还原真实客户端 IP(**须写在 server 块内**) | 日志/风控里全是 CF 机房 IP | + +> ⚠️ 在宝塔面板里保存站点配置会按模板**重写 vhost** → 上述自定义指令可能被抹掉;若症状回归,先查这几行。 + +### C.3 平台脚本族 + +| 脚本 | 用途 | +|---|---| +| `/opt/dsh/switch-domain-alotbuy.sh` | 域名切换(含 drain) | +| `/opt/dshs/ensure-role-profile-patch.cjs` | 角色化 profile patch(非 admin;`--force` 升级、`--restart`) | +| `/opt/dshs/scripts/ci.sh` | 类型检查 + 构建 + 单测(**不含**需要实例/凭据的 smoke) | +| `/opt/dshs/poc/workspace-scoped-picker/test/poc.mjs` | 目录选择器插件自检(26 项,须以实例 uid 从可读路径运行) | +| `docs/scripts/docs-sync-check.sh` | 文档库双端对账(0=全绿) | + +### C.4 排障速查(本手册新增条目) + +| 症状 | 先查 | +|---|---| +| 用户实例异常/被清 | `journalctl -u dshs | grep -E "crash-restart|circuit-open|idle-reap"` | +| 实例反复起不来 | 同上(含 `crash-loop-circuit-open` = 已熔断,需用户重新进入或人工介入) | +| 会话「连接中」/卡顿 | 先看 nginx 是否仍 `proxy_buffering off`;再看 `/www/wwwlogs/alotbuy.com.log` 的状态码与耗时 | +| 日志里客户端 IP 全是 CF 段 | vhost 的 `real_ip_header CF-Connecting-IP` 是否被覆盖 | +| 证书告警/续期失败 | renewal conf 是否含 `dns_cloudflare_propagation_seconds = 60` | +| 需要看实例资源 | `ps -eo pid,user,pcpu,rss,args | grep "[d]sh --profile"`;scope 名见 journald | + +> 注:诊断实例内文件系统时,`pgrep -f "dsh --profile web"` 会先命中 **bwrap 包装进程(root)**,在其命名空间内探测会得出"全部可读写"的**假象**——必须取 `ps -eo user,args` 中 user 为 `dsh-*` 的子进程(档案 17 §一)。 + +### C.5 本部署**未使用/未验证**的子系统(升级 dshs 时重点回归,档案 19 §C8) + +| 子系统 | 文件 | 本部署情况 | +|---|---|---| +| Kubernetes 后端 | `src/supervisor/k8s-spawner.ts`(679 行)、`deploy/*.yaml` | **未使用**(`ISOLATION_MODE=account`);升级时需按"未验证"对待 | +| PostgreSQL 后端 | `src/db/pg.ts`(508 行) | **未使用**(本部署用 sqlite/better-sqlite3) | +| Leader 选举 | `src/cluster/leader.ts`(254 行) | **未使用**(单实例部署) | +| 对账 | `src/supervisor/reconcile.ts` | 仅 k8s 路径使用 | + +> 结论:可保留上游能力,但**任何涉及它们的改动都不应在生产直接验证**;升级后若这些文件报编译错误,优先改为"绕过/排除"而非线上调试。 + +### C.6 上传安全扫描:分级(2026-09-11,档案 19 §C6) + +| 档 | 行为 | 规则 | +|---|---|---| +| **P0 阻断** | 命中即 **HTTP 400** 拒绝上传 | 原有 7 条(`rm -rf /`、`curl \| sh`、读系统凭证/SSH 私钥/云凭证/平台数据目录/`.credentials.yaml`)+ 新增:`nc -e` 反弹、`/dev/tcp` 反弹、base64 解码后直接执行、给系统二进制加 setuid、fork 炸弹 | +| **P1 告警** | **不阻断**,写日志 + 作为 findings 返回 | 动态执行子进程、`vm`/`eval`/`new Function`、敏感 env 读取、超长 base64、外部网络访问、隐藏目录/`.git` | + +- 覆盖面:文本扩展名 **19 → 31**(含 `.toml/.ini/.conf/.env/.rs/.go/.java/.php/.rb/.pl/.lua/.sql/.ps1/.bat`…);无扩展名按 **shebang** 判定;二进制按 `0x00` 跳过;体积上限 **2MB → 8MB**。 +- 排查:`journalctl -u dshs | grep upload-scan-warnings`(P1 告警);P0 直接体现在上传接口的 400 响应里。 +- 冒烟(可复跑):`node /tmp/scan-smoke.cjs` 思路见档案 19 §C6 记录(P1 不阻断 / P0 阻断 / 良性零误报)。 + +### C.7 用户数据清理(档案 28,cron 自动跑) + +| 任务 | 时间 | 阈值/年龄 | 日志 | +|---|---|---|---| +| `storage-report.cjs` | 每小时 05 分 | 生成 `/var/run/dsh-storage-report.json`(门户「存储用量」面板的数据源) | `/var/log/dsh-storage-report.log` | +| `ws-cleanup.cjs --apply` | 每天 04:10 | ws ≥ 2048 MB;清 T1 平台产物 + T2 超 90 天顶层脚本(`ws/.keep` 可豁免 T2) | `/var/log/dsh-ws-cleanup.log` | +| `session-gc.cjs --apply` | 每天 04:20 | sessions ≥ 1024 MB;清超 **365 天**会话(并同步回收 projcache) | `/var/log/dsh-session-gc.log` | +| `purge-trash.sh 30` | 每天 04:40 | 回收站超 30 天 | `/var/log/dsh-trash-purge.log` | + +- **所有清理都先 `mv` 到 `/trash/<日期>-<任务>/`**(30 天可恢复);只有 `purge-trash.sh` 会真删。 +- 手动排查:`node scripts/ws-cleanup.cjs`(dry-run 出画像)、`node scripts/session-gc.cjs`、`node scripts/clean-ws-pollution.cjs`。 +- 三档口径:T1 平台产物/明确垃圾直接清;T2 顶层一次性脚本 _超 90 天_ 才清;**T3 其余(含所有目录)永不自动删**。 +(域名 / 证书 / nginx / 平台脚本 / 排障速查) + +### C.8 备份与恢复(2026-09-11 修复:脚本曾有但不可用) + +**问题**:`/opt/dsh/backup.sh` 存在但**无执行权限**(`-rw-------`)且**未接任何调度** → 最后一次全量备份停留在 2026-09-08。 + +**现状(已修)**: +- 脚本:`/opt/dsh/backup.sh`(副本入库 `scripts/backup-platform.sh`),`chmod +x`,并加固为 + **① SQLite 一致性快照**(`sqlite3 .backup`,避免运行中只拷到 `-wal` 造成库不一致) + **② 归档内容扩展**:`var/lib/dshs`(用户数据)+ `etc/dshs.env` + systemd 单元 + `opt/dsh/artifacts`(插件包)+ `opt/dshs/scripts` + `etc/cron.d/dsh-*` + `nftables-dsh-egress.nft` + `www/server/panel/vhost/nginx`(可重建) +- 调度:`/etc/cron.d/dsh-backup` —— **每周日 05:00 全量** + 05:30 清理超 60 天的旧备份;日志 `/var/log/dsh-backup.log` +- 产物:`/opt/dsh/backups/dsh-platform-backup-.tar.gz`(全量)+ `…-db-snapshot.tar.gz`(DB 快照) + +**恢复(演练已验证)**: +```bash +B=/opt/dsh/backups/dsh-platform-backup-.tar.gz +tar -tzf "$B" | head # 先看清单 +tar -xzf "$B" -C / # 原路径还原(谨慎!会覆盖现网文件) +sqlite3 /var/lib/dshs/dshs.db "select count(*) from users;" # 校验 +``` + +**实测证据**:首跑全量 **8.7 MB / 6919 条目**(users 6858 + DB + env + artifacts + scripts 20 + cron 2 + nginx vhost 22 + systemd 3);**恢复演练**解出 DB 并查询 `users` = 2 行 ✓。 diff --git a/dsh-server-docs/03-路线图与待办.md b/dsh-server-docs/03-路线图与待办.md new file mode 100644 index 0000000..9241737 --- /dev/null +++ b/dsh-server-docs/03-路线图与待办.md @@ -0,0 +1,126 @@ +# 03 路线图与待办 + +> 状态(刷新 · 2026-09-12 15:05):本文件长期晚于实际进度(此前停在 09-11 23:05)→ 本次补登记**档案 57–68**、清理已完成行、并把待办口径与 `交接单/README.md §一` 对齐(**已规划待执行的以交接单为准**,本文件只保留指针)。原文:**2026-09-11 21:20 合并版**(补回 14~22 与 B1/B2/B3/B4、档案 28、备份加固;保留「MCN 改造暂缓」「白名单源码安装」等新决策)。 + +## 一、待验证项(Open Questions)结论 + +| # | 原问题 | 结论(实测日期) | +|---|---|---| +| 1 | `.env` 注入 `DEEPSEEK_API_KEY` vs 用户个人 key 优先级 | ✅ 已定:**统一 admin key 模型**(commit df5cc84,2026-09-09)——resolveApiKey 忽略 userId、取 role=admin 启用 key,spawn env 注入;keys 路由 requireAdmin;无"个人 key 覆盖"场景 | +| 2 | web profile 空 `.dsh` 首启自动初始化(add-user 流程) | ✅ 已解答:dshs 模式无此问题——provision 建 OS 账号 + profile 首次 spawn 即自动初始化;testuser 注册→审核→桌面→DSH 全链路已实测通过 | +| 3 | 前端 loopback 检测能否 `--patch` 放行(用户自配模型) | ✅ 已实测(2026-09-09):proxy.ts 将 Host 伪装为 `127.0.0.1:` → dsh 的 /api trust fence 与 loopback 特权校验**全放行**,Settings 面板在门户代理后可用 → **暴露面成立:登录用户可进 Settings 自配 key/模型**,作为已知安全边界记录(第二道防线),暂无封锁需求 | +| 4 | dshs 对 web Settings 限制 | ✅ 同上实测:Settings 面板可用(portal-entry v0.4.1「平台管理」分区即在此验证) | +| 5 | 容器内 `dsh plugin add` 走 npmmirror | ⏹ 过时归档:架构切换后插件走**宿主全局 dsh + profile 层 pnpm 转发**(`dsh plugin --profile web`);装法 = 本地 `npm pack` 出 tgz → `dsh plugin add `(实测 v0.1.0→v0.4.1 全链路) | + +## 二、路线图 + +### 已完成(2026-09-08 ~ 09-09) + +- [x] **P0 迁移**:data → users/main(2026-09-08 17:30) +- [x] **P2 调研**:dshs 选型前置调研(2026-09-08) +- [x] **域名接入 + 通配 HTTPS**:dsh.alotbuy.com 通配证书(2026-09-08 18:00-18:40,certbot dns-cloudflare) +- [x] **架构切换**:dshs 多租户 + account 硬隔离(2026-09-08 18:30) +- [x] **登录直达 v1+v2**:POST /api/dsh/enter 按角色分流 + token 自动携带(commit 6f63108 / e39628a / 29c8907) +- [x] **统一 KEY 管理员管控**(df5cc84) +- [x] **用户管理**:注册/审核/删除用户(4943c8c)+ 权限收紧(chmod 系列) +- [x] **实例生命周期**:last-wins 单活跃会话 + idle-reap 常驻上限(928dde1 / 6336c53) +- [x] **门户功能插件化 PoC**:`@dsh-local/portal-entry` v0.4.1 浏览器验收通过(2026-09-09)——设置面板「平台管理」分区(门户地址 + 打开管理台 + 退出登录),档案 05 +- [x] **技能管理面**:共享 + 个人技能 API 与页面、zip-only 两阶段替换(2026-09-09,档案 11) +- [x] **文档库路径统一(2026-09-10)**:工作区 `D:\AgentSkill\aliyun-work-space` → `D:\AI技能\aliyun-work-space`;文档目录 `dsh-docker\docs\` → **`dsh-server-docs\`(内容提升到根,与服务器 `/opt/dsh/docs` 同构)**;6 处旧路径改写 + 双端同步,25/25 md5 一致;对账脚本 `scripts/docs-sync-check.sh` 落地 +- [x] **工作区迁移 D: → E:(2026-09-13)**:工作区 `D:\AI技能\aliyun-dsh-server` → **`E:\ProgramData\AI技能\aliyun-dsh-server`**(`D:\AI技能` 已无实体目录)。同步改的**活路径**:`~/.workbuddy/settings.json`(两个 hook 命令)、`dsh-server-docs/scripts/lock-guard-hook.py`(`DSH_DOCS_ROOT` 默认值)、`scripts/docs-sync-check.sh`(`DOCS_LOCAL_DIR` 默认值,本库 + 项目根共两份)、`dsh-server-docs/scripts/extract-user-voice.py`(目录名示例)、`dsh-server-docs/README.md` 与 `INDEX.md` 的本机路径、档案 73 的 hook 安装片段。**历史档案(01-规划与架构 / 04-11 / 04-12 / archive/ 下各篇)中的 `D:\AI技能\...` 保留原值**(写于迁移前,属历史,按「档案只增不改」不回改)。**代码仓 `D:\github\dsh_shenxian` 未变动**(不在 `AI技能` 之下)。 + ⚠️ 排查期间曾临时建 `D:/AI技能 → E:/ProgramData/AI技能` 目录联接,用于救活**会话启动时快照**里已失效的旧 hook 路径;**已按用户要求移除**。⇒ **改 hook 路径后,已在跑的会话需「完全重启」(关窗 ≠ 退出)或新开会话才生效**(2026-09-13 实测:本会话 06:47 启动、06:55 改配置,07:05 拆联接后写操作仍报旧路径)。 +- [x] **档案 78 · 崩溃熔断冷却期与告警(2026-09-13)**:修 **档案 77 §八 遗留 1** —— 原实现熔断即 `resetCrashState()` 清预算 ⇒ 只要有重试路径(用户 F5 / 注入脚本自愈 / 脚本直铺)崩溃循环就能**无限重来**,且只留一行 stderr。改为:熔断态**跨轮存活** + **指数冷却**(10 min → 封顶 6 h),冷却期内 `launch()` 拒绝隐式启动(HTTP 503 `instance_circuit_open`),冷却过后只给**一次**干净预算;熔断**双通道告警**(stderr + `/var/log/dsh-crash-breaker.log`)并把 `breaker` 暴露到 `/api/dsh/status`。改 6 个文件(含 3 条新单测);`npm run build` rc=0、`npm test` **45 pass / 0 fail**。**已部署并验证**(2026-09-13 08:02 重启载入:PID 410287→413817、门户 200、孤儿 scope 0、`/api/dsh/status` 返回新字段 `breaker`)→ 档案 78 §九 +- [x] **登录直达冷启动竞态 404 修复(2026-09-10)**:enter 复用/AlreadyRunning 分支等 launch token 到位再返回 URL,杜绝启动窗口 404(commit `fa718d2`,档案 13) +- [x] **敏感信息暴露面审计与加固(2026-09-10,档案 14)**:判定跨租户/提权不成立;封云元数据端点 `100.100.100.200` + 外联观测(`/etc/nftables-dsh-egress.nft` + `dsh-egress.service`) +- [x] **会话失效 401 未跳登录页修复(2026-09-10,档案 15)**:desktop/admin/skills 三页注入 401 守卫;核心插件开关收归 admin +- [x] **页面导航定稿 + 插件三层归属(2026-09-10,档案 16)**:门户 SPA 化、技能/插件独立页面、功能插件候选池投放 + 实例设置启停;阶段 0-2 已实施(`bad6ed7`/`6f86a5f`/`b7fd85d`) +- [x] **实例沙箱隔离(档案 16)**:bwrap + systemd-run scope(512M/CPU 150%/TasksMax 128)+ 私有 /tmp + `--unshare-pid`(commit `449f28b`) +- [x] **工作区选择器暴露面核查(2026-09-10,档案 17)**:判定非越权;记录 P1 写边界=会话 cwd / P2 picker 无根白名单 / P3 nft 文件可读 +- [x] **文档库去重 + 单一来源(2026-09-10,档案 19 §D4)**:删根级 05 副本、`04/README.md` 改指针 +- [x] **崩溃自愈加固(2026-09-11,档案 20)**:指数退避 + 窗口熔断(5 次/10 min)+ 观测(`restarts`/`lastCrashedAt`)+ handoff 停写;**live 自愈实测通过**(kill → 1 s 后 `[crash-restart]` 拉起);commit `7cba3e3` +- [x] **CI 脚本 + 清理失效 smoke(2026-09-11,档案 19 §C3)**:新增 `scripts/ci.sh`;删 `smoke-plugins.mjs`(目标路由已移除)与 `smoke-watchdog.mjs`(watchdog 从不启动) +- [x] **访问域名迁移(2026-09-11,档案 22)**:门户 `alotbuy.com` + 用户 `<用户名>.alotbuy.com`;通配证书 DNS-01(传播 60 s)、CF 真实 IP 还原、旧域 301 含用户名映射 +- [x] **nginx 性能修复(2026-09-11,档案 21)**:`proxy_buffering off`(修「一次性输出」)、`gzip_proxied any`(930 KB bundle 压缩)、`/assets/` 30d 缓存 +- [x] **官方白名单插件来源(2026-09-11,档案 29)**:门户「插件管理」接入 awesome-dsh-plugin 官方目录(`plugins.json`,3408 条 / 23 分类),admin 搜索/筛选/多选 → 导入功能插件候选池;导入口径 = **仅预构建**(npm registry tarball / release 资产),可导入 **1792 条(52.6%)**、热门插件全覆盖,平台侧不执行任何第三方构建脚本 +- [x] **编排器孤儿实例清理(2026-09-11,档案 30)**:spawn 前清 `dsh--*` 孤儿 + 启动时清一次;两场景 live 验证通过 + +- [x] **档案 14~22 全部落地(2026-09-10~11)**:敏感信息暴露面审计(封云元数据端点 + 外联观测)、会话失效 401 跳登录、页面导航定稿 + 功能插件三层归属、实例沙箱隔离(bwrap+scope)、工作区可见面核查、目录选择器收敛 v3、崩溃自愈(退避+熔断+观测)、CI 脚本、**访问域名迁移到 alotbuy.com**(旧域 301)、nginx 性能修复(buffering/gzip/assets) +- [x] **写保护 B1(档案 17 §P1)**:实例内 `--ro-bind-try` 覆盖 `cordis.patch.yml`/`package.json`/`pnpm-lock.yaml`(写测试 ✅ + 真实 dsh 启动验证 ✅) +- [x] **编排器自愈 B2(档案 18 收尾)**:`ensurePickerProfile(userId)` 在 launch 前 + spawn 后各异步补齐(幂等) +- [x] **上传安全扫描分级 B4(档案 19 §C6)**:P0 阻断 / P1 告警 + 扩展名 19→31 + shebang/二进制/8MB(冒烟通过) +- [x] **死代码子集 B3(档案 19 §C5)**:删 `src/runtime.ts`;`folder_plugins`/`workspaces` 加废弃注释(**完整版清理已关闭**:跨 10 文件且含 k8s/PG 未验证路径,删除风险不对称) +- [x] **用户数据清理三件套(档案 28)**:`ws-cleanup`(T1 平台产物 / T2 顶层一次性脚本 >90 天 / T3 永不删)+ `session-gc`(**365 天**保留)+ `purge-trash`(回收站 30 天)+ 每小时用量快照 + 门户「存储用量」面板(`GET /api/admin/storage`) +- [x] **备份可用性修复(档案 02 附录 C.8)**:发现 `backup.sh` **无执行权限且未调度**(最后备份停在 09-08)→ 修好 + SQLite 一致性快照 + 纳入 artifacts/scripts/cron/nginx vhost + `/etc/cron.d/dsh-backup` 每周日 05:00(恢复演练已验证) + +### 已完成(2026-09-11 ~ 09-12,本次补登记 57–68) + +- [x] **档案 57 · 设置面板「用户管理」入口 + 全员安装**(2026-09-12 00:04) +- [x] **档案 58 · 实例内存优化与配额下调**:512M → **384 MiB** + 编译缓存(2026-09-12 00:23) +- [x] **档案 59 · 重连反馈:实例启动中的加载动画**(2026-09-12 01:30)|⚠️ 遗留:`wake.html` 本机 4708B vs 服务器 4591B **待核对同步** +- [x] **档案 60 · 设置面板分区改名「功能插件」→「功能管理」**(2026-09-12 08:30) +- [x] **档案 61 · 插件管理页官方插件列表加高 + 底部留白 200px**(2026-09-12 09:14) +- [x] **档案 62 · 插件目录缓存状态可见化 + 「重新拉取目录」按钮**(2026-09-12 09:21,`be42155`) +- [x] **档案 73 · 让锁真正拦得住人**(2026-09-12 17:0x):复盘发现三把锁**当天被跳过两次**(含本会话)→ 根因①无强制入口(`settings.json` hooks 段为 null)②guard 输出「无锁」被读成「可以开工」。治:① 措辞修正(guard 三处 + 交接单语义)② `scripts/lock-guard-hook.py`(PreToolUse 无锁拒写 + SessionStart 提示,**作用域仅本库/本代码库**;四态单点验证通过);⚠️ 钩子待用户在 `/hooks` 审核启用 +- [x] **档案 72 · 实例回收/关闭后回到页面自动唤醒并重建连接**(2026-09-12 16:55,`b23e386`):注入脚本原来只认 401,实例回收时代理返回的 `404 not_running` 无人处理 → 用户必须手动刷新。补:① `hit()` 识别 not_running ② `visibilitychange`/`focus`/`pageshow` 时主动探活 `/api/dsh/status` ③ `recover()` 跳 `wake.html`(走 `enter` 拿**新 token**,避免 reload 带旧 token 再撞 401) +- [x] **档案 71 · 插件兼容性预检:导入 / 上传即判定**(2026-09-12 16:35,`8d19e89`):semver 依赖范围 + 运行时导出符号比对,不兼容在导入时即拒收(承接档案 70 的教训)→ 方案 `04-71` +- [x] **档案 70 收尾 · AnySearch 彻底放弃 + 候选池存量体检**(2026-09-12 18:56):用户选 B → 下架 anysearch(`audit` 175);**顺手发现预检只覆盖"新导入"、覆盖不到存量** → 用 `lib/web/plugin-compat.js` 体检池内两条:`dsh-univer-office` = `ok`(保留)、`@liustack/modlens` = `unknown` 且有 `plugin_incident`(audit 172/173)→ 一并下架(`audit` 176,tgz 备份 `/opt/dsh/backups/plugin-pool-20260912/`)。全程**未重启服务、未中断用户**。另落 **R9 红线**(禁止人工删锁/接管)→ 档案 70 §九 · 档案 73 §十一 +- [x] **档案 70 · anysearch 插件与 dsh `0.1.2-rc.1` 不兼容 → admin 实例崩溃循环**(2026-09-12 15:32 **已止损**):根因 = 插件在 import 阶段引用 `@deepseek-ai/dsh-llm` 未导出的 `assertNever`,而 dsh 的 plugin tree 加载**全或无** → 启动中止、崩溃自愈反复重启;摘掉该 bundle 即恢复。**✅ 该待办已于 2026-09-12 18:56 关闭**:用户选 B · **彻底放弃**(候选池条目下架 + 存量体检)→ 见本节「档案 70 收尾」行与 §二 待办表「已处置」行 +- [x] **档案 69 · 并发治理落地(T04)**(2026-09-12 15:10,`exec-session-C`):文档库 commit 常态化 + 服务器侧操作锁 `/opt/dsh/state/.op-lock/`(实测往返通过)+ 交接单目录权限统一;三把锁进预检脚本 → 单子已归档 +- [x] **编号 63 为空号**:该号只出现在当日工作日志的「事故 63」里(清理残留 tgz 致依赖断裂),**无对应档案**,勿补占 +- [x] **档案 64 · 接入 AnySearch 搜索 provider(B 方案)**(2026-09-12):平台现用 DeepSeek 官方搜索;AnySearch 前置验证 4 项全绿、**admin 侧已实施**;⚠️ 待办改以**档案 65 §7.3** 为准 —— **2026-09-12 用户拍板:三方插件一律走「admin 导入候选池 → 用户自助启用」,不再向用户 profile 直铺**(§8.3 第 2 条直铺命令已作废) +- [x] **档案 65 · 功能插件启停 ↔ web provider 配置联动**(2026-09-12):根治「候选池一禁用就 `CONFIGURED_MISSING`」→ 平台按当前 bundles **重算托管段**,启/禁两态皆正确;**代码完成 + 两态实测,⏸ 待部署(需 R8 窗口)** +- [x] **档案 66 · 业务插件 P0 误报 → admin 显式信任**(2026-09-12 11:15 已部署):安全检测改 **fail-closed + 逐条回显 + admin 声明信任后放行并留痕** +- [x] **档案 67 · 「功能管理」section 按 UI 规范重做(v0.2.4)**(2026-09-12):用途说明做主视觉 + 字号/组件对齐 `06` +- [x] **档案 68 · 候选池启停的 root 属主污染根治**(2026-09-12):候选池 `install/uninstall` 改 **`setpriv` 降权** + 改插件前自动属主自愈 + 清存量 **561** 项;附带修 `npm pack` 把上一版 tgz 打进产物(加 `.npmignore`) + +### 进行中 / 待办 + +| 优先级 | 事项 | 说明 | +|---|---|---| +| ✅ **已完成** | ~~**档案 65 部署**(功能插件启停 ↔ web provider 托管段联动)~~ | **本就是生效状态**:服务器 `lib/` 00:13 构建 → **00:15:23 重启即已载入**(08:02 再载入);**§7.3 第 3 条已完成**(`ensure-anysearch-admin.cjs` 加硬拦退役,默认 `exit 2`,备份 `.bak-retire-20260913`)| +| ✅ **已完成(留一项 L1)** | ~~**档案 78 部署**(崩溃熔断冷却 + 告警)~~ | **2026-09-13 08:02 已部署并验证**(见档案 78 §九)。**遗留 L1**:故意把实例反复搞崩以验熔断(需 6 次真崩 + 该用户冷却 10 min)→ **留维护窗口**做 | +| ✅ **已完成并归档**(2026-09-13 18:0x) | **T03 · 7 插件整合投放** | **guest 启用已成功**(任务 `c20739a6dbe8975c`:`success`/`restarted=true`;配额自动 672→**800 MiB**); **admin 侧 8 项实证通过**(包结构 v0.3.9 / 上传扫描 P0=0 / 池内 / `[mcn-suite] loaded` 且 duplicate=0 / 旧 7 包已下架 / 技能落 `/skills/mcn-short-video` / 幂等跳过 / BRIEF 384M);**剩 6 项需浏览器或造场景**(D 入口逐个点开 / J agent 按名加载 / L 凭据扫描 / N 死引用扫描 / P·Q 撞名三场景)→ 见 `交接单/T03` §九 | +| ✅ **已消解(无需窗口)** | ~~**实例内存预算上调**(`--max-old-space-size` 160→256 + cgroup `MemoryMax` 384→512 MiB)~~ | **2026-09-13 13:3x 实测:R1-④ 已把它改成「按插件集合动态计算」,比原方案更优** —— `instanceMemMb()` = 160 + Σ插件预估(clamp 384–1024),`heapMbFor()` = 配额 − 96(cap 256)。运行中实证:guest `NODE_OPTIONS=--max-old-space-size=256` / scope `MemoryMax=544 MiB`(= 160 + univer 384);**admin 也是 256**(不再是 160)。平台 env 里那个写死的 `160` 已被代码侧 `withHeap()` 覆盖、**不是生效值**。⇒ **T03 的 guest 启用不再被容量阻塞**(装上 mcn-suite 后配额会自动变 672 MiB)。原「须重启服务(R8)」的前提已不成立 | +| 🟡 **待排期** | **档案 81 重构总纲的 R2/R4/R5**(**R0/R1/R3 已完成**) | **R2** 管理面就地化 → 🚧 **代码已完成(2026-09-13 17:5x,档案 82)**:插件 `@dsh-local/business-plugins` **0.2.8→0.2.9**,新增原生「平台管理」只读分区(仅 admin;数据调平台只读 API;写操作回跳管理台);`node --check` 通过、tgz 已出;**待投放 + 启用 + 浏览器验收(卡在需 admin 登录态)**;`/admin` 路由族与删 3 桩页**仍未做**。⚠️ **形态已改定(2026-09-13 用户):用「原生弹窗」,不用 iframe**(原话「iframe 不如原生弹窗体验好」)⇒ 改为**在自研插件里原生渲染只读子集**(调平台 `/api/*`,非 iframe 嵌 `portal.html`);仍按 R5 只读优先,登录后 30 天内无需重做门户全量 UI ⇒ 见档案 81 §9.2 修正|**R3** 内部标识统一为 **`dshs`** → ✅ **已完成(2026-09-13 15:4x–16:0x)**:代码 89 文件 / 382 处 + 本机其余 135 文件 / 863 处 + **服务器原子切换**(单元真名 `dshs.service`、`/etc/dshs.env`、`/opt/dshs`、`/var/lib/dshs`、`dshs.db`;含 **WAL 归位**与 DB **VACUUM**);上游具名引用全清(含移除 `git remote upstream`);两仓 git 历史已删重建为单提交|**R4** 文档/代码同仓 → ⏸ **待用户选 a(并入代码仓)/ b(单向导出)**(用户 09-13 反馈「没看懂 R4 要做什么」⇒ 需先讲清动机再选)|**R5** **多语言 i18n** → ✅ **方案已定(2026-09-13 用户):「匹配 dsh 官方方案」= 走官方 locale 体系**(`ctx.locale.addLanguage()` + `register(ns)`,平台页/注入层/自研插件全走官方扩展点、**零官方改动**);⚠️ 覆盖率边界见档案 81 §10.7 ⑥ —— 官方**仍有 20+ 个 UI 包未迁 locale**,那些包切语言不变(属官方自己的待办,不为我们可改范围)| +| ✅ **已修复并验收**(2026-09-13 17:2x) | ~~**档案 76 · `/univer-api/state` 生产持续 400**~~ | **本会话独立复核**:近 2 小时 400 = **0**、近 30 分钟 6 次请求**无 400**;实例 env `UNIVER_DSH_GATEWAY_SOCKET=auto` 已生效(档案 76 追加节=真因+修复+验收)。原述 2026-09-13 07:36 实测:guest 用户在 1 秒内被连续 ~10 次 400(平台侧只记状态码)→ **根因待取响应体**;⚠️ 同时发现 **guest `ws` 下 0 个 `.univer` 文件**(目录在、文件不在)→ 需沿「MCN 生成 `.univer` → Univer 打开」链路查 → 档案 76 追加节 | +| ✅ **已处置** | ~~AnySearch 能力是否保留~~(档案 70 §八) | **用户选 B · 彻底放弃**(2026-09-12 18:56):候选池条目已下架(HTTP 200 / `audit` 175)、凭据无残留、无用户启用过;**顺带体检池内存量** → `dsh-univer-office` = `ok`(保留)、`@liustack/modlens` = `unknown` + 有 incident → 一并下架(`audit` 176,tgz 已备份)。**未重启服务、未中断用户** → 档案 70 §九 | +| ✅ 已完成 | **T05 · 插件兼容性预检**(导入/上传即判定兼容性) | 2026-09-12 16:35 落地(`8d19e89`):判据模块 + 上传/导入双入口接入 + build + 重启;验收全绿 → 方案 `04-71`,单子已归档 | +| **档案内挂起** | 档案 57 四项(picker store 隐患/dep spec 指向 `ws` 会被清理/`portal_ping` 失效工具/插件源码两处存放)|~~档案 59 `wake.html` 本机 4708B vs 服务器 4591B 待核对同步~~ ✅ **2026-09-13 09:4x 实测双端一致**(均 4962 字节、md5 `00b81728…`;变大是因档案 78 给 wake.html 加了熔断文案)|档案 66 三项(官方目录**批量导入**未接信任入口/信任状态未持久化→建议并入 migration v6/`ensure-anysearch-admin.cjs` 覆写段待退役,否则与档案 65 托管段互相覆盖)|档案 68 平台 setpriv 路径待用户会话自然验证|档案 42 三项(③ **已闭环并实测通过**:子代理派发 `bash -c 'echo subagent-ok'` 正常返回 ⇒ shell 可用、平台零改动;见档案 42 末「追加结论 + 实测结果」)/档案 38b 三项/档案 15(`admin.html` 缺页面层 role 拦截)/档案 32(`dsh-market` 可绕过第三层管控,**未成档**) | 详见各档案 §待办段 | +| ✅ **已修复并部署** | ~~**插件启停的两处平台缺陷(档案 79)**~~ | **2026-09-13 13:4x 逐条核实产物(档案 79 §七)**:**D1** `await uninstall(…)` ✅(产物 `:587`/`:617`)|**D2** `remove` 已改 `-w`(`:575`,全仓无效 flag 仅剩 `add` 一处=合法)✅|**加固 a** 路由外层 `.catch()` ✅(`:613`+`:718`)。**D3 已消解**:R1-④ 把堆限与 `MemoryMax` 改为按插件集合动态计算(实测 guest `heap 256` / `MemoryMax 544 MiB`)⇒ 不再需要窗口。⚠️ **仍缺端到端验收**(重现「注定失败的启用」→ 期望 任务 `failed` + profile 回滚 + 服务不退出)→ 建议与 T03 的 guest 启用合并验。**加固 b**(半应用态主动自愈)未做,可选 | +| 触发式 | dsh 升级回归(档案 26 六类耦合点)|会话 GC | 升级 / 容量触发 | +| P3 | 门户「浏览文件」补下载入口 | 档案 56 §七:门户 `#/files` 目前只能列表/上传,可复用 `/api/fs/download` 加"下载"按钮 | +| ✅ | ~~老会话权限档位对齐(检测 + 提示)~~(档案 56) | **已完成(2026-09-11)**:`GET /api/dsh/session-permission` + 实例页顶部提示条(说明原因 + 切档位/新建会话两条路);实测 `stale=true` 触发正常。**不自动改档位**(安全语义变更需用户知情) | 档案 55:档位是**会话级播种** —— 09-10 及更早的会话仍 `workspace-write` → 沙箱 fail-closed,**bash 被拒 11 次**(实测:同一会话 22:16 手动切 `danger-full-access` 后立刻恢复)。建议 `/api/dsh/enter` 检测会话 preset 与平台默认不一致 → **页面提示 + 一键切换**(不自动改) | +| ✅ | ~~实例能力清单(自检)~~(档案 56) | **已完成(2026-09-11)**:`scripts/gen-capabilities.cjs`(cron 每日)→ `/opt/dsh/state/capabilities.json` + 共享技能 `platform-capabilities`(agent 可读);实例页「🧭 能力」面板同源展示 | 档案 55:agent 在 16:48 / 18:21 / 22:14 **三轮重复现场探测**,每次撞同样 4 类墙(`/etc` 白名单 / `127.0.0.1` 被 SSRF 拒 / skill 不存在 / 写边界),用户随之三次追问"能力有变化吗" → 平台注入能力说明(可读可写范围、网络边界、工具与技能清单、档位含义) | +| ✅已定 | 平台技能投放现状对齐 | **结论(用户原话):「业务技能要打包进插件里一起安装和使用,不要分开管理」** → 投放方式 = **随功能插件包投放**(见 `交接单/T03` §4.1),**不走**门户技能管理单独投放;平台自描述技能 `platform-capabilities` 照旧共享投放。「是否投放待定」的旧表述作废(档案 55 当时的背景是 `bundled-skills` 为空 → agent 撞 `unknown skill`) | +| ❌复核 | ~~管理类插件化~~(档案 05 PoC-2 ①) | **复核结论:不建议做** —— 门户 `portal.html` 已有完整管理面(服务/密钥/用户/技能/插件/运行环境),admin 在会话内经 `portal-entry` 卡片**整页跳门户**;把 admin 能力搬进实例子域反而扩大权限执行面(与档案 39 收窄方向相反) | +| ✅复核 | ~~插件↔门户鉴权令牌~~(档案 05 PoC-2 ②) | **复核结论:已实现(非令牌方式)** —— 共享会话 Cookie(`Domain=.alotbuy.com`)+ `server.ts` 的 CORS 白名单(`isAllowedOrigin` 允许 baseDomain 及其子域 + `Allow-Credentials`),功能插件 v0.2.1 已生产跑通;再做独立令牌属重复建设 | +| ❌复核 | ~~`portal_ping` 端到端~~(档案 05 PoC-2 ③) | **复核结论:原验收项作废** —— 该工具 `fetch(127.0.0.1:3080)`,而档案 39 已封 `127.0.0.0/8`(实测 BLOCKED)→ 必然失败;若要验证"host 插件给 agent 注册工具",需**另立不依赖 loopback 的探针** | +| 暂缓 | **MCN 工作台插件平台化改造**(档案 27)—— **【暂缓】需先测试插件兼容性**(2026-09-11 决策) | 3 处 P0:① `homedir()/.dsh` → `process.env.DSH_HOME ?? …`(config.js 3 处;`mcp.js` 已是正确写法可对照)② 技能路径硬编码 13 处 → 只报技能名 ③ agent preset(`.agent-presets/mcn/`)随包投放;P1:MCP 预装(现在 `npx myai-mcp`)、DB 重建兜底、技能依赖声明 | +| ✅关闭 | ~~白名单源码安装支持~~(档案 29 §七) | **评估结论:不做(2026-09-11)**:源码安装需让第三方构建脚本以 root 在平台机执行(供应链 + 可复现性 + 性能三重风险),与"平台不执行第三方构建脚本"红线冲突。**替代路径**:admin 本地构建后走「上传 tgz」通道(已有 P0/P1 扫描);如需开启须先满足沙箱构建 + `--ignore-scripts` + 仅 admin + 审计等全部前提 | +| 降级 | B5:给 portal-entry / business-plugins 加加载标记 | **降级为"顺手做"(2026-09-11 决策)**:不解决当前问题、源码不在仓库、且仅提升"升级时排查速度";下次改这两个插件时顺手加(零边际成本) | +| ✅ | ~~熔断 live 实测~~(档案 20 附) | **已完成(2026-09-11)**:由真实故障用户(档案 52)的日志完成实测 —— 退避 1000→2000→4000→8000ms、`restartsInWindow` 1→4、第 **5** 次触发 `crash-loop-circuit-open`(窗口 600000ms / 5 of 5),当日 2 次开断;无需再人为 kill 复现 | +| ✅ **已完成**(2026-09-15) | ~~档案 16 阶段 3/4(实例内「我的技能」)~~ → **= 交接单 `T01`,已归档** | **档案 100**|插件 `business-plugins` **0.3.20 → 0.3.21** 已投放两实例。① **形态修正(09-15)**:由「新增 section」改为**并入既有「功能管理」section 内分组**(依据用户口径「不要分开管理」+ 档案 60 分区命名;**仅入口层合并、机制层分离**)→ 见 `T01 §四 决策 6` 与 **`T01 §九`**;② 实现:技能行(名称/来源/状态/事实/动作)+ zip 拖拽上传 + 同名两阶段替换 + 页内确认弹窗 + 锁定行(共享技能)无动作按钮 + zh/en 46 条词条;③ 验收:`npm run verify` 全绿(含新 `scripts/verify-my-skills.mjs` 34 条断言)、06 §7 三段式全绿、端到端 **19/19**(409 / 400 守卫通过);④ 阶段 4 收口 = 未新增 section + 未改角色补丁 ⇒ 可见性不变。原述:门户 skills.html 仅 admin → 普通用户需实例内入口 | +| ✅ **已消解** | ~~摘除 guest 仍在启用的已下架插件 `@liustack/modlens`~~ | **09-13 07:0x 实测:已无需处理** —— guest `profile/web` 的 `bundles` **与 `node_modules` 均已无 `@liustack/modlens`**(原「已下架却仍启用」的不一致态已不复存在)。历史:该包 09-12 从候选池下架(档案 70 收尾)时曾留在 bundles | +| ❌已复核 | ~~Cookie 域收窄~~ | **不做(2026-09-12 用户决定:把这条待办删掉)** —— 共享 Cookie(`Domain=.alotbuy.com`)是**有意设计**:支撑「功能插件 ↔ 门户」**免令牌鉴权**(配合 `server.ts` 的 CORS 白名单),功能插件 v0.2.1 已生产跑通 → **收窄会破坏该链路,收益为零**;结论见档案 05 PoC-2 ② | + +| 🟡 **待排期** | **T08 集群化的收尾项**(主体与生产切换已完成) | ① `join-worker.sh` 一键装机(现在 join = 装 unit + 起 agent + 注册,三步手工)~~② 隧道服务化~~ ✅ 已收口(本地 20s 定时器自愈,实测 30s 内恢复)③ 集中日志 / metrics ④ `smoke-domain` 定性 ⑤ drop-in 里的 PG 口令建议进一步收权限(现 root 可读)|详见 `交接单/T08 §16.5` | + +### 历史决策记录(保留,均已落地) + +1. **域名决策**(2026-09-08):无域名 → 先解决域名再回来做多用户改造 → 已落地:dsh.alotbuy.com + CF 通配 + 源站 443。 +2. **选型决策**(2026-09-08):dshs 模式 A(account 硬隔离)优先,理由:现成密码注册/审核/网页桌面 + 每用户独立实例;替代(自研 Node 网关 + Docker 每用户、dsh-webui-auth 单实例锁)归档为备选。 +3. **内存边界**:1.8G 内存 / 2 核 → 1-3 人规模(每用户一个常驻 DSH 子进程),扩容前不超 3 用户。 +4. **镜像 v2 / std 技能库归档(2026-09-09)**:旧规划 19 章第十一节的「P1 镜像 v2:内置公共插件 + `std/agents/skills` 标准技能库(rank400 层)」「P1 系统标准技能库内容确认(MCN 脚本创作技能等随镜像/`std/` 分发)」——形态前提是「每用户一个 `dsh-web:` Docker 容器,nginx 按子域/端口分流 + 容器内挂载 `/opt/dsh/std/agents/skills` 兜底层」。**已被 2026-09-08 决策 2(dshs 多租户形态)取代**,无需再做: + - 公共插件分发路径:**profile 层 `dsh plugin --profile add/remove `** 官方机制(pnpm 转发,cordis 补丁自动入 bundles);portal-entry v0.1.0→v0.4.3 走的就是这条。 + - 标准技能库分发路径:**dsh 原生分层**(项目级 `.dsh/skills` rank100 / `.agents/skills` rank200 / 个人 `~/.dsh/skills` rank300) + 后续「管理员预置 profile-skel 与工作区种子」= 新形态下的 std 技能库等价物(原 P3「编导工作区模板」已于 2026-09-11 删除)。 + - 原 dsh-web Docker 容器与镜像(`dsh-web:0.1.2` + `/opt/dsh/dsh-web-0.1.2.tar`)**已全清(2026-09-09)**——确认不回退,回退路径由 `/opt/dsh/backups/` 承担(db.bak 20260909_162739 + profile-web-admin-poc tgz + src-pre-reap tgz)。 + - **结论**:原方案归档废弃,不再构建 dsh-web:0.1.x 增强镜像;后续若需"统一技能/插件基线"通过管理员对 profile 模板操作实现,不走镜像层。 + +--- + +## 附:红线(硬性,2026-09-09 版) + +1. 禁止启动 dsh 时自动获取最新版本;版本升级走独立"升级测试→评估→修复"流程(档案 07)。 +2. 不改官方 dsh 主程序与缓存(/usr/local/lib/node_modules/@deepseek-ai/dsh);扩展只走 profile 层 `dsh plugin` 机制。 +3. client bundle 严禁 `exports.default`(loader ESM interop 取函数 → 无 inject → 注册静默失败;v0.4.0→v0.4.1 实证)。 +4. 服务器 docs 只读归档(root 600);技能/文档同步类红线见各 skill MEMORY 约定。 diff --git a/dsh-server-docs/04-调整方案/01-launch-token自动携带.md b/dsh-server-docs/04-调整方案/01-launch-token自动携带.md new file mode 100644 index 0000000..863d539 --- /dev/null +++ b/dsh-server-docs/04-调整方案/01-launch-token自动携带.md @@ -0,0 +1,25 @@ +## 十六、方案 A 落地:launch/status 自动携带 dsh launch token(2026-09-08 19:30) + +**决策**:用户选方案 A(小改上游,普通用户无需手动拿 token 即可打开聊天界面)。 + +**改动(3 文件,已 git commit 29c8907,本地分叉 patch)**: + +| 文件 | 改动 | +|------|------| +| `src/supervisor/spawner.ts` | `Instance` 接口加 `launchToken?: string` 字段 | +| `src/supervisor/orchestrator.ts` | ① `trackChild` 监听子进程 stdout,正则 `dsh web: http://127.0.0.1:\d+/\?token=([A-Za-z0-9_-]+)` 解析 token 存入 instance;② 新增 `waitForLaunchToken()`(轮询最多 20s 等 token 出现,崩溃/停止即返回);③ `launch()`/`restartMain()` 返回前等待 token | +| `src/web/routes/dsh.ts` | `dshUrl()` 支持可选 token 参数;launch/restart/status 返回的 `url` 均拼接 `?token=`(status 从 `main.launchToken` 取) | + +**验证结果(全链路)**: + +| 步骤 | 结果 | +|------|------| +| launch 返回 | `{launchToken, url: https://admin.dsh.alotbuy.com/?token=...}` ✅ | +| 打开带 token url(带 sid,-L 跟随) | 200,24233B SPA,自动 303 种 dsh-auth cookie ✅ | +| 之后裸开 `/` | 200 ✅ | +| `/api/llm/listProviders` | `ok:true, deepseek-official` ✅ | + +**构建/部署**:`npm run build`(tsc)+ `systemctl restart dshs`。TS 坑:`RegExp.exec` 返回 `null` 非 `undefined`,判空须 `m !== null && m[1] !== undefined`。 + +**admin API Key 已配置**:从旧 `/opt/dsh/.env` 取出 `sk-81c...` 经 `/api/me/keys` 注入(name=deepseek-main,AES-GCM 加密入库,自动触发实例重启生效)。**KEY 为每用户独立**——每个用户需在桌面"管理密钥"各自添加;无全局通用 key(原容器 .env 统一 key 模型已废弃)。 + diff --git a/dsh-server-docs/04-调整方案/02-安全加固-权限边界与DB.md b/dsh-server-docs/04-调整方案/02-安全加固-权限边界与DB.md new file mode 100644 index 0000000..0f58bc9 --- /dev/null +++ b/dsh-server-docs/04-调整方案/02-安全加固-权限边界与DB.md @@ -0,0 +1,37 @@ +## 十七、权限边界实测 + DB 权限安全加固(2026-09-08) + +**问题**:admin 登录后能否访问服务器所有目录?AI 对话窗口新建工作空间时能否输入系统根目录? + +**两层防护模型(答案分两层)**: + +| 层 | 机制 | 结论 | +|----|------|------| +| Web 桌面层 | fs-guard `resolveWithinRoot()`:所有文件浏览/上传/新建工作空间的路径解析被锁在用户自身 home/ws 内,拒绝 `../`、绝对路径、符号链接逃逸 | 输入 `/`、`/root` 等系统路径会被拉回/拒绝;admin 与普通用户同样受限,**不存在"admin 浏览全服务器"入口** | +| AI 进程层 | 每用户 DSH 实例以**降权 uid**(setpriv)运行,该进程可执行 shell/agent 工具,**不受 fs-guard 约束**,能读该 uid 有权限的一切文件 | admin 的实例同样是降权 uid,非 root。实测 uid 114801 边界:`/root`、`/etc/shadow`、`/opt/dsh/.env`、secret.key、dsh db 全部 Permission denied ✅;`/etc/passwd`、`/tmp` 世界可读可写 ✅ | + +**结论**:即便 admin 也无法从 AI 对话触达 root-only 敏感文件;真正的服务器管理走 SSH/宝塔 root 通道,与 AI 会话隔离。这是 account 隔离模式的核心价值,无需额外改造。 + +**⚠️ 实测发现并修复的漏洞:DB 权限 644 → 600** + +| 项 | 详情 | +|----|------| +| 漏洞 | `/var/lib/dshs/dshs.db` 权限 644 root——任意降权账号可 `cat` 拖库 | +| 风险 | 库内含全部用户 `pass_hash`(scrypt)+ `api_key_ref`(AES-GCM 密文)+ uid/sid → 离线爆破口令 + 窃取 key 密文 | +| 修复 | `chmod 600 /var/lib/dshs/dshs.db* /var/lib/dshs/dshs.db*` | +| 验证 | 降权账号读 db → Permission denied ✅;服务 600 下正常(portal 401 为未认证正常响应)✅ | + +**目录级权限收紧(用户反馈"其他用户也能看就不行",2026-09-08 20:30)**: + +普通用户实例同样是降权 uid,盘点发现除主 db 外还有更大泄露面:`/opt/dshs` 整棵树 755、`/opt/dsh/docs/` 内**主库备份**(含全部 pass_hash+key 密文)、`/opt/dsh/backups/` 备份包、`/opt/dsh/users/main` 旧数据残留均 644/755 可读 → AI 会话可拖取。收紧如下: + +| 路径 | 改前 | 改后 | 说明 | +|------|------|------|------| +| `/opt/dsh` | 755 | **700** | 覆盖 docs(含 db 备份)、backups、users/main 残留 | +| `/opt/dshs` | 755 | **700** | root 服务运行不受影响(systemd 无 User= 默认 root) | +| `/var/lib/dshs` | 755 | **711** | 保留 o+x:降权进程需穿过根路径进入自己 home,去读防列表泄露 | +| `.../users` | 755 | **711** | 同上;用户目录本身 700 保持隔离 | + +**验证(uid 114801 实测)**:读 `/opt/dsh/docs/*.md` → denied ✅;列 `/opt/dshs`、数据根 → denied ✅;**仍可进入自己 `ws/mcnworkspace` ✅**;systemd active、portal 200、admin 实例 `/plugins/events` 正常 ✅。 + +**⚠️ 经验固化(Linux 目录权限设计)**:数据根这类"root 服务 + 降权子进程都要访问"的目录,权限用 **711/700**(去 r 留 x),不能收 700——降权进程会因无法穿过根路径而崩溃(EACCES)。子目录各自 700 才是真正的隔离边界。 + diff --git a/dsh-server-docs/04-调整方案/03-统一KEY管理员管控.md b/dsh-server-docs/04-调整方案/03-统一KEY管理员管控.md new file mode 100644 index 0000000..ecc1829 --- /dev/null +++ b/dsh-server-docs/04-调整方案/03-统一KEY管理员管控.md @@ -0,0 +1,23 @@ +## 十八、统一 KEY:管理员统一设置,用户只能使用管理员 KEY 和模型(2026-09-08 20:40) + +**决策**(用户确认):① KEY 存放 = 复用 admin 密钥库(桌面/API 自助换 key 即全局生效);② 用户 dsh 界面自配暴露面先实测再定,本次不做 dsh patch。 + +**改动(commit df5cc84,6 文件 +49/-14)**: + +| 文件 | 改动 | +|------|------| +| `src/web/server.ts` | `resolveApiKey` 忽略入参 userId,改为 `listPublicUsers().filter(role==='admin')` 取 admin 的启用 key——**所有用户 spawn 注入同一把管理员 key** | +| `src/supervisor/spawner.ts` | Spawner 接口新增 `restartAllMains()` | +| `src/supervisor/orchestrator.ts` | LocalSpawner 实现:遍历内存 `mains` 逐个 `restartMain`(单个失败 console.error 不中断广播) | +| `src/supervisor/k8s-spawner.ts` | K8sSpawner 实现:遍历 `db.listInstancesByRole('main')` 逐个重启 | +| `src/web/routes/auth.ts` | `/api/me/keys` GET/POST/select/delete 由 `requireAuth` 改 **`requireAdmin`**(普通用户 403);POST/select 后 `restartAllMains()` 广播(key 是 spawn 时注入 env 的快照,不重启运行实例不刷新) | +| `web/desktop.html` | 非 admin 隐藏"管理密钥"按钮;面板文案改"全局 API 密钥(所有用户共用,仅管理员可改)" | + +**验证(全链路实测)**: +- admin GET `/api/me/keys` → 200,deepseek-main 在列 ✅ +- 注册 testuser(pending)→ admin approve(active)→ 登录 ✅ +- 普通用户 GET/POST `/api/me/keys` → **403 forbidden** ✅ +- **testuser launch DSH → 打开 200 SPA → `llm/listProviders` ok:true deepseek-official** ——证明普通用户无自配 key 也拿到 admin 全局 key ✅ + +**遗留**:用户 dsh 界面内自配 key/模型的能力面未实测(proxy 伪装 loopback 可能放行 dsh Settings)——第二道防线待 P1 实测后再定。testuser 账号保留作普通用户全链路验证种子。 + diff --git a/dsh-server-docs/04-调整方案/04-删除用户功能.md b/dsh-server-docs/04-调整方案/04-删除用户功能.md new file mode 100644 index 0000000..f894524 --- /dev/null +++ b/dsh-server-docs/04-调整方案/04-删除用户功能.md @@ -0,0 +1,18 @@ +## 十九、界面删除用户功能(2026-09-09 07:00) + +**需求**:管理界面在禁用/恢复右侧加删除按钮 → 弹窗输入该用户名相同名称确认 → 删除(admin 不可删)。 + +**改动(commit 4943c8c,7 文件 +106/-2)**: + +| 层 | 文件 | 改动 | +|----|------|------| +| 后端路由 | `src/web/routes/admin.ts` | 新增 `DELETE /api/admin/users/:id`:admin → 409;`supervisor.stop` → `db.deleteUser`(事务)→ `rm users//` 目录 → `userdel dsh-`(OS 账号,失败容忍)→ audit | +| DB 接口 | `src/db/adapter.ts` | +`deleteUser(userId)` | +| SQLite | `src/db/schema→repo.ts` + `sqlite.ts` | 事务按序清 credential_vault/domains/sessions/dsh_instances/audit_log/folder_plugins/workspaces/users(显式删除,不依赖 FK cascade 开关) | +| Postgres | `src/db/pg.ts` | 同序 `withTx` 实现 | +| 前端 | `web/admin.html` + `web/desktop.html` | 非 admin 行尾加 🗑 按钮(红色 ghost);点击 `prompt` 输入用户名比对确认后 `DELETE`;admin 行不渲染按钮 | + +**验证(全部实测)**:① 删 admin 自己 → 409 `cannot_delete_admin` ✅ ② 删 testuser → 200 ✅ ③ 删不存在 → 404 ✅ ④ 残留检查:users/credential_vault/sessions/workspaces/dsh_instances/domains/audit_log 7 表全 0、`users//` 目录不存在、OS 账号 `dsh-2d1f22ffcde04874ba1d` 已删、用户列表仅剩 admin ✅ + +**注意**:`userdel` 账号名与 provision-new-users.sh 同规则(`dsh-` + uuid 去横线前 20 位);OS 账号删除失败只容忍不报错(DB/目录已清,残留仅 uid 占用)。testuser 已作为功能测试对象删除。 + diff --git a/dsh-server-docs/04-调整方案/05-登录直达会话与功能插件化-可行性.md b/dsh-server-docs/04-调整方案/05-登录直达会话与功能插件化-可行性.md new file mode 100644 index 0000000..f56cfb2 --- /dev/null +++ b/dsh-server-docs/04-调整方案/05-登录直达会话与功能插件化-可行性.md @@ -0,0 +1,176 @@ +# 调整方案 05:登录直达会话窗口 + 桌面功能插件化(可行性调研 + PoC) + +> 2026-09-09 | 状态:**PoC 实证完成(插件 v0.1.1 已装 admin profile),进入详细设计** | 红线:不改官方 dsh 主程序与缓存(/usr/local/lib/node_modules/@deepseek-ai/dsh 及依赖),扩展只走 profile 层官方插件机制 + +> **TL;DR**|**结论**:登录直达 + 桌面功能插件化的可行性调研与 PoC:**0.1.2-rc.1 的官方 profile 插件机制可用且完整闭环**(安装/装载/持久/升级/删除全验证),红线零违反。 +> **关键**:给出插件契约(行 `inject:[tools]`、工具注册、client bundle 形态);PoC-2 三项待办已由档案 53/54 复核(②已实现 ③作废)。 +> **状态**:✅ PoC 完成,方案已实施 + +## 一、需求 + +1. 登录成功后自动跳转到 dsh 会话窗口(跳过 desktop.html 中转)。 +2. desktop.html 的功能以**插件形式**整合进 dsh 会话窗口(用户明确:不做 HTML/代理注入,功能=插件)。 + +## 二、现状链路 + +``` +登录 dsh.alotbuy.com(门户, 3080) → desktop.html → 点"启动 DSH" → 编排器 spawn 实例(随机端口) +→ 返回带 ?token= URL → window.open 子域 .dsh.alotbuy.com(dsh web SPA,浏览器认证换 cookie) +``` + +desktop.html 功能归类: + +| 功能 | 归属层 | 能否下沉为 dsh 插件 | +|---|---|---| +| 文件浏览/上传/新建工作区 | 门户 fs 服务 | **dsh 原生已有更强版本**(workspace/attachment/directory-picker 模块),无需移植 | +| 启动/停止 DSH、选启动文件夹 | 门户编排(实例 spawn 前不存在 dsh) | 不能——但"登录直达"后此步自动化,UI 可退化为自动行为 | +| 每文件夹插件勾选(cordis patch) | 门户(spawn 时注入) | 部分可:dsh 设置内有插件清单模块,patch 注入仍由门户做 | +| 管理密钥(统一 KEY) | 门户 DB(spawn 注入 env) | 本质是"管理员在门户配全局 key"——保持门户侧更合理 | +| 用户管理/审核(admin) | 门户 DB | 登录前能力,留在门户管理页 | +| 返回桌面/登出等导航 | — | 会话窗口内需一个"门户入口"插件 | + +## 三、关键调研事实(基于服务器实测 + 官方文档) + +1. **dsh = cordis 微内核**,"一切皆插件";web profile 装配 `@deepseek-ai/dsh-base` + `@deepseek-ai/dsh-web-app` 两个 bundle;profile 层可再装插件(`dsh plugin --profile web add ` → pnpm 装进 profiles/web/node_modules,随 home 走)。 +2. **0.1.2-rc.1(实装版本)已有扩展 API**(grep lib 实证): + - 会话 UI 节点:`ConversationNodeDefinition` + chat.node keyed renderer(dsh-client-ui-conversation/chat) + - 工具注册:`ctx.tools.register`(dsh-tools) + - UI 插槽:client-ui-layout / client-ui-sidebar 的 slots 注册(`client.js` 有注册逻辑) + - 侧栏/面板类 UI 扩展点存在(dsh-client-ui-cordis/lib/client.js 命中) +3. **master 文档(0.1.5-alpha)** 的 extension-cookbook 描述同族 API(tools/钩子/UI 插件/协议驱动),但 `settingsCard`、`ctx.router` 等在 0.1.2-rc.1 未命中 → **以 0.1.2-rc.1 实际 API 为准**,必要时做最小 PoC 验证。 +4. web UI 原生能力(已含在 bundle):workspace 管理(dsh-api-workspace-controller / dsh-client-ui-workspace)、目录选择(directory-picker-browse/native)、消息附件上传(attachment)、设置/模型(settings-*)、插件清单(plugin-inventory)→ **desktop 的"文件/工作区"功能 dsh 原生覆盖**。 +5. 编排隔离:每用户实例是独立降权 uid 进程;desktop 管理密钥在门户 DB;实例 env 由门户 spawn 时注入(统一 KEY 已落地)。 + +## 四、方案选项 + +### 方案 A:登录直达会话 + 最小门户入口插件(推荐第一版) + +- **登录直达**:门户登录成功 → 调 `/api/dsh/launch`(folder 取用户默认/上次工作区,自动等 launchToken)→ 302/前端跳转到带 token 的子域 URL。desktop.html 保留为管理入口(/desktop.html),会话窗口内不再需要"启动"按钮。 +- **门户入口插件**:开发 1 个 cordis 插件(装进每个用户 web profile): + - 侧栏/插槽注册一个"门户"入口(返回桌面/管理页、账号信息、登出); + - 视需要注册 1 个会话节点(如"门户通知/操作结果"卡)。 + - 插件经 `ctx.router`/客户端桥接调门户 API(跨进程,127.0.0.1:3080)→ 需门户签发**用户级内部令牌**注入 env(复用统一 KEY 注入点,新增 `DSHS_USER_TOKEN` 一类 env)。 +- **工作量**:门户(launch-直跳 + 令牌下发 + 桌面瘦身)中;插件(最小 cordis 插件 + client slot 注册)中。风险:0.1.2-rc.1 插件装载方式与 slot 具体 API 需 **PoC 先行验证**(写 1 个最小插件装入 admin profile 实测)。 + +### 方案 B:全面插件化(把桌面功能逐项做成 dsh 插件) + +- 文件/工作区直接用 dsh 原生(不做); +- 自定义插件承载:门户控制台面板(重启实例/回桌面/状态)、上传对接门户(若原生 attachment 不满足); +- 需要更多 slot/API 探索 + 每个功能 PoC。**依赖方案 A 的插件骨架跑通后增量做**。 + +### 方案 C:维持桌面 + 仅改登录跳转 + +- 只做"登录自动进入会话",桌面功能不动(需回桌面时点返回)。零插件开发,1-2 天可落地;但不符合"功能整合进会话窗口"的长期目标(可作 A 的阶段性第一步)。 + +## 五、风险与未决项 + +1. **PoC 未做**:0.1.2-rc.1 下"自定义插件装入 profile"的确切姿势(dsh plugin add vs 直接 patch cordis.yml dependencies)与侧栏 slot 注册 API —— 必须先最小插件实证,避免方案空中楼阁。 +2. **插件↔门户鉴权**:实例内插件调门户 API 的令牌方案(推荐门户按用户签发短效内部令牌,经 spawn env 注入,插件仅能访问本用户资源)。 +3. **升级兼容**:插件 API 随 dsh 版本演进(master 已见 settingsCard/router 新增);插件只依赖 cordis 稳定层,主程序升级时验证。 +4. **KEY/用户管理留在门户**:不进会话窗口(登录前/编排层能力),方案 A/B 均如此,需与预期对齐。 +5. dsh 原生 settings/插件清单在伪装 loopback 下可打开(已知),是否对普通用户放开模型设置仍受统一 KEY 语义约束(用户不可自配 key;provider 只认注入 key)。 + +## 五·补、架构答疑:功能统一性 / 数据存放 / 是否需要独立数据库(2026-09-09 实测) + +> 用户提问:"实现后所有用户登录都是统一功能吗?不同用户的操作数据如何存放?是否需要单独的数据库支撑?" —— 以下基于 admin 实例落盘实测(home 2.3M:sessions 212K / storages 32K / ws 工作区)。 + +**Q1:所有用户登录后是统一功能吗?** + +功能框架统一,仅角色与内容存在差异: + +| 层面 | 是否统一 | 说明 | +|---|---|---| +| 界面与插件集 | ✅ 统一 | 所有用户登录直达同一 dsh 会话窗口(web profile 同版本、同批插件),不再经 desktop.html 中转 | +| 角色能力 | 差异(2 级) | admin:用户审核/管理与全局 KEY 管理(保留在门户管理台);普通用户:无管理入口 | +| 内容数据 | 按用户隔离 | 各自工作区/会话/文件互不可见(见 Q2) | + +**Q2:不同用户的操作数据如何存放?** + +一用户一目录 + 一 OS uid + 独立实例,三层隔离(admin 实例实测结构): + +``` +/var/lib/dshs/users// +├── home/ # dsh home,属主 = 该用户降权 uid(目录 0700) +│ ├── profiles/ # 装配的 web profile +│ ├── sessions/ # 会话 JSONL,按工作区分目录(如 --var-lib-...-users--ws-mcnworkspace--) +│ ├── settings.yaml +│ └── storages/ # session_projcache / workspace.json 等(32K 级) +└── ws/ # 用户工作区文件(如 ws/mcnworkspace) +``` + +- 会话/工作区 = dsh 原生**文件存储**(每用户 home 下 JSONL + 目录),非集中数据库; +- 实例 = 每用户独立降权 uid 进程 + 随机端口 + 各自 launchToken; +- 隔离 = 目录 0700 + 独立 OS uid + 独立实例进程三重保障(既有安全加固成果)。 + +**Q3:是否需要单独的数据库支撑?** + +**不需要。** 三点理由: + +1. 门户 SQLite(dshs.db,~90KB)只存元数据(账号/审核/密钥密文/实例注册),不随会话内容膨胀;要更大规模已有 `DB_URL` 切 Postgres 的通道; +2. dsh 会话、工作区、文件数据全部是文件存储(每用户目录 + JSONL),不经数据库; +3. 用户间隔离由 目录/uid/实例 在账号隔离层解决 —— 加库既不增强隔离、也不承载会话内容,纯增复杂度。 + +## 六、建议路线(待确认) + +1. **PoC(半天~1天)**:最小 cordis 插件(注册侧栏入口 + 1 会话节点 + 调门户健康接口),装入 admin web profile,验证装载/升级/删除路径与隔离账号可加载性。 +2. 基于 PoC 结果定方案 A 详细设计(登录直达流程 + 令牌 + 插件清单),逐项评审后实现。 +3. 门户桌面瘦身为"管理台"(审核/密钥/备份状态),普通用户登录即会话。 + +**决策点(2026-09-09 已拍板)**:① 接受 **PoC 先行**(已执行,见七)→ 登录直达 → 门户入口插件;② **桌面只留 admin**(普通用户登录即会话,desktop 退化为管理台);③ KEY 与用户管理**要搬进会话窗口**(与推荐项不同——依赖 PoC 骨架与令牌方案,增量做管理类插件)。 + +## 七、PoC 实证结论(2026-09-09,admin profile 实测) + +**结论先行:0.1.2-rc.1 的官方 profile 插件机制可用且完整闭环**(安装/装载/持久/升级/删除全验证),红线零违反——全程未触碰全局主程序与缓存,只经 `dsh plugin`(pnpm 转发)改 profile 自身 package.json + node_modules。 + +### 7.1 机制要点(源码级实证) + +- `dsh plugin --profile ` = pnpm 转发器(lib/plugin-*.js):profile 下跑 `pnpm `,装完后 reconcile——依赖包若声明 `dsh.bundle.patch` 自动追加进 package.json `dsh.profile.bundles` 层;被移除/失去声明的自动出层。**bundle 层的补丁 = cordis.yml 格式补丁**(与 cordis.patch.yml 同构:行 override + `insert:` 列表)。 +- 数据目录定位:`DSH_HOME`(门户 spawn 时注入 `.../users//home`)→ profile 目录 = `$DSH_HOME/profiles/`(dsh-app-boot `resolveProfileDir`)。 +- 行插件(host 面)模块契约:包 main 导出 `apply(ctx)`(可同时 `export default`);**要用某服务必须声明**——行级 `inject: [tools]`(cordis 报错原话:`cannot get property "tools" without inject`)或函数内 `ctx.inject([...], cb)`。官方样例:`@deepseek-ai/dsh-client-ui-conversation`(apply + ctx.inject)、webserver 行 `inject: [webStartup]`。 +- 工具注册契约:`import { defineTool } from '@deepseek-ai/dsh-tools'` → `ctx.tools.register(defineTool({name, description, parameters, output:{schema}, isConcurrencySafe, async execute(args, exec)}))`(dsh-tool-web 同款)。 +- UI(浏览器面)是**双面包**:`exports["./client"]` + package.json `dsh.client{platform,inject}`;0.1.2-rc.1 已实证插槽体系:`ctx.slots.register/inject`,sidebar 定义 5 槽(`sidebar.brand.mark/name`、`sidebar.workspaces`、`sidebar.settings`、`sidebar.footer.action`[list]),官方占位用 `ctx.slots.inject("sidebar.brand.mark", () => ctx.slots.inject("sidebar.brand.name", function*(){ yield ctx.slots.register({name}, Comp) }))`。**注意**:官方 client.js 是构建产物(`window.__ModuleLoader__.load({id, factory})` 格式,运行时按 `/plugins//client.js` 装载)——第三方 UI 插件需产出同格式 bundle,属 PoC-2 工作量。 + +### 7.2 实测记录 + +插件:`@dsh-local/portal-entry`(v0.1.1,源码 = 本档案同目录 `poc/portal-entry/`,服务器 `/opt/dsh/docs/04-调整方案/poc/`,staging `.../ws/poc/`),host 行:`id=portal-entry, inject:[tools]`,行为:boot 写 marker + 注册 `portal_ping` 工具(探测门户 127.0.0.1:3080 连通性)。 + +| 验收项 | 结果 | +|---|---| +| 环境准备 | 服务器无 pnpm → `npm i -g pnpm@9`(/usr/local/bin,不影响 dsh) | +| 安装 | `npm pack` 出 tgz → 以 uid114801(setpriv, HOME=ws, DSH_HOME=home)`dsh plugin --profile web add ` → +21 包,bundles 自动含 portal-entry | +| 装载 | 重启实例(kill → watchdog 拉起新 pid)→ marker 落盘 `$DSH_HOME/.dsh-poc-portal-entry.log` | +| 工具注册 | v0.1.0 无 inject → 报错 `without inject`;v0.1.1 行级 `inject:[tools]` → `tool portal_ping registered` | +| 持久化 | 3 次重启均重新 apply + 注册(pid 131059/131310/131563) | +| 升级 | v0.1.0→v0.1.1 `add` 更新 dep 生效,补丁变更随重启生效 | +| 删除 | `remove @dsh-local/portal-entry` → bundles 还原 base+web-app,重启后 marker 不再增长,实例正常 | +| 实例健康 | watchdog 崩溃拉起全程正常;SPA root 401(认证 fence 如常) | +| 红线 | 未触碰 /usr/local/lib/node_modules/@deepseek-ai/dsh 任何文件与缓存;唯一全局新增 = pnpm | + +### 7.3 结论与 PoC-2 清单 + +- **方案 A 骨架成立**:门户侧每用户 spawn 前给 profile `dsh plugin add`(或预置模板 bundle)即可让所有用户具备同一插件集(呼应"统一功能");令牌经 spawn env 注入(复用统一 KEY 注入点,如 `DSHS_USER_TOKEN`)。 +- **PoC-2 已部分落地(2026-09-09,portal-entry v0.2.0)**:client 面(`exports["./client"]` + `dsh.client.platform=web`)boot graph 实证入列——`__DSH_BOOT__` 单入口 `/plugins/??@dsh-local/portal-entry/client.js&rev=…` 200 且内容为 `window.__ModuleLoader__.load` 包裹的原始 client.js;侧栏入口 = `ctx.slots.inject("sidebar.footer.action", …)+ctx.slots.register`(镜像 brand-official 占位姿势);实例带 client 插件稳定运行(decl/组合校验通过)。 +- **PoC-2 UI 修复(v0.2.0→v0.3.1,2026-09-09)**:用户浏览器验收反馈"会话左下角设置区无管理入口"。服务端 graph/bundle 全部健康但 UI 不渲染——对照官方 bundle/package.json 源码定位**两层 inject**:① **bundle 内 `exports.inject=["slots"]`**(cordis service 注入,brand-official 明写;v0.3.0 补齐)② **package.json `dsh.client.inject`**(client 包级依赖顺序,boot entry inject 数组即来自此——brand-official 声明 `[renderer, sidebar]`、sidebar 声明 6 个依赖;v0.3.1 补 `["@deepseek-ai/dsh-client-ui-sidebar"]`,缺它则 loader 不保证 sidebar 先 apply,注册落在未就绪的槽位注册表上、渲染为空)。两处都缺一不可。重装后 boot entry 实证:`{"id":"@dsh-local/portal-entry", ..., "inject":["@deepseek-ai/dsh-client-ui-sidebar"]}`(rev 08c5208629b5881e-45)。 +- **UI 入口终章(v0.3.2→v0.4.1,2026-09-09 浏览器全链路验收通过)**: + - **v0.3.2** 补 list-slot register 的 `id` + callback 形式后,sidebar `footer.action` **依然不渲染**(连官方 cordis-panel entry 的 footerActions 也为空)→ 结论:0.1.2-rc.1 sidebar 消费端实际不消费该 list 槽,footer.action 方案**废弃**。 + - **v0.4.0** 转注册到设置面板官方分区槽 `settings.section`(id:"platform",通用设置/模型/插件/Agent预设 同槽,SettingsRoot renderSlot 实证消费)——服务端 bundle 健康(rev 80c977631f11afa1-45,5/5 markers)但浏览器左侧导航仍无「平台管理」。 + - **v0.4.1 根因(浏览器 console 抓 err 对象 description 定位,P0 教训)**:bundle 多写一行 `exports.default = apply;` → loader ESM/CJS interop 取 `module.exports.default`(=纯函数)为插件主体 → 命中 `typeof plugin === "function"` 分支(函数形式**无 inject 声明位**)→ apply(ctx) 访问 `ctx.slots` 抛 `cannot get property "slots" without inject`(堆栈特征 `at new apply`)。**修复**:删 `exports.default`(官方 bundle 只导出 `apply`+`inject`,均无 default)+ `label` 改函数形式 `() => "\u5e73\u53f0\u7ba1\u7406"`(与官方 `() => t("nav")` 一致)。 + - **v0.4.1 浏览器验收全通过**(沙箱外 Chrome + 原始 CDP 注入 sid + token URL 直达):设置面板左侧导航出现「**平台管理**」分区 → 卡片渲染(平台门户 https://dsh.alotbuy.com + 「打开管理台」window.open → /desktop.html + 「退出登录」整页跳转 → 门户同源 `GET /logout`:清 sid cookie + DB 删 session → 302 登录页,document.cookie 验证为空);console 仅 `[portal-entry] apply RUN`,无 registration failed。 + - 附带红线新增:**client bundle 严禁 `exports.default`**(见 README 红线 3 / 平台 MEMORY)。 +- **UI 颜色修复 v0.4.2→v0.4.3(2026-09-09,浏览器验收通过)**: + - **问题**:v0.4.1 验收后浏览器看平台管理分区按钮——「打开管理台」文字/背景同色(皆 `rgb(249,250,251)`,浅色面板上不可见),深色主题下肉眼几乎不可点。 + - **v0.4.2 尝试失败(var() fallback 修复)**:把 rowButton 文本色改为 `var(--dsw-alias-label-primary, #1f2329)`、背景 `var(--dsw-alias-button-primary-fill, transparent)`——本想靠 fallback hex 解决深色主题变量解析问题。**根因**:实测 `--dsw-alias-label-primary` 与 `--dsw-alias-button-elevated-fill` 在深色主题下**都被定义为 `#f9fafb`**(`getComputedStyle` 实测值),CSS 变量优先级 > fallback → 永远命中变量值 → 文本/按钮背景同为浅色 → 仍然不可见。fallback 永远不生效。 + - **v0.4.3 修复(hardcode 颜色)**:放弃 CSS 变量,全部写死 hex——rowButton 文本 `#f9fafb`、按钮边框 `#43464d`(透明底)、「打开管理台」`bg=#4f6ef7 borderColor=#4f6ef7 color=#ffffff`(蓝底白字)、「退出登录」`color=#ff4d4f borderColor=#ff4d4f`(红字透明底)。同时 rowButton 内部加保护:若 caller 传入的 `style.color/background` 含 `var(`,强制替换成 hex(防止上层再传入变量化样式)。 + - **v0.4.3 部署后浏览器仍见旧 bundle(根因 = 实例未重启)**:v0.4.3 tgz (`portal-entry-0.4.3.tgz`) 部署于 **16:40**(含 `pnpm install` 把顶层 symlink 切到 `@dsh-local+portal-entry@...+0.4.3.tgz`、lib/client.js md5 `e6bc9c8b...` 含 4f6ef7×2/f9fafb×5/43464d×1 标记);但运行中的 dsh 实例 **pid 147068 启动于 16:32**(port 37817,admin 用户 web profile),整个会话未重启。dsh 实例的 client bundle 在**实例启动时打包并生成 `&rev=…` 清单缓存**到自身进程,浏览器 `https://admin.dsh.alotbuy.com/plugins/??...@dsh-local/portal-entry/client.js&rev=259e7a43027c` 始终由**老进程**响应——磁盘换 tgz/symlink 不重启实例 = 浏览器视角的 bundle 不变(**核心教训,固化为运维锚点**)。 + - **诊断取证**:CDP `Runtime.evaluate` 取按钮 `b.style.color/backgroundColor`(**inline** vs computed 区分)——v0.4.3 部署后 inline 仍是 `var(--dsw-alias-label-primary, #1f2329)`、computed `rgb(249,250,251)`(与 v0.4.2 一致),反推加载的 bundle 含 var() → 老 bundle;与磁盘 `lib/client.js`(e6bc9c8b...)内容不符。 + - **实例重启(编排器控制面 API)**:admin.dsh.alotbuy.com 是 **admin 用户的实例子域**(nginx 通配符 `*.dsh.alotbuy.com` → 127.0.0.1:3080,3080 proxy 层按 Host 转发到 admin 用户实例 37817),所以 `fetch('/api/dsh/restart', ...)` 会被 proxy 拦截转发到实例(实例无该路由 → 404 `not found`)。**正确路径**:`curl -X POST http://127.0.0.1:3080/api/dsh/restart -H 'Host: dsh.alotbuy.com' -H 'Cookie: sid=' -d '{"command":""}'` ——以主域 Host 直连 3080 编排器控制面(`/opt/dshs/lib/web/routes/dsh.js:81` `POST /api/dsh/restart {requireAuth}` → `supervisor.restartMain(user.id)` + `spawnWatchdog`)。admin sid 通过 `/opt/dshs/mksess.cjs` 生成(10 分钟有效期,poc-curl2 UA)。 + - **v0.4.3 浏览器验收全通过**:重启后实例 **port 37817 → 45543**(id `49fda683-0b65-4d29-8853-c26ec2b13322`,新 launchToken `d5bSX5a-cutMwDjN3bN0fAVY7c2VDbbuel-QXgi_RVM`),bundle rev `259e7a43027c → 936d9012841a`。导航 `https://admin.dsh.alotbuy.com/?token=`(`Page.navigate` 而非 `Page.reload`——SPA reload 卡死 bug)→ 复用 B0A71377 tab(用户偏好复用标签页)+ `Network.setCookie` 注入 sid → 点 sidebar「设置」→ 左侧导航含「**平台管理**」→ 点开 → 「打开管理台」按钮 inline/computed 一致 `color=#ffffff bg=#4f6ef7 borderColor=#4f6ef7`;「退出登录」`color=#ff4d4f bg=transparent`,对比 v0.4.2 白字白底完全不可见 → **深色主题下清晰可点**(截图 `portal-043-platform-clean.png`)。console 无 registration failed/无 err。 + - **固化到运维锚点(dsh-change-workflow / MEMORY)**: + ① **plugin tgz 更新后必须重启实例**(kill `pid` 由 watchdog 拉起,或 `POST /api/dsh/restart` 主域 Host);**不可只换磁盘文件 + symlink**。 + ② **浏览器 bundle rev 验证**:CDP 取 `performance.getEntriesByType('resource')` 过滤 portal-entry,与重启前对比。 + ③ **inline vs computed 双取**:判定加载的 bundle 版本——inline 含 `var(...)` 即旧 bundle(v0.4.2 特征);全部 hardcode hex 即新 bundle(v0.4.3 特征)。 + ④ **admin.dsh.alotbuy.com = admin 实例子域**,调编排器控制面 API 必须以主域 Host 走 3080,**不能直接子域 fetch**。 + ⑤ **Page.navigate 替代 Page.reload**(SPA reload 路径 root 持续 0 卡死)。 + ⑥ **截图前 Page.bringToFront**(后台 tab 的 captureScreenshot 会挂起超时)。 +- **PoC-2 待办**:① 管理类插件(KEY/用户管理搬进会话窗口)基于令牌方案增量做 ② 插件↔门户鉴权令牌设计落地 ③ `portal_ping` 工具在真实会话里端到端调用(admin 登录后让 agent 执行一次)。 +- 备份:`/opt/dsh/backups/profile-web-admin-poc-20260909.tgz`(装插件前 profile 快照);回滚 = 解包覆盖 profiles/web + 重启。 diff --git a/dsh-server-docs/04-调整方案/06-登录直达会话窗口.md b/dsh-server-docs/04-调整方案/06-登录直达会话窗口.md new file mode 100644 index 0000000..57fecf7 --- /dev/null +++ b/dsh-server-docs/04-调整方案/06-登录直达会话窗口.md @@ -0,0 +1,30 @@ +# 调整方案 06:登录直达 DSH 会话窗口(v1 + v2 已上线) + +> 2026-09-09 | 状态:**已上线(commit 6f63108 + e39628a)** | 关联:05-登录直达会话与功能插件化(方案 A 第一步) + +## 需求 + +登录成功后直接进入自己的 dsh 会话窗口,跳过 desktop.html 中转。v1:普通用户直达、admin 进管理台;**v2(决策②补充):admin 也统一直达会话**,desktop.html 作为管理台保留(侧栏「门户/管理台」入口或直接访问 /desktop.html)。 + +## 改动点 + +| 文件 | 改动 | 说明 | commit | +|---|---|---|---| +| `src/web/routes/dsh.ts` | 新增 `POST /api/dsh/enter` | v1 按角色分流:admin → `{kind:'desktop'}`;active → 会话 URL(运行中复用 launchToken,未运行 ws 根 launch,`AlreadyRunningError` 兜底) | 6f63108 | +| `web/login.html` `web/index.html` | 按角色分流跳转 | admin → desktop;非 admin → enter 直达 | 6f63108 | +| `src/web/routes/dsh.ts` | **enter 取消 admin 分支** | admin/active 一视同仁,均返回 `{kind:'session',url}`(放行角色检查 = active\|admin,其余 403) | e39628a | +| `web/login.html` `web/index.html` | **去掉角色分流** | 登录/首页统一直达会话;enter 失败时 admin 显示「前往管理台」链接兜底 | e39628a | +| `web/desktop.html` | init() 顶部拦截 | 非 admin 访问桌面 → enter 直达会话;admin 直接访问 /desktop.html 正常进管理台 | 6f63108 | + +## 验证(curl 直连 127.0.0.1:3080 实测) + +1. v1:admin enter → `{"kind":"desktop"}`;pocuser 全链路(注册→审核→登录→enter→`https://pocuser.dsh.alotbuy.com/?token=…` 独立实例拉起);幂等复用;删除 API 零残留 +2. **v2(e39628a):admin enter → `{"kind":"session","url":"https://admin.dsh.alotbuy.com/?token=…"}`,实例自动 spawn ✅** +3. 服务:tsc 零报错、systemctl active、portal 200 + +## 说明与后续 + +- 门户重启不自动拉起运行中实例(按需启动);实例由 enter/launch 触发拉起。 +- 会话窗口内的「门户/管理台」入口 = portal-entry 插件 client 侧栏按钮(档案 05 PoC-2,v0.2.0,点开主域 /desktop.html)。 +- 桌面 API 目前 user-scope(仅自己目录),页面层已对非 admin 拦截;彻底禁普通用户桌面 API = 方案 A 加固项。 +- 后续:KEY/用户管理搬进会话窗口的管理类插件、插件↔门户令牌方案(05 方案 A 详细设计)。 diff --git a/dsh-server-docs/04-调整方案/07-红线-禁止dsh自动获取最新版本.md b/dsh-server-docs/04-调整方案/07-红线-禁止dsh自动获取最新版本.md new file mode 100644 index 0000000..1f40790 --- /dev/null +++ b/dsh-server-docs/04-调整方案/07-红线-禁止dsh自动获取最新版本.md @@ -0,0 +1,43 @@ +# 调整方案 07:红线 — 禁止 dsh 自动获取最新版本 + 版本升级流程 + +> 2026-09-09 | 状态:**生效**(配置已落地 + 核查记录)| 适用:dshs + dsh 0.1.2-rc.1 生产平台(47.77.182.89) + +## 一、红线(用户 2026-09-09 提出,原文) + +> **禁止启动 dsh 时自动获取最新版本**,避免版本更新带来的功能差异导致运行失败。后续 dsh 版本更新时,需要**单独建立项目升级测试评估和修复**后,再整体更新到现有项目。 + +含义拆解: + +1. 任何"启动/命令时联网拉取 dsh 最新版本"的行为一律禁止——防止运行环境在某次启动后被悄悄切到新版本(功能差异 → 运行失败)。 +2. dsh 版本升级 = **独立升级项目**:先在隔离环境做 升级测试 → 影响评估 → 差异修复,验证通过后才允许整体更新生产项目。 +3. 红线不阻止日常 dshs 平台层自身功能改造(那是本项目源码,不在此列)。 + +## 二、自动获取版本向量核查(2026-09-09,服务器实测) + +| 向量 | 是否自动查版本 | 核查结果 | 处置 | +|---|---|---|---| +| dsh 主程序启动(`dsh --profile web`) | ❌ 不会 | grep lib 全部源码:无 checkForUpdate / update-notifier / registry latest 拉取逻辑(零命中) | 无需处理(红线天然满足) | +| npm 命令 | ⚠️ 会 | npm update-notifier 默认 **true**,任何 npm 命令联网查 latest 并打印横幅 | `/usr/local/etc/npmrc` 追加 `update-notifier=false` ✅(npm/pnpm 双生效,实测均 false) | +| pnpm 命令(含 `dsh plugin` 转发) | ⚠️ 会 | 此前已见横幅 "Update available 9.15.9 → 12.3.4"(仅提示,不自动升级) | 同上,同一条全局配置已覆盖 | +| 门户 spawn 实例 | ❌ 不会 | spawner 仅注入 DSH_HOME/DEEPSEEK_API_KEY 等 env,无版本检查 | 无需处理 | +| 门户自身升级 | — | dshs 由本项目 git 管理,手动构建部署 | 遵守第三节流程 | + +**结论**:dsh 本身启动不查版本;真正会联网查版本的只有 npm/pnpm 的 update-notifier(横幅性质,不改变版本),已全局关闭。该配置在 `/usr/local/etc/npmrc`,对所有用户(root + 各 dsh-uid)生效。 + +## 三、版本升级流程(红线执行协议) + +| 阶段 | 动作 | 产出/门禁 | +|---|---|---| +| 0 触发 | 官方发布新版本 / 修复需要升级时,先建独立任务,**不得直接在现网执行升级** | 任务单 + 本档案更新 | +| 1 升级测试(隔离) | 独立项目/目录或临时 profile 安装目标版本,跑冒烟:profile 启动、插件装载、会话、工具、portal 联动 | 测试记录(通过/失败清单) | +| 2 影响评估 | 对比当前 0.1.2-rc.1 与目标版:breaking API、profile/bundle 兼容、自定义插件(portal-entry 等)API 依赖面、schema/DB 变化 | 评估表:差异项 → 影响 → 处置 | +| 3 差异修复 | 对受影响的自定义插件 / 平台代码做适配修复,在隔离环境回归 | 修复清单 + 回归通过 | +| 4 整体更新 | 评估+修复全部完成后,备份 → 更新全局 dsh(含主程序与 bundle)→ 更新自定义插件 → 全量回归(所有用户实例) | 备份归档 + 回归记录 | +| 5 回滚预案 | 任一步失败可回滚:备份清单见 `/opt/dsh/backups/` + profile 快照机制(如 profile-web-admin-poc-*.tgz 同法) | 回滚演练确认 | + +**当前版本基线**:dsh 0.1.2-rc.1(全局 `/usr/local/lib/node_modules/@deepseek-ai/dsh`);dshs master@6f63108;自定义插件 @dsh-local/portal-entry v0.1.1(装于各 profile bundles)。 + +## 四、相关 + +- 档案 05 的既有红线仍有效:不改官方 dsh 主程序与缓存,扩展只走 profile 层官方插件机制(dsh plugin add)。本红线是其"版本侧"补充——**两者都禁改主程序,本红线进一步禁止隐式换版本**。 +- 备份:本次仅改 `/usr/local/etc/npmrc`(追加一行,原内容为空)。 diff --git a/dsh-server-docs/04-调整方案/08-实例常驻上限与单活跃会话.md b/dsh-server-docs/04-调整方案/08-实例常驻上限与单活跃会话.md new file mode 100644 index 0000000..2389e13 --- /dev/null +++ b/dsh-server-docs/04-调整方案/08-实例常驻上限与单活跃会话.md @@ -0,0 +1,98 @@ +# 08-实例常驻上限与单活跃会话(idle-reap + last-wins,2026-09-09 落地) + +## 背景与动机 + +- 平台:dshs(本地后端,单机)托管多租户 dsh 0.1.2-rc.1,每账号一个常驻 main 实例。 +- **资源约束(实测)**:机器总内存 1.87GB;单个 dsh web 实例 RSS≈187MB(约占整机 10%)。 + 现状"无限常驻 + 无回收"下,约 8~10 个并发账号即接近 OOM——**多用户上线前必须先加常驻上限与回收**。 +- 对话/工作区数据全部磁盘持久化(`$DSH_HOME/sessions/`、`storages/`、`ws/`),回收进程**不触碰数据**, + 冷启恢复后数据完整——这是回收机制成立的前提。 + +## 用户决策(2026-09-09) + +1. **确认实现**:最大常驻数量 + 自动回收(默认 `cap=4`、空闲 `TTL=7d`、扫描 `60s`,均可 env 覆盖)。 +2. **同账号多设备 = 单活跃会话(last-wins)**:不支持多浏览器同时访问;**只允许最后登录的浏览器**访问, + 先前登录的浏览器立即失效(API/子域 401),重新访问需再次登录。 + +## 实现(commit 928dde1,dshs) + +### A. 单活跃会话(last-wins) + +| 文件 | 改动 | +|---|---| +| `src/web/routes/auth.ts` | `/api/auth/login` 在 createSession **前** `deleteUserSessions(user.id)`——新登录作废该账号全部旧 sid | + +- 旧 sid 一旦删除,所有 `requireAuth` 路由与 proxy 子域鉴权(`resolveSubdomainAccess` 查 DB)立即返回 401。 +- 同一浏览器多标签页共享 cookie = 同一 sid,不受影响;按 **sid 粒度**判定,非按连接数。 + +### B. 实例常驻上限 + 空闲回收(本地后端 idle-reap) + +| 文件 | 改动 | +|---|---| +| `src/config.ts` | 新增 3 配置(默认值 + env):`maxIdleInstances=4`(0=禁用 cap)、`instanceIdleTtlSeconds=604800`(0=禁用 TTL)、`idleReapIntervalSeconds=60` | +| `src/supervisor/spawner.ts` | `Spawner` 接口新增 `touch(userId)`(活跃信号) | +| `src/supervisor/orchestrator.ts` | `LocalSpawner`:`lastActive` Map + `touch()`;构造时按配置启 reaper 定时器(unref);`reapOnce()` 规则见下;`stop()` 清 lastActive;`teardown()` 清定时器 | +| `src/supervisor/proxy.ts` | `resolveSubdomainAccess` 鉴权通过后 `touch(userId)`(子域 HTTP + WS 两入口全覆盖);legacy `/u/:slug/dsh` 路由同步 touch | +| `src/web/routes/dsh.ts` | `/api/dsh/enter` 开头 `touch(userId)`(进入工作区即活跃) | +| `src/supervisor/k8s-spawner.ts` | `touch` no-op(k8s 后端走自身 reconcile Phase 4 会话回收,不依赖流量信号) | + +**reapOnce 规则(每次 tick 按顺序执行)**: +1. **空闲 TTL 回收**:`lastActive` 距今 > TTL 的 main → `stop()`; +2. **数量上限(LRU)**:剩余常驻数 > cap → 按 `lastActive` 升序停掉差额; +3. 每次回收在 stderr 打日志:`[idle-reap] stop (原因; idle Ns)`(journald 可见)。 + +**活跃信号来源**:proxy 真实流量(用户每次点击/WS 消息都会 touch)+ enter + launch/restartMain。 + +### 配置速查(env,前缀 DSHS_) + +| env | 默认 | 说明 | +|---|---|---| +| `DSHS_MAX_IDLE_INSTANCES` | `4` | 常驻实例上限(0=不限制) | +| `DSHS_INSTANCE_IDLE_TTL` | `604800`(7 天) | 无流量空闲多久回收(0=禁用) | +| `DSHS_IDLE_REAP_INTERVAL` | `60` | 回收扫描周期(秒) | + +## 验证记录(2026-09-09 实测) + +### 单活跃会话 +``` +login#1 → 200 sid1;login#2 → 200 sid2 +me(sid1) = 401(被顶下线 ✓) me(sid2) = 200(活跃 ✓) → PASS +``` +(throwaway 用户经真实 /api/auth/login 双登录验证,用完即删) + +### idle-reap(env 覆盖 TTL=20s / interval=5s 实测后还原) +``` +enter admin → 冷启实例 pid 138036 +[journald] [idle-reap] stop cce6d1cd... (idle>20s; idle 23s) ← 到点回收 +进程数 0;sessions/ 目录 2 条保留、portal-entry marker 保留 ← 数据零丢失 +re-enter → 新实例拉起(port 35515)正常 ← 冷启恢复 +env 还原 → 0 覆盖残留(默认 cap=4 / ttl=7d / interval=60s) +``` + +## 各场景行为 + +| 场景 | 行为 | +|---|---| +| 用户每天活跃 | 常驻不回收,秒进 | +| 用户 >7 天不用 | 空闲回收(数据保留);重登冷启 5-6s | +| 并发 >4 | 最早不活跃的被 LRU 回收(admin 长期不用同样会被回收) | +| 回收瞬间用户点开 | enter 冷启兜底,数据无损 | +| 同账号 2 台电脑 | **同一实例同一对话空间**;后登录顶掉先登录(旧 sid 401) | +| 崩溃中实例 | 不参与回收(崩溃重启 backoff 机制管) | +| 门户服务重启 | 常驻实例全部消失(现状);数据在,用户访问自动冷启 | + +## 事故记录:代理 keep-alive 半开池挂起(2026-09-09,commit 6336c53) + +- **现象**:部署重启后用户访问 admin.dsh.alotbuy.com 一直加载;门户 3080 秒回、实例直连秒回,但**走 proxy 转发的请求全部挂起**(日志 req 只有 incoming 无 completed)。 +- **根因**:proxy 进程(门户)的 HTTP keep-alive 连接池在**实例多次重启**(12:24→12:30→12:40)后残留指向已回收端口的半开连接;新请求复用到死 socket 时**不触发 error**(TCP 半开),只有 error 才走 retry → 永久挂起。恢复手段=重启门户进程清空池。 +- **治本**:本地模式 proxy 转发**每请求新连接**(`useKeepAlive=false`,loopback 建连开销 ~0.01ms 可忽略),从机制上消除半开池;k8s 模式保留 keep-alive(Headless Service DNS 重解析语义不变)。 +- **验证**:重启后 HTML 经 proxy 200 @17ms,连续 5 次全 200 @~10ms;commit 6336c53。 +- **经验**:实例(子进程)重启频率高(插件升级/崩溃/回收都会),**任何跨进程复用的连接池都要防半开**;遇到"服务看似活着但请求挂起",先查 proxy 转发层。 + +## 回滚 / 注意 + +- 备份:`/opt/dsh/backups/server-login-src-pre-reap-20260909.tgz`(src);DB 无需迁移(无 schema 变更)。 +- 升级流程:备份 → 改 src → `npm run build` → `systemctl restart dshs` → 验证。 +- 单会话上线后,同一账号的临时测试 sid 直插模式仍可用(不走 login 不受顶下线约束),但**真实登录只会有一个活跃 sid**; + 多窗口/多设备联调时需注意(后登录会顶掉先登录的调试窗口)。 +- 默认 cap=4 基于当前 2GB 机器(约 760MB 实例预算);机器扩容后调大 `DSHS_MAX_IDLE_INSTANCES` 即可。 diff --git a/dsh-server-docs/04-调整方案/09-普通用户隐藏模型设置-角色化profile-patch.md b/dsh-server-docs/04-调整方案/09-普通用户隐藏模型设置-角色化profile-patch.md new file mode 100644 index 0000000..c107387 --- /dev/null +++ b/dsh-server-docs/04-调整方案/09-普通用户隐藏模型设置-角色化profile-patch.md @@ -0,0 +1,73 @@ +# 09-普通用户隐藏「模型」设置分区(角色化 profile patch) + +> 日期:2026-09-09 | 状态:已落地 | 类型:功能改造(会话 UI 角色裁剪) +> 相关:档案 03(统一 KEY 管理员管控)、档案 05(portal-entry / settings.section 机制) + +## 一、需求 + +普通用户在 dsh 设置面板不应出现「模型」分区(可配置 provider/key 的入口): + +1. **管控一致性**:模型 KEY 由管理员统一管控(档案 03)——若普通用户能在自己实例里配 key,可绕过统一 key(用自己的 key); +2. **现状即故障感**:官方 web profile 的模型 provider 目录在此环境不可用(实测「加载提供方目录失败: settings are unavailable in this browser」),普通用户点进去看到报错,体验差; +3. **方案决策**:用户拍板 **B 方案 = 隐藏「模型」设置分区**(admin 保留),比"保留分区+报错"(A)体验好;不选改官方文案(C,触碰官方 client)。 + +## 二、机制调研(实证) + +| 项 | 结论 | +|---|---| +| client 插件装载 | profile bundles(`package.json dsh.profile.bundles`,如 base/web-app)→ 展开为 cordis 树,client 插件行形态 `- id: ui-settings-models\n name: "@deepseek-ai/dsh-client-ui-settings-models"`(**id 是短 id 非包名**,`dsh --profile web --dump-config` 可见) | +| cordis patch disable | dsh-app-boot `applyEntryPatches`:非 insert patch 按 `id` 找到目标行 → overrides(含 `disabled: true`)合入 → 该 client 插件不装载、分区消失。官方 telemetry patch 亦用 `disabled: true`(profile-boot) | +| patch 落点 | **profile 层 `$DSH_HOME/profiles/web/cordis.patch.yml`**(dsh 组合顺序:bundle 层 → 此文件 → --patch overlays)。当前生产实例 spawn 不带 `--patch`(enablePatch=false 且 enter 传 undefined),故 --patch overlay 通道未启用——disable 只能写 profile 层 | +| 生效时机 | **必须重启实例**:client bundle 在实例启动时打包(同 v0.4.3 教训);`patchReload: live` 实测对 client 插件增减**不生效**(改文件后不重启,浏览器设置分区不变,5 次轮询确认) | +| dump 验证命令 | `DSH_HOME= dsh --profile web --dump-config` → `ui-settings-models` 行出现 `disabled: true` 且注释 `# == ... patched by .../cordis.patch.yml` | + +## 三、改动点 + +| 文件 | 内容 | +|---|---| +| `profiles/web/cordis.patch.yml`(普通用户,guest 已写) | 默认 `[]` → 管理标记头 + disable 块(见下) | +| `/opt/dshs/ensure-role-profile-patch.cjs`(**新增**,chmod 600) | 幂等工具:非 admin 用户检查/写入 disable 块;`--restart` 可 kill 实例由 watchdog 拉新。用法:`node ensure-role-profile-patch.cjs [--restart] [username...]`(不带用户名 = 全部非 admin) | + +disable 块内容: + +```yaml +# dshs role patch: 普通用户隐藏「模型」设置分区 +# admin 保留;由 ensure-role-profile-patch.cjs 管理,勿手改 +- id: ui-settings-models + name: "@deepseek-ai/dsh-client-ui-settings-models" + disabled: true +``` + +**admin profile 不动**(保留「模型」分区)。未来其他"角色化 UI 裁剪"沿用同一机制(在 ensure 脚本注册更多 disable 块 / 按 role 扩展)。 + +## 四、验证记录(guest 实测) + +1. guest profile 写 disable 块 → `dsh --dump-config`:`ui-settings-models` 行带 `disabled: true` + patched 注释 ✅ +2. 重启 guest 实例(POST /api/dsh/restart,port 37089→45255)→ 浏览器(沙箱外 Chrome + CDP + sid + token URL)设置面板:导航 = 通用设置/插件/Agent 预设(**无「模型」**)✅(截图 `guest-no-models.png`) +3. 还原 `[]` 不重启 → 5 次轮询模型分区不回来 → **live reload 对 client 增减无效**,需重启 ✅(反证生效时机) +4. ensure 脚本:幂等(已管理 → skip);wrote 分支演练(还原 [] → 跑 → 写入 disable 块 + dump 验证合入)✅ +5. admin profile 未动(dump 正常,模型分区保留) + +## 五、新增普通用户 SOP(模型分区隐藏) + +```bash +# 1. 用户注册 → admin approve(role=active) +# 2. 用户首次登录一次(dsh spawn,创建 profiles/web/)后: +node /opt/dshs/ensure-role-profile-patch.cjs --restart +# (--restart:kill 其实例 main,watchdog 拉起带 disable patch 的新实例) +# 或用户此时实例未运行 → 下次 enter spawn 自动读 disable,无需 --restart +``` + +## 六、后续 + +- P1「管理类插件化 / 编导工作区模板」落地时,评估把"角色化 profile patch 注入"收编为编排器原生(新用户 spawn 自动带),替代人工跑脚本; +- admin 若后续也不需要「模型」分区(统一 key 走门户 /api/me/keys),同样机制对 admin profile 执行即可。 + +## 七、回滚 + +```bash +# 恢复该用户 profile 默认空 patch + 重启实例: +echo '[]' > /var/lib/dshs/users//home/profiles/web/cordis.patch.yml +# (或先备份:cp cordis.patch.yml cordis.patch.yml.bak-) +# 重启实例后「模型」分区回归 +``` diff --git a/dsh-server-docs/04-调整方案/10-共享技能只读部署-bundledSkillDir.md b/dsh-server-docs/04-调整方案/10-共享技能只读部署-bundledSkillDir.md new file mode 100644 index 0000000..397e3d9 --- /dev/null +++ b/dsh-server-docs/04-调整方案/10-共享技能只读部署-bundledSkillDir.md @@ -0,0 +1,128 @@ +# 10-共享技能只读部署(bundledSkillDir 层) + +> 状态:**已实施(机制生效);管理面见档案 11**|2026-09-09|对象:dshs + dsh 0.1.2-rc.1 +> 需求来源:能否上传 skill 让**所有用户可用、只允许使用不允许修改**?用 MCN V1.0 短视频脚本技能(290MB)做可行性分析 + 嵌套 subskill(browser-harness)能否在服务器运行。 + +> **TL;DR**|**结论**:共享只读技能层:dsh **原生支持** `bundledSkillDir`(全员可用、不可修改)。 +> **关键**:实测结论:主技能放 bundled 只注册 1 个;**6 个 subskill 要各自独立目录平铺**才能成为可调技能。 +> **状态**:✅ 机制已实施(管理面见档案 11) + +## 一句话结论 + +**支持,且 dsh 原生设计了这一层**:`skill-filesystem` 的 **bundledSkillDir**(env `DSH_BUNDLED_SKILL_DIR`,rank 600)就是"系统标准技能层"——所有实例共享、只读、不可改(`trustedHost: true` 直读 + root 属主 OS 权限兜底)。**唯一卡点**:dshs 编排器 spawn 子进程时 env 走**白名单过滤**(`ALLOWED_ENV`),新变量不会自动透传,需改编排器 2 处(自研代码,**不触碰红线 2** 的官方 dsh 主程序)。 + +## 需求判定 + +| 子问题 | 结论 | +|--------|------| +| dsh 是否支持上传 skill 全员共享 | ✅ 支持,但**无"上传"界面**——机制是文件系统扫描 + env 注入指向共享只读目录 | +| 只允许使用、不允许修改 | ✅ bundled 层设计即此语义(详见机制表) | +| MCN V1.0(290MB)能否直接放 | ⚠️ 需裁剪(290MB 中 238MB 是 browser-harness 的 **Windows venv**,服务器 Linux 不可用)+ 拍平(见 subskill 结论) | +| 嵌套 subskill(browser-harness 等)能否被识别 | ⚠️ discoverRoot **只扫 root/<技能名>/SKILL.md 一层**,subskills/ 内技能不会自动注册为独立技能;需**拍平**到 bundled 根目录(每个子技能目录含自己的 SKILL.md 即可独立注册) | +| browser-harness 能否在服务器运行 | ⚠️ 机制上可行但工程量大:自带 envs 是 **Windows** 构建(`Scripts/browser-harness.exe`),Linux 需重建 venv + 装 headless Chrome + 依赖;且服务器为**海外节点**,直连抖音有风控/地区限制风险 | + +## 机制调研表(源码级证据) + +### 1. skill 装配链(谁在装载技能) + +| 环节 | 位置 | 证据 | +|------|------|------| +| 注册插件 | `@deepseek-ai/dsh-agent-presets/presets/standard/agent.cordis.yml` L83-88 | `skill-filesystem`(dsh-skill-filesystem)+ `tool-skill`(dsh-tool-skill,提供技能目录/加载给 agent) | +| provider 实现 | `@deepseek-ai/dsh-skill-filesystem/lib/index.js` | `apply(ctx, config)` → `ctx.skills.registerProvider(...)` | +| env 来源 | 同文件 L84 | `config.bundledSkillDir ?? (includeDefaultRoots ? process.env.DSH_BUNDLED_SKILL_DIR : void 0)` → **preset 里无 config 块 → 走 env** | +| 只读语义 | 同文件 L142 `get()` | `parseSkillFile(locator.path, ctx, signal, candidate.source === "bundled")` —— bundled 走 trustedHost 直读 | + +### 2. 技能分层 rank(roots 构建,L166-190) + +| root | 路径 | source | rank | 修改权限 | +|------|------|--------|------|---------| +| project-dsh | `<项目根>/.dsh/skills` | project-dsh | 100 | 项目内 | +| project-agents | `<项目根>/.agents/skills` | project-agents | 200 | 项目内 | +| custom | config.customSkillDirs | custom | 300 | 配置方 | +| user-dsh | `$DSH_HOME/skills` | user-dsh | 400 | **用户自己**(每人独立,改自己的) | +| user-agents | `$DSH_AGENTS_HOME 或 ~/.agents/skills` | user-agents | 500 | 用户自己 | +| **bundled** | **`$DSH_BUNDLED_SKILL_DIR`** | bundled | **600** | **只读共享(trustedHost=true,无修改面)** | + +> rank 600 注释 `trustedHost: true`:读取绕过沙箱 fs 服务走 Node 原生直读;技能源为系统标准层,模型侧无可写入口。 + +### 3. 扫描粒度(决定 subskill 命运) + +- `discoverRoot(root)` = `readdir(root.path)` 单层 → 每个 entry: + - 目录 → 要求 `/SKILL.md` 存在 + - `.md` 文件 → 直接视为技能 +- `isPotentialSkillPath`:相对 root 最多 **2 段**(`root/<技能名>/SKILL.md`),更深(如 `root/主技能/subskills/子技能/SKILL.md`)**不被发现**。 +- 结论:V1.0 主技能放 bundled 只注册「短视频工作台」一个;6 个 subskill 要成为独立可调技能必须**各自独立目录平铺**在 bundled 根。 + +### 4. 编排器 env 白名单(实施唯一卡点) + +`/opt/dshs/lib/supervisor/spawn.js` L11-27: + +```js +const ALLOWED_ENV = new Set(['PATH','HOME','USER','TMP','TEMP','TMPDIR','SYSTEMROOT','SystemRoot', + 'PATHEXT','ProgramFiles','ProgramFiles(x86)','LANG','LC_ALL']); +``` + +`orchestrator.js baseEnv()`(L189-205)= `{...scrubEnv(process.env), HOME: ws, DSH_HOME: home, DEEPSEEK_API_KEY}`。 + +→ `DSH_BUNDLED_SKILL_DIR` 不在白名单 → 即使 systemd 里设了也会被 scrub 掉。**必须**: +1. `spawn.js` ALLOWED_ENV 加 `'DSH_BUNDLED_SKILL_DIR'` +2. `orchestrator.js` baseEnv 显式注入(建议读自身 config,如 `DSH_BUNDLED_SKILL_DIR ?? '/opt/dshs/bundled-skills'`) +3. src/*.ts 同步修改(保持与 lib 编译产物一致) + +改的是**自研编排器**,非官方 dsh 主程序 → 不违反红线 2。 + +## 推荐实现方案(获批后执行) + +| 步骤 | 操作 | 说明 | +|------|------|------| +| 1 | 服务器建共享目录 `/opt/dshs/bundled-skills/`(root:root 755) | 用户进程只读,OS 权限兜底不可改 | +| 2 | 改编排器 2 文件(spawn.js 白名单 + orchestrator.js baseEnv)+ src 同步 | 重启 `systemctl restart dshs` | +| 3 | 裁剪 V1.0 → 部署包(见下) | 290MB → ~30MB | +| 4 | 平铺部署:bundled 根下放 `mcn-workstation/`(主技能)+ 各 subskill 独立目录 | 主技能内 `subskills/` 相对引用保留(V1.0 内目录不动);独立注册的子技能解决"按名调用" | +| 5 | 重启 admin/guest 实例(client 面技能目录即时可见) | 实例重启走主域 Host `/api/dsh/restart` | +| 6 | guest 浏览器实测:技能目录可见 → 发起会话让模型列出技能 → 确认只读 | 见档案 09 同款验证栈 | + +### V1.0 裁剪清单(290MB → 预计 <30MB) + +| 内容 | 大小 | 处置 | +|------|------|------| +| `subskills/browser-harness/envs/` | 238MB | **删除**(Windows venv,Linux 不可用;服务器按需重建) | +| `mcn-work-shop/*.db.bak-*` | ~16MB | **删除**(4 份调试备份) | +| `mcn-work-shop/` 其余(server.js/tmp/png/.mjs) | ~4MB | 视需求——调试工作台,建议**不随技能分发**(保留 SKILL.md 引用则需评估) | +| `SKILL.md` + `references/` + `references-add/` + `scripts/` | ~1.3MB | **保留**(核心) | +| 6 个 subskill(除 browser-harness envs 外) | ~25MB | **保留 + 拍平** | + +## subskill / browser-harness 服务器可行性 + +### 识别(注册) +- 每个 subskill 目录已含独立 `SKILL.md`(已逐一确认 6/6)→ 平铺到 bundled 根后**可独立注册**。 +- 主技能 SKILL.md 的「子技能索引」用相对路径 `subskills/<名>/SKILL.md` 引用 → 主技能加载后模型可读文件;但要"作为技能被调用"需独立注册(dsh 无宿主递归扫描机制,WorkBuddy 的软链/递归扫描不适用)。 + +### 运行(browser-harness) +| 检查项 | 服务器现状 | 结论 | +|--------|-----------|------| +| 自带环境 | `envs/browser-harness/Scripts/browser-harness.exe`(**Windows**) | ❌ 不可直接用 | +| Chrome | 未安装(`which` 空) | 需装 headless chromium + 系统库(libnss3 等) | +| Node | v22.23.2 ✅ | 可跑 node 型脚本 | +| 网络 | 47.77.182.89 海外节点 | 抖音直连风控/地区限制风险高(MCN 数据抓取核心场景) | +| RedFox API 路径 | `REDFOX_API_KEY` 已具备 | ✅ API 型数据(榜单/诊断)不受浏览器影响,**优先走此路** | + +→ 结论:browser-harness 服务器**机制可行**(Linux headless Chrome + 重建 venv),但投入大且抖音网页抓取在海外 IP 收益不确定;建议第一阶段**不启用** browser-harness,抖音类技能优先走 RedFox API 子技能;网页交互类需求待业务验证后再评估。 + +## 方案对比 + +| 方案 | 机制 | 全员可用 | 只读 | 改动面 | 结论 | +|------|------|:---:|:---:|------|------| +| **A. bundledSkillDir(推荐)** | env 注入共享只读目录 | ✅ | ✅(原生 trustedHost + OS 权限) | 编排器 2 文件 + 共享目录 | 正解,语义即"系统标准技能层" | +| B. 每用户 `$DSH_HOME/skills` | 编排器建号时播种 | ✅(复制分发) | ❌ 用户可改自己副本 | 播种逻辑 + 同步机制 | 不合"不可改" | +| C. repository 插件全局注册 | preset 注释提到"deployment registered globally" | 未验证 | 未验证 | 机制不透明 | 不做 | + +## 验证记录 + +- 源码级证据全部取自行机服务器(2026-09-09 18:xx):装配链 / rank 常量 / discoverRoot 单层 / scrubEnv 白名单 / browser-harness envs 为 Windows venv。 +- **已实施(2026-09-09)**:spawn.ts ALLOWED_ENV 加变量 + orchestrator.ts baseEnv 注入 + 建 /var/lib/dshs/bundled-skills;admin dsh 实例 `/proc//environ` 已含 `DSH_BUNDLED_SKILL_DIR=/var/lib/dshs/bundled-skills`。 +- 后续管理面(API+UI+个人技能)见档案 11。 + +## 回滚 + +- 编排器改动 revert 2 文件 + `systemctl restart dshs` 即可(bundled 目录删除即技能消失;实例无需逐个回滚)。 diff --git a/dsh-server-docs/04-调整方案/100-实例内我的技能-并入功能管理分组.md b/dsh-server-docs/04-调整方案/100-实例内我的技能-并入功能管理分组.md new file mode 100644 index 0000000..8d5e09b --- /dev/null +++ b/dsh-server-docs/04-调整方案/100-实例内我的技能-并入功能管理分组.md @@ -0,0 +1,249 @@ +# 100 · 实例内「我的技能」—— 并入「功能管理」的分组方案与实现 + +- 日期:2026-09-15 +- 触发:用户「规划一个产品方案(界面布局美观 方便操作)然后实现这个功能」=落地 `交接单/T01`(档案 16 阶段 3/4) +- 对象:`poc/business-plugins`(`@dsh-local/business-plugins`)client bundle 的「功能管理」section +- 状态:🚧 实施中(方案已定,代码/投放/验收见 §八) + +> **TL;DR**|**结论**:在**既有「功能管理」section 内新增「我的技能」分组**(**不新开 section**),用户在同一页看完并管理「功能插件 + 我的技能」两类功能;技能行支持 上传 / 启用 / 停用 / 删除,平台共享技能显示为**锁定只读**。 +> **关键**:入口层合并、**机制层分离**(技能 watch 即时生效 vs 插件需重启;API / 落盘各自独立)—— 依据见 `交接单/T01 §九`。 +> **不做**:不改服务端(6 个 `/api/skills/mine*` 路由早已就绪,档案 41);不新造包;不碰官方包。 + +--- + +## 一、目标与完成判定 + +**目标**:普通用户在实例内**自助管理个人技能**,且平台投放/用户自传两类"功能"在**同一页、同一套交互语汇**下呈现。 + +**完成判定(可被第三方复现)**: +1. guest 实例 → 设置 →「功能管理」页内出现「**我的技能**」分组,且与「功能插件」同页可见; +2. 上传一个合法 `.zip` 技能 → 行内新增且状态「已启用」;`$DSH_HOME/skills/` 存在; +3. 停用 → 文件移到 `home/skills-library/`;启用 → 回到 `skills/`;删除 → 两处均无; +4. 与共享技能同名上传 / 对锁定行操作 → 后端 409,界面以**行内错误**如实呈现; +5. 全程**无需重启实例**(技能是 watch 驱动)。 + +--- + +## 二、产品方案(界面布局) + +### 2.1 页内结构(自上而下,同页滚动) + +``` +┌─ 功能管理 ───────────────────────────────────────────────────────┐ +│ │ +│ ▍功能插件 ← 既有分组(不动) │ +│ ├─ 内存配额状态条(既有) │ +│ ├─ 工具行:搜索 / 全选 / 清空 / 已选 N 项(既有) │ +│ └─ 插件卡片网格(既有,勾选式) │ +│ │ +│ ─────────────── 1px 分隔线 ─────────────── │ +│ │ +│ ▍我的技能 [3] [+ 上传技能] ← 新增分组(本单) │ +│ ├─ 说明行:上传 .zip 技能包;启用/停用**立即生效,无需重启** │ +│ ├─ 上传面板(默认收起,点「上传技能」展开 / 支持拖入) │ +│ └─ 技能行列表 │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ 账号诊断 [共享]🔒只读 12 文件 · 136 KB │ │ +│ │ 我的图表工具 [我的]●已启用 8 文件 · 24 KB [停用][删除]│ +│ │ 旧版脚本 [我的]○已停用 5 文件 · 12 KB [启用][删除]│ +│ └──────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +**为什么是这个结构**:用户在「功能管理」里的心智是"**我有哪些功能、开没开**"。两类功能放同一页、同一行卡片语汇,一次滚动看完;差异(要不要重启)由**行内事实文案**承担,而不是靠分成两页去解释。 + +### 2.2 分组头(`.bp-group-h`) + +| 元素 | 规格 | +|---|---| +| 标题 | 15px / 600 / `T.text`,前缀 3px 竖条(`T.primary`,圆角 2px)—— 与「功能插件」分组头**同一形态** | +| 计数徽章 | 13px,`T.field` 底 + `T.sub` 字,`2px 8px` r10(规范 §4.6 `.badge` 量级) | +| 右侧主按钮 | 「+ 上传技能」,白底 + `T.border` 边 + `T.primary` 字(规范 §4.1 `.btn-view` 量级),点击展开/收起上传面板(`aria-expanded`) | +| 与上方分组的分隔 | `1px solid T.border` + `margin-top: 22px; padding-top: 18px`(规范 §2.4 间距序列) | + +### 2.3 技能行(`.bp-skill-row`)—— 单行不换行 + +| 区 | 内容 | 规格 | +|---|---|---| +| 左 | **技能名**(`name`) | 14px / 500 / `nowrap` + ellipsis + `title`(规范 §5 长文本截断三件套);`min-width:0` 允许收缩 | +| 中 | **来源徽章** | `共享` = `T.primary` 12% 底 + `T.primary` 字;`我的` = `T.field` 底 + `T.sub` 字。13px / r10 | +| 中 | **状态** | ● 点(6px 圆点)+ 文字:已启用 = `T.success`;已停用 = `T.dim`。13px | +| 中 | **事实**(右对齐) | `12 文件 · 136 KB`,13px + `font-variant-numeric: tabular-nums`(规范 §5「数值一律右对齐 + 等宽数字」) | +| 右 | **动作区** | `margin-left:auto` + `nowrap`;启用/停用 = 白底 border 按钮(`.btn-sm` 量级);删除 = `T.danger` 文字按钮。**busy 时该行按钮统一 disabled + opacity .5** | +| 整行 | 容器 | `padding: 12px 14px`,`border: 1px solid T.border`,r12,白底(`--dsw-alias-bg-layer-1`),hover 变 `T.field` 底 | + +**锁定行(`locked: true`,平台共享技能)**:来源徽章后追加 🔒 + 「只读」,**不渲染任何动作按钮**;`title` 说明「平台共享技能,全员只读,不可停用/删除/覆盖」。→ 与后端 `assertNotShared()` 的 409 双向一致(前端不给出会失败的按钮,后端仍然兜底)。 + +### 2.4 上传面板(`.bp-upload`) + +| 元素 | 交互 | +|---|---| +| 虚线投放区 | `border: 1px dashed T.border`,r10,`padding: 18px`,居中;文案「把 .zip 拖到这里」+ 次行「技能包需含 `SKILL.md`(name / description)」 | +| 「选择文件」 | `input[type=file][accept=".zip"]` 隐藏 + `