1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
+ ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
20 KiB
⚠️ 历史章节(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。
# ===== 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/<uid>到新机同路径即可; - 多用户同构扩展:新用户增加
users/<uid>/,脚本无需改动(tar 整体 users/)。
八、多用户扩展(dshs 落地前的过渡方案)
目标:每用户独立实例 + 严格隔离。新增一个用户 = 一套同构目录 + 一个容器 + nginx 分流:
# 以新增 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=<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 优先,全流程零中断):
- SSH 备份 + 移走手写 conf:
cp到/opt/dsh/docs/nginx-dsh.alotbuy.com.conf.bak-20260908,mv出 include 目录(不 reload,服务无感) - MCP
ProxyProjectCreate:domains=dsh.alotbuy.com,proxy_pass=http://127.0.0.1:3080→ 站点 id=2 入面板 DB - MCP
ProxyWriteConfig:把完整精调 conf(HTTP→HTTPS 跳转 ×2 + 443 主域 SSL + 443 通配子域 SSL)覆盖写回同路径 - 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-cce6d1cdb376430480f0uid=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-connectionBrowserAuth)强制:首次访问需?token=<launch token>(进程启动随机、打印到 stdout)换取签名 cookie(绑定 authority=127.0.0.1:<port>,30 天) - 上游 dshs 0.1.0:launch 返回 url 不带 token,desktop.html "打开 DSH" 直接
window.open(url)→ 首访 401dsh web authentication required;且端口动态分配,实例重启换端口 → 旧 cookie 失效需重新带 token - 已实测全链路可行:门户 sid +
?token=→ 303 种 dsh-auth cookie → 双 cookie 访问https://admin.dsh.alotbuy.com/200(24233B SPA)+/api/llm/listProvidersok: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:常用命令速查
# 备份 / 恢复
/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 <npm包名|github:owner/repo|link:/path>
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] <username>
# 新普通用户 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 <CF段> + 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 |
| 实例反复起不来 | 同上(含 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 |
注:诊断实例内文件系统时,
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到<userRoot>/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-<TS>.tar.gz(全量)+…-db-snapshot.tar.gz(DB 快照)
恢复(演练已验证):
B=/opt/dsh/backups/dsh-platform-backup-<TS>.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 行 ✓。