DeepSeek Harness Plugin Hub

Publish and manage complete Harness Profiles. Discover Plugins for your next setup.

Explore

PluginsPresetsDocsNews

Community

Publish a pluginContactReport an issue

Resources

Plugin Hub on GitHubDeepSeek HarnessSystem statusPrivacy notice
© 2026 DeepSeek Harness Plugin HubPowered byPaxTech

Independent and unofficial. Not affiliated with, authorized by, or endorsed by DeepSeek.

Kczx User Management — DSH Plugin for DeepSeek Harness
DeepSeek Harness Plugin Hub
ProfilesPluginsCategoriesNewsDocsSign inManage Profiles
ProfilesPluginsCategoriesNewsDocsSign in
← Plugins

kczx-user-management

Kczx User Management

dsh plugin · login gateway + accounts / permissions / quotas / audit ledgers (dsh-passwords functionality has been integrated): HTTPS login gateway with self-signed certificates, user creation/deletion/update/query (with optional workspace creation), workspace allowlist and ownership tracking, hourl

The plugin will be installed here. Keep web if you are unsure.

npx -y @deepseek-ai/dsh plugin --profile web add kczx-user-management@1.7.0
READMECompatibilityVersions

Description

dsh plugin · login gateway + accounts / permissions / quotas / audit ledgers (dsh-passwords functionality has been integrated): HTTPS login gateway with self-signed certificates, user creation/deletion/update/query (with optional workspace creation), workspace allowlist and ownership tracking, hourly token and daily duration quotas, sandbox tier minimums, upload/git toggles, three audit ledgers (login/access/operations), IP blocking, and TOTP two-factor authentication.

Compatibility and provenance

Kczx User Management is published as kczx-user-management and currently resolves to version 1.7.0. The Hub verifies its manifest and preserves the exact installation source for reproducible installs.

DSH compatibility
*
Runtime surfaces
web
Release source
npm
Registry updated
10/16/2026

Versions

1.7.0stable
10/11/2026
1.6.3stable
9/23/2026
1.6.2stable
9/23/2026
Show 9 more versionsCollapse versions
1.6.0stable
9/23/2026
1.5.6stable
9/22/2026
1.4.1stable
9/21/2026
1.4.0stable
9/21/2026
1.0.4stable
9/18/2026
1.0.3stable
9/17/2026
1.0.2stable
9/17/2026
1.0.1stable
9/17/2026
1.0.0stable
9/17/2026

Related plugins

Loading related plugins…

Latest
1.7.0
DSH
*
HMR
Process restart
Tree shaking
Safe tree shaking not declared
Unpacked size
2.2 MB
Files
32
Surface
web
License
MIT
Source
npm
GitHub
★ 0
Weekly downloads
193
View source ↗Project homepage ↗
README badge

Click the badge to copy Markdown for your README.

Do you maintain this Plugin?Claim benefit · Priority security scan

Verify the GitHub repository declared in package.json to manage this listing. After you claim it, Hub will prioritize a security scan of the current version and publish the result when it passes.

Claim this Plugin →
Report an issue

Related plugins

More verified plugins in security-access.

Cc Safety Netcc-safety-netA coding agent CLI hook - block destructive commands and secret file accessDoctor@linxin666/dsh-doctorTransactional rescue mode for DSH profiles with a supervised launcher, isolated recovery capsule, deterministic repairs, health monitoring, and a local Web recovery consoleMobiledsh-mobileDeepSeek Harness mobile adaptation and secure access plugin, supporting LAN, remote connections, Android App, and mobile browsers.Codex Subscriptiondsh-codex-subscriptionUse ChatGPT and Codex subscriptions in DeepSeek Harness with OAuth, quota, safe resets, web search, images, and Fast mode

README

kczx-user-management

本包是本地扩展版(1.0.0),已把 dsh-passwords 的账号 / 权限 / 配额 / 台账能力并入,不再是上游 0.9.5 的功能集。 上游同名包(npm 上的 @weibaohui/user-management)仅提供登录门禁与两步验证;本版额外提供工作区白名单与归属跟踪、新建账号即建工作区、 token 与时长配额、沙盒档位下限、上传 / git / 运维面门控、历史沙盒降级与隐藏字符清洗。 为便于从 registry 安装,本版以独立包名 kczx-user-management 发布,与上游互不覆盖。

给 dsh web 装一扇门:HTTPS 登录网关 + 账号管理 + 权限与配额执行 + 三本审计台账,一个插件完成。

安装

# 从 registry(发布之后;-w = pnpm 的 --workspace-root)
dsh plugin --profile web add kczx-user-management -w

# 从 tarball(离线 / 内网部署)
dsh plugin --profile web add ./kczx-user-management-1.0.0.tgz -w

# 或直接从目录 / 仓库(相对路径以你执行命令的目录为锚)
dsh plugin --profile web add link:E:\path\to\dsh-user-management -w
dsh plugin --profile web add github:you/dsh-user-management -w

# 装完重启 dsh web 生效

dsh plugin --profile <name> <args...> 就是在 ~/.dsh/profiles/<name> 里转发 pnpm <args...>;退出码 0 时按已安装状态 对账 dsh.profile.bundles——凡是声明了 dsh.bundle.patch 的依赖会自动进 layer 列表(本包含),所以不必手工改 bundles。

用 pnpm 直接安装

包内自带 dsh.bundle.patch,运行期依赖(@deepseek-ai/schemastery、bcryptjs、selfsigned)都在公共 registry 上,因此也可以当普通 npm 包装:

pnpm add kczx-user-management                      # 已发布到 registry 时
pnpm add ./kczx-user-management-1.0.0.tgz          # 从 tarball
pnpm add /abs/path/to/dsh-user-management          # 从目录

装进 dsh profile(把下面几行加进 ~/.dsh/profiles/web/package.json,再在该目录 pnpm install,然后重启 dsh web):

{
  "dependencies": { "kczx-user-management": "file:./kczx-user-management-1.0.0.tgz" },
  "dsh": { "profile": { "bundles": ["kczx-user-management"] } }
}

本包不依赖安装期脚本:postinstall 已移除(pnpm ≥10 默认拦截依赖构建脚本,留着只会让安装以 ERR_PNPM_IGNORED_BUILDS 退出码 1 收场),ensure:deps 保留为手动脚本,仅供「内网无 registry 且以 link: 挂载」的场景,从宿主补齐 @deepseek-ai/schemastery:

pnpm add ./kczx-user-management-1.0.0.tgz        # 正常路径:依赖从 registry 装齐
node scripts/ensure-deps.mjs                     # 仅当上述解析不到 schemastery 时才需要

首次使用(初始化)

自助注册是关闭的:账号只能由管理员在设置页创建,第一个管理员走"初始化"流程。

  1. 启动 dsh。库里还没有任何账号时,启动日志会打印一行初始化密钥:

    [user-management] first-run setup key 3f0c… (also in ~/.dsh/user-management/setup-key.txt)
    

    密钥同时写在 $DSH_HOME/user-management/setup-key.txt;也可以用 settings.yaml 的 user-management.setupKey 自行指定(那样不会往磁盘写密钥文件)。

  2. 浏览器打开 https://<主机>:8088/login —— 此时显示初始化页:填初始化密钥 + 用户名 + 密码。

  3. 提交后该账号直接成为管理员并登录,初始化密钥当场销毁(删文件、清内存);此后 /login 只显示登录表单, /user-management/api/setup 拒绝一切后续调用。

登录页样式与 dsh-passwords 一致(光晕背景 + 网格 + 玻璃卡片 + 渐变按钮),跟随宿主主题深浅色,右上角可切换中文 / English。 品牌可配:标题 / 副标题 / 页脚 / LOGO / 主色 / 默认语言都在 user-management: 段里,见「配置」一节。

它做什么

一扇门

独立的 node:https 监听器(默认 0.0.0.0:8088),自签证书(SAN 覆盖本机全部 IP + sslip.io / nip.io 别名), 未登录的页面访问跳转 /login、API 请求 401;登录页自带深浅色。设置页提供证书下载(PEM/DER)、指纹核对与 各系统导入向导。

账号与权限(管理员)

能力说明
用户增删改查用户表每行一个「编辑」全屏页:基本资料(部门 / 角色)+ 账号操作(重置密码 / 重置两步验证 / 禁用启用 / 删除)+ 权限与配额,集中一屏;改动类走「保存」,破坏性操作仍是各自带确认的按钮
所属部门(1.6.0 起支持多层级)账号资料字段,支持多层级路径:用 / 分层,例如 和平分局/刑侦大队/一中队(\ 也认,存的时候统一成 /)。新增用户时可填,编辑页可随时改或清空;编辑时框下会把层级摊开显示,并列出已在用的层级供一键接续;变更写进操作日志。规则(服务端为准,界面同款提示在前面):最多 5 级、每一级 ≤ 24 字、整串 ≤ 64 字(含分隔符);层级里不能有 `\ / : * ? " < >
新建账号即建工作区新增用户时勾选「同时创建该用户的工作区」:路径预填 <harness 根>/workspace/<部门各级>/<用户名>,一级部门一个目录(…\workspace\和平分局\刑侦大队\zhang;可改成任意绝对路径);提交后目录按需创建、登记进 dsh 工作区列表,并把该路径写进这个账号的工作区白名单。部门不合法时先报 400,再建目录——不会留下半棵树
工作区白名单留空 = 不允许任何工作区(白名单默认拒绝);填写后只允许这些目录下的工作区;条目 * = 允许全部。Windows 路径折叠大小写,末尾斜杠自动忽略
工作区归属跟踪谁创建的工作区归谁:别人看不到、也不能删改;删除后释放,账号删除后清理
会话可见性(1.6.0 起)会话按会话自己的工作目录判定:目录不在本账号白名单内、或落在别人认领的工作区里,这个会话就不下发。为什么必须有:前端把「没有被任何可见工作区认领」的会话统统塞进「未分组」那一组——只藏工作区行的话,里面的会话会带着标题、点开即读的历史原地复活。三个入口一起堵:① session.list(0.1.1)/ session/list(0.1.2+)的一元响应;② 0.1.1 的 host/session-added 实时推送(GET /api/events.host 的 SSE——每个新建会话都会被推给所有在线浏览器,只改一元响应的话,别人新建一条就会把行实时推回来);③ 0.1.2+ 的 api-session/added($events mux 流上的 emit 帧)。session.search 沿用列表刚藏掉的 id 清单摘命中片段。判定只看 cwd:blank 不豁免(别人目录里的「新建会话」行也还是别人的目录),只有子代理行(origin: subagent,前端本来就不列)和读不到 cwd 的行保留——「判断不了」不等于「不该看」
每小时 token 配额由浏览器上报 dsh 自身的用量投影增量,服务端按小时窗口累计并拦截
每日时长配额按真实活跃时间累计(不是请求数),限流不因轮询膨胀
沙盒档位下限read-only / workspace-write / danger-full-access:AI 无法通过设置、slash 命令或审批把自己提权
上传 / git 开关上传与「把数据带走」的通道分别控制(git RPC、会话导出、SSH 下载等)
策略封禁与「禁用」不同:执行层每次请求都读,封禁即刻踢出会话

三本台账

台账内容
登录记录登录 / 失败 / 登出 / 改密 / 初始化(首次建号)/ 建号 / 删号 / 角色变更 / 资料变更 / 权限变更(含紧凑差异串);按页浏览
访问记录页面级访问(保留真实客户端 IP);按页浏览
操作日志经过网关的每次 API 调用与 WebSocket 连接(方法 / 路径 / 状态 / 来源 IP);对话类请求连内容一起记:session.prompt 的提示词、session.rename 的会话标题写进同一行(最多 500 字,点开展开;粘贴的图片只记数量、不存 base64)。列表多一列「对话内容」,并按页浏览(默认每页 50,可选 20/50/100/200)

另有 IP 封禁(回环与当前 IP 拒绝封禁,防自锁)与 TOTP 两步验证(默认关闭,可选开启)。

分页:登录记录 / 访问记录 / 操作日志 / 用户表四张表都是服务端分页——查询参数带 limit + offset, 响应回 { ..., total },前端底部是共用的分页控件(上一页 / 第 X/Y 页 · 共 N 条 / 下一页 / 每页 20·50·100·200, 默认 50)。筛选同样在服务端执行,所以「共 N 条」永远是匹配总数而不是当前页条数。账号表只有在调用方 传了 limit 时才分页,不传仍然整份返回(后端脚本的老用法不受影响)。

dsh 版本适配(1.6.0 起)

dsh 有两代 API,本插件两代都认——这曾经是个坑:老代码只认第一代的路径,换到新宿主上不会报错, 只是那些请求永远匹配不上,于是三条强制悄悄失效。

0.1.1-rc.2(第一代)0.1.2-rc.1+ / 0.1.5-rc.2(第二代)
一元 RPCPOST /api/<ns>.<method>POST /api/<ns>/<method>
工作区列表workspace.list 一元响应workspace/follow 流(走 /api/remote.mux)
会话历史session.history 一元响应session/page 一元 + session/follow 的开场 snapshot
审批应答POST /api/respondPOST /api/$events/result(事件 id 由 mux 上的 waterfall 帧给出)
大文件外带——workspaceFiles/readAll、readRelated(整文件 base64)

因此在 0.1.2-rc.1 及更新的宿主上:

  • 工作区白名单 / 归属:过滤 workspace/follow 的 baseline / upsert / order 帧(别人的工作区不下发, 且 upsert 会被整帧丢弃,否则被过滤掉的条目会立刻长回来);
  • 沙盒档位下限:钳制 session/page 的 records、session/follow 的 snapshot 与逐条事件、 以及 session/control 上 permissions 投影的 currentValue;
  • 审批一律拒绝:把 $events/result 的 outcome 改写成 {kind:'result', value:'rejected'}。 判断「这是不是审批」有两条独立依据:mux 上 approval/request 的 waterfall 帧记下的 eventId, 以及 allowed-once(审批词表里唯一的放行值,问答的答案不可能是这个字符串);
  • 整文件读取:workspaceFiles/readAll|readRelated 归到「git / 下载」开关下。窗口化的 read/readBytes 与 stat/list 不拦——文件浏览器要靠它们画那一页,否则没开 git 的账号连文件都看不了。
  • 会话可见性:三个入口共用一条判定(见上表)。两代的一元返回值都是 { items: SessionSummary[] }, 但都包在连接信封里({ type:'server-response', rpcId, result:{ ok, value } })——只看顶层 items 在真流量上什么都匹配不到(这插件在对话内容台账上踩过一次,1.6.0 又踩了一次,所以 dsh-wire.js 现在带一个信封感知的定位器,测试也一律用真实信封而不是手写的裸形状)。 session.search 沿用同一份「刚被藏掉的 id」清单把命中片段一并去掉(列表是整份快照,每次响应 覆盖该清单而不是累加)。判定走 workspaceVisibility,与工作区列表同一套「白名单 + 归属」语义; 归属按包含该目录的最深认领算,所以 …/mty/src 里的会话算 …/mty 的。
  • 实时推送:0.1.1 走 src/sse-filter.js(逐 SSE 块解析,只丢 host/session-added 且判定为不可见的那种块, 其余字节原样放行);0.1.2+ 复用 mux 拦截器丢 api-session/added 帧。两条路都只在确认识别时动手, 认不出来就放行——这条流里跑着整个实时会话(含助手增量),绝不能因为它变哑。

宿主源码级的核对记录(每条形状的文件:行)在 docs/dsh-wire-notes.md。

配置

走 ~/.dsh/settings.yaml 的 user-management: 段。改完即时生效的字段无需重启;只有监听器字段 (listenHost / port / plaintext / sites)会触发监听器重建,其余一律下一个请求就用新值。

user-management:
  enabled: true
  listenHost: '0.0.0.0'   # 只本机可达就填 127.0.0.1
  port: 8088
  plaintext: false        # true 时只允许回环(明文服务观察层用,见 MERGED-DEPLOYMENT.md)
  sites: []               # 空 = 自动枚举本机 IP;配域名 + 证书则按 SNI 选择
  setupKey: ''            # 首次初始化密钥;留空 = 启动时随机生成并写到 setup-key.txt
  historyMaxBytes: 67108864   # 历史改写缓冲上限(字节,默认 64 MiB,最小 1 MiB)

  # ── 登录页品牌(全部即时生效,改完刷新 /login 即可) ──
  title: 'DSH 控制台'      # 部署名:标签页标题后缀 + 标题兜底
  brandName: ''           # 卡片主标题;只填它,门面就是你的产品名
  loginTitle: ''          # 逐行覆盖内置文案('' = 用内置的「登录」/ Sign in)
  loginSub: ''
  setupTitle: ''          # 同上,作用于首次「初始化平台」表单
  setupSub: ''
  footerText: ''          # '' = 内置的「dsh 插件 · user-management」
  showFooter: true
  logoUrl: ''             # http(s)、data: URI,或本机图片文件;'' = 内置标记
  logoSize: 48            # 标记方框边长(px,24–96);图片按 contain 铺满方框
  auditConversationText: true   # 操作日志记录 session.prompt 文字与会话标题(见下)
  brandColor: ''          # '#rrggbb';只重染品牌色 token,danger/ok/warn 不动
  defaultLang: 'zh'       # 不带 ?lang= 时的门面语言(zh | en)
  showLangSwitch: true    # 是否显示页内中英切换

  # ── 登录免责声明(即时生效;点「登录」先弹窗,点「确定」才真正登录) ──
  disclaimerEnabled: true   # false = 不弹窗,点登录直接提交
  disclaimerTitle: ''       # '' = 内置「免责声明」/ Disclaimer
  disclaimerText: ''        # '' = 内置条文;换行原样保留,可用 1. 2. 3. 分条
  disclaimerConfirm: ''     # '' = 内置「我已知晓,确定」(弹窗里那颗确定按钮写什么)

  # ── 会话与请求策略(全部即时生效) ──
  sessionDays: 7          # 会话有效期(天,滑动续期:过半后续一次;一直在用就不会掉线)
  sessionMaxHours: 12     # 登录最长时长(小时,从登录算起,不因操作延长);0 = 不限制
  sessionIdleMinutes: 0   # 闲置超时(分钟,超过这么久没请求就过期);0 = 不限制(默认)
  maxBodyBytes: 65536     # 本插件 API 的 JSON 请求体上限(字节)
  workspaceRoot: ''       # 新建账号工作区的根路径;'' = 问宿主(dsh 的工作区根,默认即 harness 所在目录)
  otpFailLimit: 5         # 两步验证连续错误几次后锁定
  otpLockoutSeconds: 60   # 锁定多久

登录页的品牌只吃 brandName / loginTitle 这类字段,改名不会影响账号与台账;brandColor 只重染 品牌色那几个 token(按钮、LOGO 底、聚焦环及其阴影),状态色保持不变——颜色承载语义,不该被顺手改掉。

LOGO 支持本机图片(1.1.2 起):logoUrl 填绝对路径(E:/brand/logo.png)或 $DSH_HOME/user-management/ 下的文件名(把图片放到 users.json 旁边,然后填 logo.png)。裸路径本身不是 URL,浏览器会拿它当相对路径去 请求网关,所以本机图片由插件用 /user-management/branding/logo 这条公开路由回吐给登录页(和 /login 一样 必须免登录,否则图片是 401);该路由只吐这一个配置文件,且限定图片扩展名、非空、≤ 512 KiB,其余一律 404, 路径下的其它子路径在本机就直接 404,不会被匿名转发到 dsh 上游。改图不用重启——路由每次现读文件。

图片铺满方框:logoSize 控制方框边长(默认 48px,24–96),图片用 object-fit: contain 填进去——方图正好 铺满,长图/竖图留白而不裁切。这是品牌字段里唯一会改布局的一个,调大小在设置页里改完刷新即可,不用重启。 未接线的占位字段仍有 loginFailLimit / lockoutSeconds(登录失败不自动锁定,用 IP 封禁代替), 保留只是为了老的 settings.yaml 继续可解析。

登录免责声明是「闸门」而不是装饰(1.7.0 起,默认开启):点「登录」不会直接登录——先弹出免责声明 窗口,读完点「确定」,登录请求这时才发出去;首次「初始化平台」页同理(先把两次密码对一遍,再弹窗)。 同意因此是访客先做的一个动作,而不是页面替他假设的状态。四段文字(弹窗标题 / 正文 / 确定按钮文案 / 总开关) 都在 **设置 → 通用设置 →「登录免责声明」**里改,留空一律回到内置中英文文案;正文的换行会原样保留, 弹窗内正文限高滚动,长条文不会把「确定」挤出手机屏幕。

弹窗可以按 Esc 或点遮罩关掉——关掉就是不登录(什么东西都不发),再点一次「登录」会重新弹。

点过「确定」之后门面会在 POST /user-management/api/login 里带上 disclaimerAccepted: true,宿主把它记进 登录台账(detail: disclaimer accepted)——即「门被通过」这件事有据可查。这一位是记录,不是校验: 直接调 API 的脚本、以及合并部署里由 dsh-passwords 把门的那些请求,本来就没见过这个页面, 在这里拒掉只会把他们关在门外。闸门在页面上,台账负责留下痕迹。

登录会过期(1.7.0 起)

「一直在用就永远不掉线」对内部平台是不够的,所以一个会话现在有三条独立的线,谁先到算谁:

设置从哪算起会被操作延长吗默认
sessionDays(天)上一次续期会(过半就续)7 天
sessionMaxHours(小时)登录那一刻不会12 小时
sessionIdleMinutes(分钟)上一次带会话的请求会(有请求就顺延)0 = 关闭

默认组合的效果:当天登录,第二天要重新登录;只要还在操作就不会因为闲置被踢;如果想按等保要求做「闲置自动退出」, 把 sessionIdleMinutes 设成 30 即可(三项都能在 设置 → 通用设置 →「会话与请求策略」 里改)。

过期之后发生的事,有意做得说得清楚:

  • 浏览器下次访问页面时,网关不是当作「没登录过」,而是 302 到 /login?expired=1,登录页顶部会有 一条黄色的「登录已过期,请重新登录」——不然用户只会觉得自己被莫名踢出来了。
  • 控制台里还开着的标签页不会被晾着:本插件每分钟问一次 /session,会话没了就自动回到登录页(闲置超时则是控制台自己先主动登出); 插件自己的接口返回 401 时也走同一条路(只跳一次,不会连环跳)。
  • 登录台账会记一行 session_expired,detail 是原因(absolute 到点 / idle 闲置 / expired 滑动过期 / disabled 账号被禁用), 同一个 token 只记一行(浏览器拿着死 cookie 重试十次也只记一行)。
  • 会话 cookie 的 Max-Age 取「滑动窗口」和「最长时长」里更短的那个,浏览器不会继续揣着一个平台早就不认的 cookie。

闲置窗口是两侧一起执行的,因为它们量的是两件不同的事:

  • 浏览器侧(主):量「人还在不在」。控制台监听指针、键盘、滚轮、触摸、滚动与聚焦,超过窗口没有任何输入就自己登出 (POST /logout 带 reason: idle,台账记 session_expired / detail idle),然后跳 /login?expired=1。 检查节拍 15 秒,所以填 1 分钟大约 1~1.25 分钟后生效。
  • 服务端(兜底):量「这个会话还在不在说话」。最后一次带会话的请求超过窗口就判过期——标签页关掉、浏览器崩了、 没有客户端在跑时,只有这一条能兜住。静态资源和控制台每分钟一次的 /session 探针不算活动, 否则探针自己就会把闲置时钟一直往后推。

为什么不能只按服务端的请求算:实测这个部署里,一个开着的控制台十分钟内把 /_dsh/vision-router/route-ownership 轮询了 2000 多次(约每秒 3.5 次),服务端看到的永远是「很活跃」——人早就走了。所以「人离开座位就退」只能在浏览器里量。 生产上建议 15~30 分钟,1 分钟只适合验证功能。

  • 收紧立即生效,放宽不追溯。三道线都是每次解析会话时现算的,所以把 sessionMaxHours 从 12 改成 2, 已经登录的会话下次请求就会过期;而把 2 改成 12 不会让已经被销毁的会话复活。

操作日志的分页(1.3.0 起)

台账上限 5000 行、每行还带对话文字,一次全拉进浏览器既慢又没必要。现在「操作日志」是服务端分页: GET /user-management/api/audit?limit=50&offset=100 返回 { entries, total, limit, offset }——除了这一页, 还给出匹配总数,前端才说得出「第 3/40 页 · 共 1987 条」。表格下方有上一页 / 下一页和每页条数(20/50/100/200)。

两个容易做错的点,都按服务端口径处理:

  • 筛选也走服务端,和分页窗口是同一组参数:username / method / path / statusClass。若在前端过滤已取回的 那一页,翻页翻的就是"某个结果的子集",而且"共 N 条"只能是这一页的数字——那是错的。筛选条件改动会回到第 1 页 (换条件后停在旧页码没有意义),输入框有 250ms 去抖,不会每敲一个字发一次请求。
  • 删掉最后一条时若当前页只剩那一行,会退回上一页,而不是停在"第 N 页(空的)"。

操作日志里的对话内容(1.2.0 起)

「用户管理 → 操作日志」每一行是经过网关的一次 API 调用。以前只看得到 POST /api/session.prompt——可这一行不论用户说了什么都长一个样, 日志于是回答不了「这个账号到底做了什么」。现在网关把人打的那句话从请求体里取出来,随行写进台账:

  • session.prompt → content[] 里所有 type: 'text' 片段按行拼接;type: 'image' 只记数量(截图是 base64,不是台账该存的东西)。
  • session.rename → 会话标题。
  • 其它 RPC 完全不碰请求体,仍按原来的方式流式转发。
  • 存储侧每行最多 500 字(MAX_AUDIT_TEXT,超长截断);前端那一列默认单行省略、点一下就地展开全文,图片数量显示为徽标。
  • 只观察、不拦截:抓取只是顺路把请求体缓一下再原样重放给上游,字节不变;超过 2 MiB 的请求体(例如带大图的提示词)原样透传、不记录文字。
  • 隐私开关:user-management.auditConversationText: false(设置页「会话与请求策略」里也有这一项)关掉即完全不抓。台账是 $DSH_HOME/user-management/audit.jsonl 明文文件,提示词可能含口令 / 密钥时请关闭。

部门层级(1.6.0 起)

部门的「多层级」不是一张独立的部门表,而是一个字符串里的路径:和平分局/刑侦大队/一中队。这么定有两个好处——老数据不用迁移(原来的 平台组 就是单级),而且它正好就是工作区目录的形状:勾了「同时创建该用户的工作区」的账号,目录会一级一个文件夹地铺开 (都挂在 <harness 根>/workspace/ 这一层下面)。

规则值
层级分隔符/(\ 也接受,存成 /)
最多层级5
每一级最长24 字
整串最长64 字(含分隔符)
层级里禁止`\ / : * ? " < >

空层级会被丢掉,两端空格会被裁掉:" 和平分局 / 刑侦大队 // 一中队 " 存成 和平分局/刑侦大队/一中队。不合法就报错,不会把非法字符悄悄折成 -:这条标签同时也是目录名,标签和目录不一致比报错更糟。

编辑页与新建对话框里,部门框下面是层级摊开预览(和平分局 › 刑侦大队)、已在用层级的快捷前缀(点一下就把框变成 该路径/,接着往下敲一级即可),以及和上面同一套规则的即时校验提示。已用层级来自 GET /user-management/api/departments(管理员专用,返回所有前缀,不只是叶子节点——有人正好挂在中间层时,父级不能消失)。

新建用户时一并创建工作区(1.5.0 起)

「用户 → 新增用户」多了一个复选框「同时创建该用户的工作区」。勾上后出现路径框,预填默认路径 <harness 根>/workspace/<部门各级>/<用户名>(部门留空就少一层,只到用户名;所有账号都在同一个 workspace 目录下, 根目录因此不会被一堆部门目录铺满),可以直接改成任意绝对路径;下方一行小字写着默认值, 改过之后还有「恢复默认」。不勾选则一切照旧。

提交时服务端按这个顺序做事,能失败的都在写账号之前失败:

  1. 校验路径:自定义路径必须以盘符或 / 开头(相对路径会报错,不会被悄悄拼到进程 cwd 上);根路径本身 (harness 目录)不能当工作区;路径长度 ≤ 1024。部门是管理员随手写的自由文本,所以它会被折成一段合法的目录名 (\ / : * ? " < > | 与控制字符 → -,纯点号(..)直接丢掉),绝不允许它多出一层目录或跳出根路径。
  2. 归属冲突:这个路径已经属于别的账号 → 409,账号和目录都不会建。既有的目录(别人放的文件)会被领养而不是重新创建。
  3. 建目录(mkdir -p,已存在则复用);失败则 400,不建账号。
  4. 建账号,并把该路径写进它的工作区白名单(allowedFolders)。白名单是默认拒绝的:不放开的话,账号 根本看不到自己那块地。第 3 步建出来的空目录在账号写入失败时会被回收(只删空目录)。
  5. 登记进 dsh 工作区列表:通过宿主的 workspaceRegistry 服务(运行期 ctx.inject,不写死依赖)。 这一步失败不回滚——目录和账号都是真的,接口把 registered: false 和原因一起返回,界面提示 「用户已创建、目录已建好,但未登记进工作区列表」。dsh 的工作区列表用 realpath 后的规范路径,所以登记返回的 规范路径也会一并加进白名单(软链接根目录 / Windows 大小写差异下,账号才看得到自己的那块地)。
  6. 认领归属:把该路径记到这个账号名下——别人看不到、也不能删改(见「工作区归属跟踪」)。

接口:POST /user-management/api/users 增加 createWorkspace: true 与可选的 workspacePath(留空 = 服务端按默认规则生成), 响应多一个 workspace 报告({ ok, path, source, directory, registered, owner, error },没勾选时为 null); GET /user-management/api/workspace-root(管理员)回 { root, sep },新增用户弹窗用它预填路径。 根路径优先取 user-management.workspaceRoot,未配置则问宿主的 sandboxPolicy.workspaceRoot(dsh 默认就是进程 cwd, 即 harness 所在目录),最后兜底 process.cwd()。

被拒绝时提示什么(1.5.1 起)

被网关拦下的请求(没有建工作区的权限、目录不在白名单、配额用完、上传 / git 被关、沙盒提权被拒……)过去一律回 HTTP 403。 而 dsh 的浏览器客户端把任何非 2xx 都当成传输故障、根本不读响应体,于是用户看到的是:

workspace create failed: internal: transport failure for /api/workspace.create: HTTP 403

这读起来像程序坏了,而不像「你的账号没有这个权限」。现在这类拒绝改走 RPC 协议自带的错误位:HTTP 200 + result.ok: false, 原因写在 error.message 里,各处 dsh 界面都会原样显示:

workspace create failed: internal: 本账号没有创建 / 管理工作区的权限(请联系管理员在「用户管理 → 编辑用户 → 权限与配额」中开启「允许创建工作区」)

只有不是 RPC 调用的请求(没有 rpcId 可回显的裸请求、GET)仍回原来的 403 JSON —— 给它们编一个 rpcId 只会把「被拒绝」 变成客户端的协议错误,比原样回报更糟。

顺带修好一个盲区:被拒绝的请求以前完全不进操作日志(它没到上游,写日志的钩子永远不会触发)。现在会记一行 403 + 已拒绝:<原因>,管理员在「用户管理 → 操作日志」里直接看得到谁、想做什么、为什么被挡下来。

控制台页头的位置(1.5.3 起)

「用户管理」整页原来的页头是一条横贯屏幕的标题栏:宽屏上标题孤零零贴在左上角、按钮贴在右上角,和下面的 tab 条与内容列毫无关系。现在标题与 tab 收进居中内容列,而「退出登录」单独钉在窗口的右上角:

[⏻ 退出登录]                                  ← 贴窗口右上角(与「编辑用户」页那颗同一位置)
     用户管理                                  ← 内容列内,与 tab 条左对齐
     [用户][登录记录][访问记录][操作日志][IP 封禁][证书][2FA]  [返回设置]
     …表格 / 台账…

「用户管理」这一行是「这一页是什么」,与内容左对齐;「返回设置」与 tab 条同一条线、右边缘对齐。退出登录之所以 不跟着内容列走,是因为上一层「编辑用户」页把自己的退出登录放在窗口右上角——同一个按钮不能因为进出一层就横向挪位。 普通用户没有 tab 条,但「返回设置」仍然落在同一条线上(右对齐),两个视图因此读起来还是同一页。观察者模式 (dsh-passwords 在前)没有退出登录,只有「返回设置」。

「退出登录」现在先问一句(确定退出登录?退出后需要重新输入用户名和密码。):控制台页头和账号菜单里各有一个入口, 且都离用户正在做的事只差一次误点——确认放在共用的 doLogout() 里,谁都绕不过去。

「编辑用户」整页自带一个退出登录,钉在页面最右上角(关闭 / 保存之后隔一条竖线,不跟表单按钮混在一起):

┌ 编辑用户 · bob ─────────────────────────────────────────────┐
│                             [关闭] [保存] │ [⏻ 退出登录]    │
└─────────────────────────────────────────────────────────────┘

同一时刻屏幕上只有一个退出登录:打开这一页时,控制台那个窗口右上角的退出登录收起来(位置本来就是同一个,只是避免两颗同时存在)。不透明浮层确实盖住了它, 但「盖住」不等于「不在文档里」——那时它离一次 Tab+Enter 只有一步,而这颗按钮结束的是整个会话。收放由一个模块级的 小订阅(setFullPageOverlay)广播,编辑页挂载时置位、卸载时清除。

欢迎页右上角的账号信息(1.5.4 起)

会话头部只在有会话时才存在,于是落地页(欢迎页 / 新建会话页)成了唯一一个不说「你是谁」的页面——连账号菜单里 仅有的两件事(改密码、退出登录)也没地方点。现在账号芯片(头像 + 用户名,点开即账号菜单)还会落在shell 的全局 overlay 座位(ui-layout 的 shell.overlay,一个可叠加的 list 槽)上,固定在窗口右上角;一旦有会话成为当前会话, 这个芯片立刻消失,把位置让回会话头部那一个,不会出现两个。

实现上用的是该座位给每个全局占用者注入的 useSessions:state.current 就是框架自己对「现在有没有打开会话」的回答, 不需要去偷看 DOM。hero 上的三个座位(品牌标记 / 工作区选择器 / 代理预设)都是单占位且已被占用,所以走 overlay 座位。

在设置页里改(1.1.0 起)

上面这些字段也可以在 设置 → 通用设置 里改,就在「语言」「外观」那几行下面:「登录页与策略配置」一行, 默认收起着(通用列里都是紧凑的单行偏好,全部字段展开会把这一页压垮),行内摘要写着登录页当前显示 什么、有几项被改过,点「展开」出现完整表单——分「登录页品牌」「登录免责声明」「会话与请求策略」三组, 其中声明正文是一个多行输入框(普通单行输入会把条文挤成一段)。本插件在浏览器侧把这个设置命名空间绑成 settingsScope.bind({ namespace: 'user-management' }),并注册进 settings.general.item 槽——宿主注册 命名空间、浏览器注册控件,两边经由同一份 settings 文档同步。

表单行为:改过没存的字段标「未保存」;写入过 settings.yaml 的字段标「已自定义」并带「恢复默认」 (unset,清掉覆盖回到内置值,也让 settings.yaml 保持干净);文本框留空等于清除覆盖;数字字段按 schema 的下限校验(比如请求体上限 ≥ 1024);保存按字段顺序逐个写入,任何一个值非法就整体不落盘并提示是哪一项。 远程浏览器访问时设置是只读的(settings RPC 只走回环),这一行会直接说明并让你改服务器上的 settings.yaml; 装了旧版插件时它也会明说「这些新字段保存了也不会生效」。

刻意没放进设置页的字段:listenHost / port / plaintext / sites / enabled 会重启监听器——那正是承载 这个设置页的东西,从 UI 里翻可能把自己关在门外;trustedSecret / setupKey 是密钥,不该经浏览器表单往返。 这些继续只在 settings.yaml 里配。

并发与登录性能(1.7.0 起)

登录路径上有两处共享资源,原来都被「占着不放」,表现就是「一个人登录 / 一开控制台,别人就登不进来」:

  • 密码校验不再占用事件循环。校验(scrypt,N=16384,本机实测约 27 ms/次)以前走同步的 scryptSync, 每一次登录尝试都会把整个进程——代理、WebSocket、其他人的请求、登录页本身——冻住这么久。几个人同时登录, 或有人反复试密码,就是全站卡住。现在改成异步 scrypt(走 libuv 线程池)+ bcryptjs 的异步 compare: 同样的活只花调用者自己的延迟,不再花别人的。免登录的 /api/login 是匿名可打的,所以这条同时也是挡洪水的一道墙。
  • 哈希并发有闸门。scrypt 用的是 libuv 线程池(默认 4 个线程),而这个池子同时承载本插件所有异步文件写入 (两本台账、users.json、sessions.json)。所以同时在跑的哈希最多 4 个,多出来的在事件循环里排队—— 事件循环保持空闲,磁盘写入不会被饿死。要提高登录并发,先给 dsh 进程设 UV_THREADPOOL_SIZE=8 (必须在进程启动前设置,运行期改无效)。
  • 上游连接池不再有固定上限。到宿主的连接池原来是 maxSockets: 64:池满时 Node 不是拒绝而是排队, 而且没有超时。长连接响应(事件流、大文件、一直开着的标签页)会长期占着槽位,64 个占满之后,后面所有人的请求 (包括刚登录那位的首页)就一直等在队列里,页面上什么都不说。现在活跃连接不设上限(上游自己就是背压), 只限定空闲连接(maxFreeSockets: 64)。
  • 请求被中断时不再拖着上游连接。刷新、关标签、取消 fetch 之后,代理原来会继续把上游响应读进一个没人看的 socket, 那条池子里的连接会一直忙到上游结束为止(对事件流来说是永远)。现在浏览器先走,上游请求立刻被中断; 正常读完的响应不受影响——它的连接该回池子还是要回池子。
  • 两本台账各用各的写入队列。它们本来就是两个文件、两个上限,但共用一条写队列会把关系搞反:操作日志是每个请求一行, 而登录台账是登录接口自己要等的那一行。合在一条队列上,一个繁忙下午的操作日志就成了每次登录记录的排队时间。

回归测试在 test/login-concurrency.test.mjs:8 个账号并发登录时事件循环必须仍在走动(改回同步实现,该项会以 「ticked 0 times」失败),80 个并发请求必须一个不落地到达上游(改回 64 上限,会以「只到 64 个」失败)—— 两条都是先在旧实现上确认为失败、再在修复后确认为通过的。

数据文件

都在 $DSH_HOME/user-management/(默认 ~/.dsh/user-management/,0600,原子写):

文件内容
users.json账号(scrypt 哈希;迁移进来的账号保留 bcrypt,首次登录成功后自动升级为 scrypt;含 department 所属部门)
sessions.json会话令牌(重启不掉线,7 天滑动过期)
activity.jsonl / audit.jsonl登录与访问台账 / 操作日志(滚动保留最近若干条)
bans.jsonIP 封禁
usage.json按账号的当日活跃秒数与小时窗口 token(隔日自动清理)
workspaces.json工作区归属(路径 → 账号)

从 dsh-passwords 迁移

账号、权限与配额可由 scripts/import-from-dsh-passwords.mjs 一次性导入(读取源库时复用 dsh-passwords 自己的模块, 因为它的用户名是 AES-GCM 密文 + HMAC 索引):

node scripts/import-from-dsh-passwords.mjs --from E:\path\to\dsh-passwords --dry-run
node scripts/import-from-dsh-passwords.mjs --from E:\path\to\dsh-passwords
# 同名账号两边密码不同时,以源端为准(迁移期间通常正是你要的)
node scripts/import-from-dsh-passwords.mjs --from E:\path\to\dsh-passwords --overwrite-credentials

迁移是幂等的:重复运行只会更新权限,不会重复建号;默认不覆盖已有账号的密码。

测试

npm test          # 248 项:策略纯函数、store、API 层、客户端契约、以及真实网关的端到端用例
npm run check     # 全量语法检查

端到端用例会起真实网关(明文模式 + 假上游)验证:工作区过滤含 gzip、目录门与请求体重放、沙盒提权拦截与审批改写、 上传 / git / 运维面门控、历史降级与隐藏字符清洗、配额拦截、归属认领与释放。

test/dsh-0.1.5-wire.test.mjs 另外覆盖第二代 wire:假的 dsh 上游在 /api/remote.mux 上做真实的 WebSocket 握手,按 streamId 收发 JSON 文本帧,验证 workspace/follow 的 baseline / upsert / order 过滤、 session/follow 的 snapshot 与逐条事件钳制、$events waterfall 记录 + $events/result 审批拒绝、 session/page 改写、session/control 的权限投影钳制、以及 workspaceFiles/readAll 门控; 纯函数部分(dsh-wire.js 的帧计划、mux-filter.js 的编解码)单独覆盖丢弃 / 透传 / 分片放行语义。

test/session-visibility.test.mjs 覆盖会话可见性:越界 cwd 的行被摘掉、没 cwd 与子代理行保留、 别人认领目录(含其子目录)里的会话被摘掉(blank 也照摘)、自己认领的共享目录不摘、管理员原样、 空白名单全摘、session.search 跟随列表的隐藏清单、以及 SSE 那一路——假上游按真实 data: {…}\n\n 写 host/session-added,且故意从块中间切成两个 chunk 写出来,验证缓冲与逐块判定。 所有响应都用真实信封形状,裸 { items } 只作为兼容形状另测一次。

卸载 / 回滚

dsh plugin --profile web remove kczx-user-management

数据文件不会被删除,重新装回即恢复。若在合并部署中回退到「观察层」形态,见 MERGED-DEPLOYMENT.md。

已知边界

  • 执行只对本地存在的账号生效:签名身份 / 未知用户名按「不限制」处理,避免迁移期误锁人。
  • 门只管走门的人:若另有插件把 dsh 宿主绑到 0.0.0.0(例如 dsh-lan-access),直连宿主端口即可绕过登录, 且绕过流量不会出现在任何一本台账里。要真正的强制约束,宿主端口必须保持回环。
  • 工作区白名单的语义:空名单 = 全部禁止(1.0.4 起;此前是"允许全部")。升级时会对老记录做一次性迁移——把"空名单"写成显式 的 *(允许全部),保持账号原有的可达范围;迁移只跑一次(留下 workspace-scope-migration.json 标记),之后你手工清空白名单 才等于全部禁止。新建账号默认空 = 无工作区权限。
  • token 配额依赖客户端上报:浏览器侧被禁用或页面未加载时不计费(时长配额不受影响,它在服务端计时)。
  • 会话历史要缓冲后才能改写:历史响应是一个 JSON 文档(长会话实测 9–16 MB),网关必须先缓冲才能做沙盒降级与隐藏字符 清洗,因此有 historyMaxBytes 上限(默认 64 MiB)。超过上限的会话会 fail-closed 返回 502,UI 显示「历史加载失败」—— 调大该值即可;1.0.0 之前用代理默认的 8 MiB,长会话必然踩中。
  • 第二代 wire 的强制有两条前提:一是浏览器走本网关(直连宿主端口没有任何强制,见上),二是帧是 单帧 JSON 文本。分片的 WebSocket 消息、二进制帧、以及 ping/pong 控制帧一律原样放行——为了一个分片帧去缓冲 别人整条流不划算;ws 宿主与浏览器发这些控制消息都不分片,所以这条边界在实践中碰不到。
  • SSE 过滤会「认输」:一个块迟迟不结束、或缓冲超过 1 MiB,就原样转发并停止改写这条连接(宁可漏拦,不能把实时流卡住)。
  • A 方案只挡「看得到」:知道 sessionId 仍然能通过 session/page / session/follow(0.1.1 是 session.history) 读别人的会话——那是 B 方案的事。
  • 审批记忆是进程内的:网关在 mux 上看到 approval/request 的 waterfall 帧后记住它的 eventId。 网关重启期间若浏览器重连,宿主会重放未决的 waterfall(同一个 eventId),记忆随之重建;万一两条依据都没命中 (既没记住、值也不是 allowed-once),该次应答按原样放行——这是有意的取舍:把 $events/result 一律拒掉 会连 ask_user_question 的答案一起吃掉。
  • 整文件读取按「整文件」划界:readAll / readRelated 受开关管,窗口化的 readBytes 不受管——反复调它理论上 也能把文件搬走,但它是图片预览与分页阅读的底座,关掉会连文件浏览器一起关掉。
  • /api/login 没有内置速率限制:连续失败不会自动锁定账号(loginFailLimit / lockoutSeconds 是保留字段,见上), 封禁靠管理员手工加 IP 封禁。1.7.0 起校验不再阻塞事件循环、并发也有闸门,但「有人一直拿密码来试」这件事本身需要你在 前面放一层限流(反向代理 / WAF),或直接把来源 IP 封掉。
  • 上游 @weibaohui/user-management(0.9.5)与本包 kczx-user-management 是两个不同的包,互不覆盖,pnpm update 不会把两者串在一起。

License

MIT(沿用上游许可)。