kczx-user-management 1.6.0 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -88,7 +88,7 @@ node scripts/ensure-deps.mjs # 仅当上述解析不到 sche
88
88
  |---|---|
89
89
  | 用户增删改查 | 用户表每行一个「编辑」**全屏页**:基本资料(部门 / 角色)+ 账号操作(重置密码 / 重置两步验证 / 禁用启用 / 删除)+ 权限与配额,集中一屏;改动类走「保存」,破坏性操作仍是各自带确认的按钮 |
90
90
  | 所属部门(1.6.0 起支持多层级) | 账号资料字段,支持**多层级路径**:用 `/` 分层,例如 `和平分局/刑侦大队/一中队`(`\` 也认,存的时候统一成 `/`)。新增用户时可填,编辑页可随时改或清空;编辑时框下会把层级摊开显示,并列出**已在用的层级**供一键接续;变更写进操作日志。规则(服务端为准,界面同款提示在前面):最多 **5 级**、每一级 ≤ **24 字**、整串 ≤ **64 字**(含分隔符);层级里不能有 `\ / : * ? " < > |`,也不能是 `.` / `..`——因为这些层级要变成目录名。写不进去的值**报错而不是悄悄改写**(理由同长度上限) |
91
- | 新建账号即建工作区 | 新增用户时勾选「同时创建该用户的工作区」:路径预填 `<harness 根>/<部门各级>/<用户名>`,**一级部门一个目录**(`…\和平分局\刑侦大队\zhang`;可改成任意绝对路径);提交后目录按需创建、登记进 dsh 工作区列表,并把该路径写进这个账号的工作区白名单。部门不合法时**先报 400,再建目录**——不会留下半棵树 |
91
+ | 新建账号即建工作区 | 新增用户时勾选「同时创建该用户的工作区」:路径预填 `<harness 根>/workspace/<部门各级>/<用户名>`,**一级部门一个目录**(`…\workspace\和平分局\刑侦大队\zhang`;可改成任意绝对路径);提交后目录按需创建、登记进 dsh 工作区列表,并把该路径写进这个账号的工作区白名单。部门不合法时**先报 400,再建目录**——不会留下半棵树 |
92
92
  | 工作区白名单 | **留空 = 不允许任何工作区**(白名单默认拒绝);填写后只允许这些目录下的工作区;条目 `*` = 允许全部。Windows 路径折叠大小写,末尾斜杠自动忽略 |
93
93
  | 工作区归属跟踪 | 谁创建的工作区归谁:别人看不到、也不能删改;删除后释放,账号删除后清理 |
94
94
  | 会话可见性(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** 的行保留——「判断不了」不等于「不该看」 |
@@ -231,7 +231,8 @@ user-management:
231
231
 
232
232
  ### 部门层级(1.6.0 起)
233
233
 
234
- 部门的「多层级」不是一张独立的部门表,而是**一个字符串里的路径**:`和平分局/刑侦大队/一中队`。这么定有两个好处——老数据不用迁移(原来的 `平台组` 就是单级),而且它正好就是工作区目录的形状:勾了「同时创建该用户的工作区」的账号,目录会一级一个文件夹地铺开。
234
+ 部门的「多层级」不是一张独立的部门表,而是**一个字符串里的路径**:`和平分局/刑侦大队/一中队`。这么定有两个好处——老数据不用迁移(原来的 `平台组` 就是单级),而且它正好就是工作区目录的形状:勾了「同时创建该用户的工作区」的账号,目录会一级一个文件夹地铺开
235
+ (都挂在 `<harness 根>/workspace/` 这一层下面)。
235
236
 
236
237
  | 规则 | 值 |
237
238
  |---|---|
@@ -248,7 +249,8 @@ user-management:
248
249
  ### 新建用户时一并创建工作区(1.5.0 起)
249
250
 
250
251
  「用户 → 新增用户」多了一个复选框「同时创建该用户的工作区」。勾上后出现路径框,**预填**默认路径
251
- `<harness 根>/<部门>/<用户名>`(部门留空就少一层,只到用户名),可以直接改成任意绝对路径;下方一行小字写着默认值,
252
+ `<harness 根>/workspace/<部门各级>/<用户名>`(部门留空就少一层,只到用户名;所有账号都在同一个 `workspace` 目录下,
253
+ 根目录因此不会被一堆部门目录铺满),可以直接改成任意绝对路径;下方一行小字写着默认值,
252
254
  改过之后还有「恢复默认」。不勾选则一切照旧。
253
255
 
254
256
  提交时服务端按这个顺序做事,**能失败的都在写账号之前失败**:
package/client/bundle.js CHANGED
@@ -309,7 +309,7 @@ window.__ModuleLoader__.load({
309
309
  cfgFieldOtpFailLimit: '两步验证失败上限',
310
310
  cfgFieldOtpLockoutSeconds: '两步验证锁定时长(秒)',
311
311
  cfgFieldWorkspaceRoot: '新账号工作区根路径',
312
- cfgHintWorkspaceRoot: '新建用户勾选「同时创建该用户的工作区」时,默认路径为 <根路径>/<部门>/<用户名>。留空 = 用宿主提供的工作区根路径(dsh 默认是 harness 所在目录)。',
312
+ cfgHintWorkspaceRoot: '新建用户勾选「同时创建该用户的工作区」时,默认路径为 <根路径>/workspace/<部门各级>/<用户名>。留空 = 用宿主提供的工作区根路径(dsh 默认是 harness 所在目录)。',
313
313
  }
314
314
 
315
315
  const EN = {
@@ -576,7 +576,7 @@ window.__ModuleLoader__.load({
576
576
  cfgFieldOtpFailLimit: '2FA failure limit',
577
577
  cfgFieldOtpLockoutSeconds: '2FA lockout (seconds)',
578
578
  cfgFieldWorkspaceRoot: 'Workspace root for new accounts',
579
- cfgHintWorkspaceRoot: 'When a new account is created with “also create its workspace”, the default path is <root>/<department>/<username>. Empty = use the host’s own workspace root (dsh defaults to the harness directory).',
579
+ cfgHintWorkspaceRoot: 'When a new account is created with “also create its workspace”, the default path is <root>/workspace/<department levels>/<username>. Empty = use the host’s own workspace root (dsh defaults to the harness directory).',
580
580
  }
581
581
 
582
582
  /** Ledger event type → locale key (also the login-log type filter). */
@@ -634,6 +634,13 @@ window.__ModuleLoader__.load({
634
634
  * one that decides.
635
635
  */
636
636
  const DEPARTMENT_MAX_DEPTH = 5
637
+
638
+ /**
639
+ * The directory every default workspace lives under (mirror of
640
+ * WORKSPACE_SUBDIR in src/workspace-create.js): the preview must name the same
641
+ * folder the server will build, level for level.
642
+ */
643
+ const WORKSPACE_SUBDIR = 'workspace'
637
644
  const DEPARTMENT_MAX_LEVEL_LEN = 24
638
645
  const DEPARTMENT_MAX_LEN = 64
639
646
 
@@ -676,7 +683,7 @@ window.__ModuleLoader__.load({
676
683
 
677
684
  /**
678
685
  * The default workspace path, as the create-user dialog shows it:
679
- * `<root>/<一级>/<二级>/…/<username>`.
686
+ * `<root>/workspace/<一级>/<二级>/…/<username>`.
680
687
  *
681
688
  * This is the DISPLAY twin of the host half's rule (src/workspace-create.js):
682
689
  * one folder PER department level, each folded into a directory name, joined
@@ -689,7 +696,7 @@ window.__ModuleLoader__.load({
689
696
  // No root = no preview: the dialog then asks for an absolute path instead of
690
697
  // showing a path relative to nothing.
691
698
  if (base === '') return ''
692
- const parts = [base]
699
+ const parts = [base, WORKSPACE_SUBDIR]
693
700
  // The username is folded by the same rule as a level: it is one segment too.
694
701
  for (const raw of [...departmentLevels(department), username]) {
695
702
  const segment = String(raw === undefined || raw === null ? '' : raw)
@@ -1576,7 +1583,8 @@ window.__ModuleLoader__.load({
1576
1583
  *
1577
1584
  * Split out from the tab that opens it because the workspace half is the part
1578
1585
  * worth pinning down: the checkbox reveals a path box pre-filled with the
1579
- * default (`<harness root>/<department>/<username>`) and left editable, so the
1586
+ * default (`<harness root>/workspace/<department levels>/<username>`) and left
1587
+ * editable, so the
1580
1588
  * admin either accepts it or types another absolute path. As a component of its
1581
1589
  * own it can be rendered on demand — the tab's dialog otherwise only exists
1582
1590
  * after a click, which a contract test cannot perform.
package/client/index.js CHANGED
@@ -299,7 +299,7 @@ const ZH = {
299
299
  cfgFieldOtpFailLimit: '两步验证失败上限',
300
300
  cfgFieldOtpLockoutSeconds: '两步验证锁定时长(秒)',
301
301
  cfgFieldWorkspaceRoot: '新账号工作区根路径',
302
- cfgHintWorkspaceRoot: '新建用户勾选「同时创建该用户的工作区」时,默认路径为 <根路径>/<部门>/<用户名>。留空 = 用宿主提供的工作区根路径(dsh 默认是 harness 所在目录)。',
302
+ cfgHintWorkspaceRoot: '新建用户勾选「同时创建该用户的工作区」时,默认路径为 <根路径>/workspace/<部门各级>/<用户名>。留空 = 用宿主提供的工作区根路径(dsh 默认是 harness 所在目录)。',
303
303
  }
304
304
 
305
305
  const EN = {
@@ -566,7 +566,7 @@ const EN = {
566
566
  cfgFieldOtpFailLimit: '2FA failure limit',
567
567
  cfgFieldOtpLockoutSeconds: '2FA lockout (seconds)',
568
568
  cfgFieldWorkspaceRoot: 'Workspace root for new accounts',
569
- cfgHintWorkspaceRoot: 'When a new account is created with “also create its workspace”, the default path is <root>/<department>/<username>. Empty = use the host’s own workspace root (dsh defaults to the harness directory).',
569
+ cfgHintWorkspaceRoot: 'When a new account is created with “also create its workspace”, the default path is <root>/workspace/<department levels>/<username>. Empty = use the host’s own workspace root (dsh defaults to the harness directory).',
570
570
  }
571
571
 
572
572
  /** Ledger event type → locale key (also the login-log type filter). */
@@ -624,6 +624,13 @@ function interpolate(template, vars) {
624
624
  * one that decides.
625
625
  */
626
626
  const DEPARTMENT_MAX_DEPTH = 5
627
+
628
+ /**
629
+ * The directory every default workspace lives under (mirror of
630
+ * WORKSPACE_SUBDIR in src/workspace-create.js): the preview must name the same
631
+ * folder the server will build, level for level.
632
+ */
633
+ const WORKSPACE_SUBDIR = 'workspace'
627
634
  const DEPARTMENT_MAX_LEVEL_LEN = 24
628
635
  const DEPARTMENT_MAX_LEN = 64
629
636
 
@@ -666,7 +673,7 @@ function departmentProblem(levels, t) {
666
673
 
667
674
  /**
668
675
  * The default workspace path, as the create-user dialog shows it:
669
- * `<root>/<一级>/<二级>/…/<username>`.
676
+ * `<root>/workspace/<一级>/<二级>/…/<username>`.
670
677
  *
671
678
  * This is the DISPLAY twin of the host half's rule (src/workspace-create.js):
672
679
  * one folder PER department level, each folded into a directory name, joined
@@ -679,7 +686,7 @@ function workspaceDefaultPath(root, sep, department, username) {
679
686
  // No root = no preview: the dialog then asks for an absolute path instead of
680
687
  // showing a path relative to nothing.
681
688
  if (base === '') return ''
682
- const parts = [base]
689
+ const parts = [base, WORKSPACE_SUBDIR]
683
690
  // The username is folded by the same rule as a level: it is one segment too.
684
691
  for (const raw of [...departmentLevels(department), username]) {
685
692
  const segment = String(raw === undefined || raw === null ? '' : raw)
@@ -1566,7 +1573,8 @@ function EditUserPage({ user, me, departments, onClose, onDone, onTempPassword,
1566
1573
  *
1567
1574
  * Split out from the tab that opens it because the workspace half is the part
1568
1575
  * worth pinning down: the checkbox reveals a path box pre-filled with the
1569
- * default (`<harness root>/<department>/<username>`) and left editable, so the
1576
+ * default (`<harness root>/workspace/<department levels>/<username>`) and left
1577
+ * editable, so the
1570
1578
  * admin either accepts it or types another absolute path. As a component of its
1571
1579
  * own it can be rendered on demand — the tab's dialog otherwise only exists
1572
1580
  * after a click, which a contract test cannot perform.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kczx-user-management",
3
- "version": "1.6.0",
3
+ "version": "1.6.3",
4
4
  "type": "module",
5
5
  "description": "dsh 插件 · 登录门 + 账号 / 权限 / 配额 / 审计台账(dsh-passwords 的功能已并入):HTTPS 登录网关与自签证书、用户增删改查(可一并创建工作区)、工作区白名单与归属跟踪、每小时 token 与每日时长配额、沙盒档位下限、上传/git 开关、三本审计台账(登录/访问/操作)、IP 封禁、TOTP 两步验证。",
6
6
  "license": "MIT",
package/src/dsh-wire.js CHANGED
@@ -66,6 +66,9 @@ const SESSION_SEARCH_RE = /^\/api\/session[.\/]search$/
66
66
  /** 0.1.1's host event stream (SSE) — the live session-list push rides here. */
67
67
  const HOST_EVENT_STREAM_RE = /^\/api\/events[.\/]host$/
68
68
 
69
+ /** 0.1.1's session event stream (SSE) — the live permission projection rides here. */
70
+ const MUX_EVENT_STREAM_RE = /^\/api\/events[.\/]mux$/
71
+
69
72
  /** The forwarded event that asks a human for an allow/reject decision. */
70
73
  const APPROVAL_EVENT = 'approval/request'
71
74
 
@@ -152,6 +155,39 @@ function parseJsonObject(text) {
152
155
  return decoded
153
156
  }
154
157
 
158
+ /**
159
+ * Parse one host→browser message of the 0.1.1 event-stream WebSocket.
160
+ *
161
+ * The generation difference that matters here: /api/events.mux and
162
+ * /api/events.host are not SSE responses to a browser — the host upgrades
163
+ * them to a WebSocket and sends ONE JSON ServerRequest envelope per text
164
+ * message ({ type:'server-request', rpcId, method, payload }, where method
165
+ * mirrors payload.type). The browser sends no business data on either
166
+ * socket; a client message makes the host close the connection.
167
+ *
168
+ * A validating reader, for the same reason as the mux half: an unparsed
169
+ * message is forwarded byte for byte, so a false positive would mean
170
+ * mis-reading somebody else's traffic.
171
+ */
172
+ function parseServerRequestMessage(text) {
173
+ const value = parseJsonObject(text)
174
+ if (value === null) return null
175
+ if (value.type !== 'server-request') return null
176
+ if (value.payload === null || typeof value.payload !== 'object' || Array.isArray(value.payload)) return null
177
+ return value
178
+ }
179
+
180
+ /**
181
+ * Re-encode one replaced message of the same stream.
182
+ *
183
+ * `method` is kept in step with `payload.type` because the host writes both
184
+ * from one frame, and a client that reads `method` would otherwise be told
185
+ * about a frame the message is not carrying.
186
+ */
187
+ function encodeServerRequestMessage(message, payload) {
188
+ return JSON.stringify({ ...message, method: payload.type, payload })
189
+ }
190
+
155
191
  /** One workspace entry as the registry projects it. */
156
192
  function isWorkspaceView(value) {
157
193
  return value !== null && typeof value === 'object'
@@ -270,25 +306,82 @@ function sessionRowVisible(row, decide) {
270
306
  }
271
307
 
272
308
  /**
273
- * One `host/session-added` frame (0.1.1's host event stream), or null when the
274
- * frame is something else.
309
+ * Decide what to do with one frame of the host event stream.
310
+ *
311
+ * Why this is not the unary list rewrite alone: the 0.1.1 host PUSHES its
312
+ * registry news to every connected client over one stream — /api/events.host,
313
+ * which the host upgrades to a WebSocket (one ServerRequest text message per
314
+ * frame). A sidebar therefore grows rows for somebody else's conversation and
315
+ * GROUPS for somebody else's workspace without a single workspace.list or
316
+ * session.list call, and re-fetching the list (a page refresh) makes the
317
+ * leaked row vanish again: the unary answer was filtered, the push was not.
318
+ * That asymmetry is the whole reason this planner exists.
319
+ *
320
+ * Every frame carrying a workspace is judged by the SAME rules the list
321
+ * rewrite uses (visibleView / visibleId), so the two can never disagree:
322
+ * - host/session-added — judged by its cwd, exactly like a list row;
323
+ * - host/workspace-changed — the full new WorkspaceView after every durable
324
+ * mutation, which includes ATTACHING a session to a workspace (creating a
325
+ * session in it is what makes an invisible workspace reappear);
326
+ * - host/workspace-removed — nothing to hide (it removes a row), but the
327
+ * id→path harvest is dropped with it;
328
+ * - host/workspace-order-changed — the registry order, filtered to the ids
329
+ * this account may see, so a hidden workspace is not even named.
275
330
  *
276
- * The session list is not only a unary answer: the host also PUSHES every newly
277
- * created session to every connected client, which is how a row for somebody
278
- * else's conversation appears in a sidebar without a single `session.list`
279
- * call. The frame carries the same `cwd` the list row does, so it is judged by
280
- * the same rule.
331
+ * `views` carries EVERY workspace the frame described, visible or not, so the
332
+ * caller can harvest the id→path pairs a later session.create needs; the same
333
+ * posture as the workspace/follow stream of the newer generation.
281
334
  *
282
- * @returns 'drop' when the frame must not reach the browser, else null.
335
+ * @param frame - one ServerRequest envelope as the host writes it.
336
+ * @param decide - `{ visibleView(view), visibleId(id), sessionVisible(cwd) }`.
337
+ * @returns `{ action, value, views, removedId }` where action is
338
+ * 'pass' | 'drop' | 'replace' and `value` is the replacement payload
339
+ * (only for 'replace').
283
340
  */
284
- function planHostSessionAdded(frame, decide) {
285
- if (frame === null || typeof frame !== 'object') return null
286
- if (frame.type !== 'server-request') return null
341
+ function planHostFrame(frame, decide) {
342
+ const none = { action: 'pass', value: null, views: [], removedId: null }
343
+ if (frame === null || typeof frame !== 'object') return none
344
+ if (frame.type !== 'server-request') return none
287
345
  const payload = frame.payload
288
- if (payload === null || typeof payload !== 'object' || payload.type !== 'host/session-added') return null
289
- return sessionRowVisible(payload, decide) ? null : 'drop'
346
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return none
347
+
348
+ if (payload.type === 'host/session-added') {
349
+ return sessionRowVisible(payload, decide) ? none : { ...none, action: 'drop' }
350
+ }
351
+
352
+ if (payload.type === 'host/workspace-changed') {
353
+ const workspace = payload.workspace
354
+ if (!isWorkspaceView(workspace)) return none
355
+ const views = [workspace]
356
+ // Dropping the push is what keeps an invisible workspace out of the
357
+ // client's list: the browser upserts whatever arrives, and its own list is
358
+ // never re-filtered until the next full fetch.
359
+ return decide.visibleView(workspace)
360
+ ? { ...none, views }
361
+ : { ...none, action: 'drop', views }
362
+ }
363
+
364
+ if (payload.type === 'host/workspace-removed') {
365
+ return {
366
+ ...none,
367
+ removedId: typeof payload.workspaceId === 'string' ? payload.workspaceId : null,
368
+ }
369
+ }
370
+
371
+ if (payload.type === 'host/workspace-order-changed') {
372
+ if (!Array.isArray(payload.workspaceIds)) return none
373
+ const kept = payload.workspaceIds.filter((id) => typeof id !== 'string' || decide.visibleId(id))
374
+ if (kept.length === payload.workspaceIds.length) return none
375
+ return { ...none, action: 'replace', value: { ...payload, workspaceIds: kept } }
376
+ }
377
+
378
+ // host/session-removed, host/session-status, host/agent-error,
379
+ // host/archived-sessions-changed, host/remote-event and anything a later
380
+ // host adds: no workspace and no session ROW to judge, so they pass.
381
+ return none
290
382
  }
291
383
 
384
+
292
385
  /**
293
386
  * One `api-session/added` forwarded event (0.1.2+), or null when the item is
294
387
  * something else.
@@ -306,6 +399,35 @@ function planForwardedSessionAdded(value, decide) {
306
399
  return sessionRowVisible(summary, decide) ? null : 'drop'
307
400
  }
308
401
 
402
+ /**
403
+ * Clamp one `session/projection` or `session/event` frame from 0.1.1's session
404
+ * event stream, or null when the frame is something else (or needed no change).
405
+ *
406
+ * The live half of the sandbox cap. The unary history response carries the
407
+ * projection once, but every later change — including the one a picker click
408
+ * causes — arrives as a push on `/api/events.mux`, so a gateway that rewrites
409
+ * only the response leaves the browser showing a mode the account was refused.
410
+ *
411
+ * @param frame - one parsed SSE frame (the `server-request` envelope).
412
+ * @param clamp - the caller's `clampSessionHistorySandbox`; it mutates in place
413
+ * and returns whether anything changed.
414
+ * @returns the same frame when it changed, else null (caller keeps the bytes).
415
+ */
416
+ function planMuxStreamFrame(frame, clamp) {
417
+ if (frame === null || typeof frame !== 'object' || frame.type !== 'server-request') return null
418
+ const payload = frame.payload
419
+ if (payload === null || typeof payload !== 'object') return null
420
+ if (payload.type === 'session/projection' && payload.key === 'permissions') {
421
+ return clamp(payload) ? frame : null
422
+ }
423
+ if (payload.type === 'session/event' && payload.event !== null && typeof payload.event === 'object') {
424
+ const kind = payload.event.type
425
+ if (kind !== 'permission/preset' && kind !== 'sandbox/mode' && kind !== 'approval/policy') return null
426
+ return clamp(payload) ? frame : null
427
+ }
428
+ return null
429
+ }
430
+
309
431
  /**
310
432
  * Drop the session rows an account may not see.
311
433
  *
@@ -458,6 +580,7 @@ export {
458
580
  SESSION_LIST_RE,
459
581
  SESSION_SEARCH_RE,
460
582
  HOST_EVENT_STREAM_RE,
583
+ MUX_EVENT_STREAM_RE,
461
584
  APPROVAL_EVENT,
462
585
  APPROVAL_GRANT,
463
586
  APPROVAL_DENIAL,
@@ -470,11 +593,14 @@ export {
470
593
  isWorkspaceView,
471
594
  parseMuxClientMessage,
472
595
  parseMuxServerMessage,
596
+ parseServerRequestMessage,
597
+ encodeServerRequestMessage,
473
598
  planWorkspaceFollowFrame,
474
599
  planSessionListFilter,
475
600
  planSessionSearchFilter,
476
- planHostSessionAdded,
601
+ planHostFrame,
477
602
  planForwardedSessionAdded,
603
+ planMuxStreamFrame,
478
604
  sessionRowVisible,
479
605
  isApprovalGrant,
480
606
  denyRemoteEventResult,
@@ -60,6 +60,7 @@ import {
60
60
  APPROVAL_EVENT,
61
61
  EVENTS_RESULT_PATH,
62
62
  HOST_EVENT_STREAM_RE,
63
+ MUX_EVENT_STREAM_RE,
63
64
  REMOTE_MUX_PATH,
64
65
  SESSION_LIST_RE,
65
66
  SESSION_PAGE_RE,
@@ -74,8 +75,11 @@ import {
74
75
  isWorkspaceFollowEndpoint,
75
76
  parseMuxClientMessage,
76
77
  parseMuxServerMessage,
78
+ parseServerRequestMessage,
79
+ encodeServerRequestMessage,
77
80
  planForwardedSessionAdded,
78
- planHostSessionAdded,
81
+ planHostFrame,
82
+ planMuxStreamFrame,
79
83
  planSessionListFilter,
80
84
  planSessionSearchFilter,
81
85
  planWorkspaceFollowFrame,
@@ -407,6 +411,14 @@ function createGateway(options) {
407
411
  * unary `session.list` answer alone still lets another account's conversation
408
412
  * appear as a row (under "未分组", since its workspace is hidden too).
409
413
  *
414
+ * This is the SSE carrier only, and no browser ever reaches it: the host
415
+ * answers `/api/events.host` to a browser with a WebSocket UPGRADE, which
416
+ * never becomes an HTTP response here (see createEventStreamInterceptor for
417
+ * that half, and note that both halves run the same planner). It is kept
418
+ * because the path is an SSE GET on the in-process fetch carrier, and because
419
+ * a non-upgrading client — a probe, a test fixture — must not be the reason
420
+ * the filter has a hole in it.
421
+ *
410
422
  * Returns null for every other request — in particular for a deployment on
411
423
  * 0.1.2+, which has no host event stream at all (its pushes ride the mux, see
412
424
  * createMuxInterceptor).
@@ -417,13 +429,26 @@ function createGateway(options) {
417
429
  const visibility = workspaceVisibility(req.umSession, permissionsFor(req.umSession))
418
430
  if (visibility === null) return null
419
431
  return createSseFilter({
420
- onFrame: (frame) => {
421
- visibility.refresh()
422
- return planHostSessionAdded(frame, visibility)
423
- },
432
+ onFrame: (frame) => planHostStreamDecision(frame, visibility),
424
433
  })
425
434
  }
426
435
 
436
+ /**
437
+ * One host-stream frame as the SSE carrier wants it: the bytes to keep, or a
438
+ * replacement document. The WebSocket carrier (createEventStreamInterceptor)
439
+ * asks the same planner and re-encodes it its own way — the two must never
440
+ * disagree about WHICH frames a restricted account may receive.
441
+ */
442
+ function planHostStreamDecision(frame, visibility) {
443
+ visibility.refresh()
444
+ const plan = planHostFrame(frame, visibility)
445
+ if (plan.action === 'drop') return 'drop'
446
+ if (plan.action === 'replace') {
447
+ return { ...frame, method: plan.value.type, payload: plan.value }
448
+ }
449
+ return null
450
+ }
451
+
427
452
  /**
428
453
  * Frame-level enforcement for one accepted Remote mux connection.
429
454
  *
@@ -434,10 +459,15 @@ function createGateway(options) {
434
459
  *
435
460
  * Returns null — leaving the connection a plain byte pipe — when the account
436
461
  * has neither a workspace restriction nor a sandbox cap, or when the upgrade
437
- * is not the mux at all (a 0.1.1-rc.2 host has no mux route).
462
+ * is not one of this layer's streams: a 0.1.1-rc.2 host has no mux route at
463
+ * all, and its own two event streams are handled by
464
+ * createEventStreamInterceptor.
438
465
  */
439
466
  function createMuxInterceptor(req) {
440
- if (requestPath(req) !== REMOTE_MUX_PATH) return null
467
+ const path = requestPath(req)
468
+ // Two generations, two WebSocket shapes: the multiplexed mux above, and
469
+ // the 0.1.1 event streams the host upgrades one-per-path.
470
+ if (path !== REMOTE_MUX_PATH) return createEventStreamInterceptor(req, path)
441
471
  const session = req.umSession || null
442
472
  const perms = permissionsFor(session)
443
473
  const visibility = workspaceVisibility(session, perms)
@@ -495,6 +525,77 @@ function createGateway(options) {
495
525
  })
496
526
  }
497
527
 
528
+ /**
529
+ * Frame-level enforcement for one accepted 0.1.1-rc.2 event-stream upgrade.
530
+ *
531
+ * The transport difference that hid this door: `/api/events.host` and
532
+ * `/api/events.mux` LOOK like the SSE responses streamFilterFor rewrites, and
533
+ * on the in-process (fetch) carrier they are. A BROWSER never sees that
534
+ * shape — the host upgrades both paths to a WebSocket and sends one
535
+ * `ServerRequest` text message per frame — so the SSE filter never ran for a
536
+ * real client and every live push arrived unfiltered while the unary answers
537
+ * stayed filtered. That asymmetry is exactly what a person sees as "the
538
+ * workspace appears in the other account's sidebar, and a refresh takes it
539
+ * away again": workspace.list was rewritten, host/workspace-changed was not.
540
+ *
541
+ * One rule per stream:
542
+ * - `/api/events.host` carries the registry news — a session was added, a
543
+ * workspace was changed (the host pushes the whole new WorkspaceView
544
+ * after every durable mutation, and attaching a session to a workspace
545
+ * IS one), removed, or reordered — judged by the same plan the unary
546
+ * rewrites use;
547
+ * - `/api/events.mux` carries live session frames, clamped to the
548
+ * account's sandbox cap exactly like the history response.
549
+ *
550
+ * Returns null — leaving the connection a plain byte pipe — for an account
551
+ * that hides nothing and has no sandbox cap, and for every other upgrade.
552
+ */
553
+ function createEventStreamInterceptor(req, path) {
554
+ const isHostStream = HOST_EVENT_STREAM_RE.test(path)
555
+ const isMuxStream = MUX_EVENT_STREAM_RE.test(path)
556
+ if (!isHostStream && !isMuxStream) return null
557
+
558
+ const session = req.umSession || null
559
+ const perms = permissionsFor(session)
560
+ const visibility = isHostStream ? workspaceVisibility(session, perms) : null
561
+ const allowedMode = isMuxStream && perms && typeof perms.sandboxMode === 'string' && perms.sandboxMode !== ''
562
+ ? perms.sandboxMode
563
+ : null
564
+ if (visibility === null && allowedMode === null) return null
565
+
566
+ return createMuxFilter({
567
+ // Downlink only: the host closes the socket on any client message, so
568
+ // there is nothing to read and nothing to remember.
569
+ parseClientMessage: () => null,
570
+ parseServerMessage: parseServerRequestMessage,
571
+ encodeServerMessage: encodeServerRequestMessage,
572
+ onServerMessage: (message) => {
573
+ if (visibility !== null) {
574
+ visibility.refresh()
575
+ const plan = planHostFrame(message, visibility)
576
+ // Harvest first, filter second — including workspaces this account
577
+ // cannot see, so a later session.create that names one by id still
578
+ // resolves to a path and is refused for the right reason.
579
+ for (const view of plan.views) workspacePathById.set(view.workspaceId, view.path)
580
+ if (plan.removedId !== null) workspacePathById.delete(plan.removedId)
581
+ if (plan.action === 'drop') return { action: 'drop' }
582
+ if (plan.action === 'replace') return { action: 'replace', value: plan.value }
583
+ }
584
+ if (allowedMode !== null) {
585
+ // planMuxStreamFrame knows the 0.1.1 frame vocabulary (session/event,
586
+ // session/projection) that clampStreamItem does not: it serves the
587
+ // 0.1.2 session/follow items instead.
588
+ const planned = planMuxStreamFrame(
589
+ message,
590
+ (payload) => clampSessionHistorySandbox(payload, allowedMode),
591
+ )
592
+ if (planned !== null) return { action: 'replace', value: planned.payload }
593
+ }
594
+ return null
595
+ },
596
+ })
597
+ }
598
+
498
599
  /**
499
600
  * The workspace-visibility rules for one account, in the shape BOTH the
500
601
  * unary list rewrite and the 0.1.2+ follow stream need.
package/src/index.js CHANGED
@@ -718,7 +718,8 @@ async function handleApi(req, res, deps) {
718
718
  * The root the console builds per-account workspaces under.
719
719
  *
720
720
  * Read by the create-user form so it can prefill the default path
721
- * (<root>/<department levels>/<username>, one folder per level). Admin-only:
721
+ * (<root>/workspace/<department levels>/<username>, one folder per level).
722
+ * Admin-only:
722
723
  * the value is a path on the
723
724
  * host, which is exactly what a plain account must not be handed.
724
725
  */
@@ -1112,7 +1113,8 @@ const Config = z.object({
1112
1113
  lockoutSeconds: z.natural().min(1).default(60),
1113
1114
  // ── per-account workspaces (the "create user" flow) ──────────────────────
1114
1115
  // Root the console builds a new account's workspace under:
1115
- // <workspaceRoot>/<department>/<username>. Empty (the default) asks the
1116
+ // <workspaceRoot>/workspace/<department levels>/<username>. Empty (the
1117
+ // default) asks the
1116
1118
  // running host for it — the sandbox policy's own workspaceRoot, which dsh
1117
1119
  // defaults to the harness's process cwd — and falls back to process.cwd().
1118
1120
  // Set it explicitly to park every account folder on another volume.
package/src/mux-filter.js CHANGED
@@ -1,14 +1,24 @@
1
1
  'use strict'
2
2
 
3
3
  /**
4
- * The Remote-mux frame transformer: the WebSocket half of the 0.1.2+ API.
4
+ * The WebSocket frame transformer: the codec every live dsh stream is
5
+ * enforced through.
5
6
  *
6
- * dsh multiplexes every Remote stream over one WebSocket (`/api/remote.mux`),
7
- * where each logical stream is a `streamId` the browser opened with an
8
- * `{ type:'open', streamId, endpoint, payload }` text message. Enforcement
9
- * that used to be a response rewrite (the workspace list) or a request rewrite
10
- * (an approval answer) therefore has to happen inside the frames — which is
11
- * what this module does, and why it owns a WebSocket codec at all.
7
+ * Two generations share it, and they differ only in vocabulary:
8
+ * - 0.1.2+ multiplexes every Remote stream over one WebSocket
9
+ * (`/api/remote.mux`), where each logical stream is a `streamId` the
10
+ * browser opened with an `{ type:'open', streamId, endpoint, payload }`
11
+ * text message;
12
+ * - 0.1.1-rc.2 upgrades `/api/events.host` and `/api/events.mux` to a
13
+ * WebSocket of their own and sends one `ServerRequest` envelope per text
14
+ * message (`{ type:'server-request', rpcId, method, payload }`), with no
15
+ * client traffic at all.
16
+ *
17
+ * Enforcement that used to be a response rewrite (the workspace list) or a
18
+ * request rewrite (an approval answer) therefore has to happen inside the
19
+ * frames — which is what this module does, and why it owns a WebSocket codec
20
+ * at all. The parsers and the replacement encoder are injected by the caller,
21
+ * so this file stays transport mechanics and the policy stays in gateway-core.
12
22
  *
13
23
  * Scope, deliberately narrow:
14
24
  * - only server→browser frames are ever rewritten; the browser→host
@@ -135,9 +145,11 @@ function encodeTextFrame(text) {
135
145
  * Build the per-connection transformer.
136
146
  *
137
147
  * @param handlers - `{ onClientMessage(message), onServerMessage(message,
138
- * endpoint) }`. `onServerMessage` returns null/'pass' to leave the
139
- * frame alone, `{ action: 'drop' }` to swallow it, or
140
- * `{ action: 'replace', value }` to send a different item value.
148
+ * endpoint), encodeServerMessage(message, value) }`. `onServerMessage`
149
+ * returns null/'pass' to leave the frame alone, `{ action: 'drop' }` to
150
+ * swallow it, or `{ action: 'replace', value }` to send a different
151
+ * value. `encodeServerMessage` is optional and owns the replacement's
152
+ * wire shape; without it the Remote mux item envelope is assumed.
141
153
  * @returns `{ observeClientChunk, filterServerChunk, streamEndpoint,
142
154
  * pendingBytes, disabled }`.
143
155
  */
@@ -209,6 +221,23 @@ function createMuxFilter(handlers) {
209
221
  return out.length === 1 ? out[0] : Buffer.concat(out)
210
222
  }
211
223
 
224
+ /**
225
+ * Re-encode one replaced message. The default is the Remote mux's own item
226
+ * shape; the 0.1.1 event streams carry a different envelope and pass their
227
+ * own encoder, because the two generations share this codec but not their
228
+ * wire vocabulary. An encoder that throws or returns nothing keeps the
229
+ * original bytes: a replacement nobody could encode is not worth a frame.
230
+ */
231
+ function encodeReplacement(message, value) {
232
+ if (typeof options.encodeServerMessage === 'function') {
233
+ try {
234
+ const text = options.encodeServerMessage(message, value)
235
+ return typeof text === 'string' && text !== '' ? text : null
236
+ } catch { return null }
237
+ }
238
+ return JSON.stringify({ type: 'item', streamId: message.streamId, value })
239
+ }
240
+
212
241
  function transformServerFrame(frame) {
213
242
  if (!frame.fin || frame.opcode !== OPCODE_TEXT) return frame.raw
214
243
  const message = parseServer(frame)
@@ -218,7 +247,8 @@ function createMuxFilter(handlers) {
218
247
  if (decision === null) return frame.raw
219
248
  if (decision.action === 'drop') return null
220
249
  if (decision.action === 'replace') {
221
- return encodeTextFrame(JSON.stringify({ type: 'item', streamId: message.streamId, value: decision.value }))
250
+ const text = encodeReplacement(message, decision.value)
251
+ return text === null ? frame.raw : encodeTextFrame(text)
222
252
  }
223
253
  return frame.raw
224
254
  }
@@ -354,8 +354,17 @@ function sanitizeHiddenUnicodeJson(value, depth = 0) {
354
354
  * wider mode without a single action from anyone — an escalation with no
355
355
  * approval step to intercept.
356
356
  *
357
- * Rewrites in place: `permission/preset` events, `sandbox/mode` events, and
358
- * the projected `currentValue` the client displays as the active preset.
357
+ * Rewrites in place: `permission/preset` events, `sandbox/mode` events, the
358
+ * projected `currentValue` the client displays as the active preset, and the
359
+ * `options` list the picker renders.
360
+ *
361
+ * Why the OPTIONS list matters: clamping only the active value leaves the wider
362
+ * presets on the menu. They stay clickable, and the click is refused a round
363
+ * trip later — which reads as "the cap does not work" even though the mode never
364
+ * changed. Only presets the rank table KNOWS to be wider are removed; anything
365
+ * unrecognised (a deployment's own preset name) is left alone, because hiding a
366
+ * narrow custom preset would be worse than listing it, and the request gate
367
+ * still refuses it fail-closed if it really is wider.
359
368
  *
360
369
  * @returns whether anything changed.
361
370
  */
@@ -383,6 +392,13 @@ function clampSessionHistorySandbox(value, allowedMode, depth = 0) {
383
392
  value.currentValue = allowedMode
384
393
  changed = true
385
394
  }
395
+ if (Array.isArray(value.options) && typeof value.currentValue === 'string') {
396
+ const kept = value.options.filter((option) => !widerThan(option && option.value))
397
+ if (kept.length !== value.options.length) {
398
+ value.options = kept
399
+ changed = true
400
+ }
401
+ }
386
402
 
387
403
  for (const key of Object.keys(value)) {
388
404
  const nested = value[key]
package/src/proxy.js CHANGED
@@ -553,6 +553,13 @@ function createProxy(upstream, getAuthenticatedUrl, hooks) {
553
553
  socket.write(split.header)
554
554
  writeFiltered(upstreamSocket, socket, mux, split.rest)
555
555
  upstreamSocket.on('data', (next) => writeFiltered(upstreamSocket, socket, mux, next))
556
+ // The plain path lets pipe() carry the upstream's FIN to the
557
+ // browser; once frames go through a filter there is no pipe, so the
558
+ // end has to be forwarded by hand. Without it the browser answers
559
+ // the close frame and then waits for a transport close that never
560
+ // comes: the socket (and the tab's half of it) hangs around forever.
561
+ upstreamSocket.on('end', () => { if (!socket.writableEnded) socket.end() })
562
+ upstreamSocket.on('error', () => { if (!socket.destroyed) socket.destroy() })
556
563
  // Observational only: the browser's bytes reach the host
557
564
  // unchanged, so a parse miss costs the connection nothing.
558
565
  socket.on('data', (browserChunk) => mux.observeClientChunk(browserChunk))
package/src/sse-filter.js CHANGED
@@ -56,11 +56,18 @@ function dataPayload(block) {
56
56
  return parts.length === 0 ? null : parts.join('\n')
57
57
  }
58
58
 
59
+ /** Re-encode one event block from a replaced payload. */
60
+ function encodeBlock(payload) {
61
+ return Buffer.from(`data: ${JSON.stringify(payload)}\n\n`, 'utf8')
62
+ }
63
+
59
64
  /**
60
65
  * Build the per-connection filter.
61
66
  *
62
- * @param handlers - `{ onFrame(parsedJson) }`, returning 'drop' to swallow the
63
- * event block it was given (anything else keeps it).
67
+ * @param handlers - `{ onFrame(parsedJson) }`. Return 'drop' to swallow the
68
+ * event block, a different object to REPLACE it (re-encoded as one
69
+ * `data:` block — the caller mutated it in place), or anything else to
70
+ * keep the original bytes.
64
71
  * @returns `{ push(chunk), disabled(), pendingBytes() }`.
65
72
  */
66
73
  function createSseFilter(handlers) {
@@ -90,7 +97,10 @@ function createSseFilter(handlers) {
90
97
  const taken = takeBlock(buffer)
91
98
  if (taken === null) break
92
99
  buffer = taken.rest
93
- if (decide(taken.block) !== 'drop') out.push(taken.block)
100
+ const decision = decide(taken.block)
101
+ if (decision === 'drop') continue
102
+ if (decision !== null && typeof decision === 'object') out.push(encodeBlock(decision))
103
+ else out.push(taken.block)
94
104
  }
95
105
  if (buffer.length > MAX_PENDING_BYTES) {
96
106
  out.push(buffer)
@@ -110,4 +120,4 @@ function createSseFilter(handlers) {
110
120
  }
111
121
  }
112
122
 
113
- export { createSseFilter, MAX_PENDING_BYTES }
123
+ export { createSseFilter, encodeBlock, MAX_PENDING_BYTES }
@@ -4,8 +4,9 @@
4
4
  * Workspace provisioning for the console's "create user" flow.
5
5
  *
6
6
  * An account created by an administrator may come with its own workspace. The
7
- * DEFAULT home is `<harness root>/<department levels>/<username>` (one folder
8
- * per department level); the admin may type any other absolute path instead. This module owns the pure half of that — the
7
+ * DEFAULT home is `<harness root>/workspace/<department levels>/<username>`
8
+ * (one folder per department level, all of them under a single `workspace`
9
+ * directory); the admin may type any other absolute path instead. This module owns the pure half of that — the
9
10
  * path arithmetic and its validation — plus the directory step, so both are
10
11
  * verifiable without a store, a request or a running dsh.
11
12
  *
@@ -26,6 +27,16 @@ import { departmentLevels, StoreError } from './store.js'
26
27
 
27
28
  /** Longest path this module will hand to the filesystem. */
28
29
  const MAX_PATH_LEN = 1024
30
+ /**
31
+ * The single directory every default workspace lives under:
32
+ * `<root>/workspace/<department levels>/<username>`.
33
+ *
34
+ * One level between the harness root and the accounts, so the root keeps its
35
+ * own files (the checkout, its config, whatever else lives there) beside a
36
+ * single `workspace` folder instead of being mixed with one folder per
37
+ * department.
38
+ */
39
+ const WORKSPACE_SUBDIR = 'workspace'
29
40
  /** Longest directory name built out of free text (a department is capped at 64
30
41
  * characters by the store, and a username at 32). */
31
42
  const MAX_SEGMENT_LEN = 64
@@ -55,10 +66,10 @@ export function sanitizeSegment(value) {
55
66
 
56
67
  /**
57
68
  * The default workspace for one account:
58
- * `<root>/<一级>/<二级>/…/<username>`.
69
+ * `<root>/workspace/<一级>/<二级>/…/<username>`.
59
70
  *
60
71
  * A multi-level department becomes NESTED folders, one per level, so the tree on
61
- * disk mirrors the organisation: `E:\work\和平分局\刑侦大队\zhang`. A blank
72
+ * disk mirrors the organisation: `E:\work\workspace\和平分局\刑侦大队\zhang`. A blank
62
73
  * department drops its segments instead of inventing a placeholder — the username
63
74
  * alone is already unique, and a folder named after nothing is worse than one
64
75
  * level less.
@@ -75,7 +86,7 @@ export function sanitizeSegment(value) {
75
86
  * @returns an absolute path built with the host separator.
76
87
  */
77
88
  export function defaultWorkspacePath(root, department, username) {
78
- const parts = [String(root === undefined || root === null ? '' : root).trim()]
89
+ const parts = [String(root === undefined || root === null ? '' : root).trim(), WORKSPACE_SUBDIR]
79
90
  for (const level of departmentLevels(department)) {
80
91
  const segment = sanitizeSegment(level)
81
92
  if (segment !== '') parts.push(segment)
@@ -127,6 +138,12 @@ export function resolveWorkspaceTarget({ root, department, username, custom } =
127
138
  if (sameWorkspacePath(path, base)) {
128
139
  throw new StoreError('bad_workspace_path', '工作区不能就是 harness 根目录本身(该目录是所有账号工作区的父目录)')
129
140
  }
141
+ // The container is off limits for the same reason: a username that sanitizes
142
+ // away would otherwise turn the folder every account lives under into one
143
+ // account's workspace.
144
+ if (sameWorkspacePath(path, join(base, WORKSPACE_SUBDIR))) {
145
+ throw new StoreError('bad_workspace_path', `工作区不能就是 ${WORKSPACE_SUBDIR} 目录本身(该目录是所有账号工作区的父目录)`)
146
+ }
130
147
  return { path, root: base, source }
131
148
  }
132
149