@mzzsfy/dsh-session-manager 0.9.7 → 0.11.0

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 + 归档删除 + 回收站还原重挂载。
4
4
 
5
- 历史输入浮层、插话撤回与对话分叉已迁至独立插件 `dsh-context-manager`(会话上下文管理);工作区文件夹运行标记见 `dsh-turn-notify`。
5
+ 历史输入浮层、插话撤回与对话分叉已迁至独立插件 `dsh-context-manager`(会话上下文管理);工作区文件夹运行标记见 `dsh-turn-notify`;会话列表瘦身(列表投影裁剪与缓存)已迁至 `dsh-bug-perf`。旧设置项 `listSlimEnabled` 不再读取,开关改由 `dsh-bug-perf` 的 `listSlim.enabled` 管理(默认开启)。本包 0.10.0 及更早版本仍带丢失调用方上下文的旧列表包装,必须升级到 0.11.0(本次发布);旧包装与新 `dsh-bug-perf` 同装时仍会绕过宿主鉴权,不能只升级对端包。
6
6
 
7
7
  ## 功能
8
8
 
@@ -78,7 +78,7 @@ DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 +
78
78
  npm test
79
79
  ```
80
80
 
81
- 纯逻辑层(评估状态机 / 删除资格与失败矩阵 / 面板投影 / 归档差分 / 空白判定 / 回收站命令构造)以 `node --test` 覆盖,无外部依赖。路由层测试依赖 peer 包可解析,仓库根未安装依赖时自动 skip,`npm install` 后激活。Windows 真实回收站执行测试仅在本平台执行,其余平台自动 skip。周期评估测试经 `mock.timers` 接管 `setInterval` 与 `Date`(显式传 `now`:接管后虚拟时钟从 epoch 起算,夹具时间戳会全成「未来」),timer 服务以 makeCtx 桩模拟激活与缺失两种形态。
81
+ 纯逻辑层(评估状态机 / 删除资格与失败矩阵 / 面板投影 / 归档差分 / 空白判定 / 回收站命令构造)以 `node --test` 覆盖,无外部依赖。路由层测试依赖 peer 包可解析,仓库根未安装依赖时自动 skip(3 个非 skip 用例不受影响,其中 Config 导出契约在 peer 缺失时会失败而非 skip),`npm install` 后激活。Windows 真实回收站执行测试仅在本平台执行,其余平台自动 skip。周期评估测试经 `mock.timers` 接管 `setInterval` 与 `Date`(显式传 `now`:接管后虚拟时钟从 epoch 起算,夹具时间戳会全成「未来」),timer 服务以 makeCtx 桩模拟激活与缺失两种形态。
82
82
 
83
83
  ## 开发安装(仓库工作副本直挂,不经 npm 发布)
84
84
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-session-manager",
3
3
  "description": "会话自动归档与管理:超期自动归档、归档面板、删除进系统回收站、还原后一键重挂载",
4
- "version": "0.9.7",
4
+ "version": "0.11.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
@@ -10,6 +10,7 @@
10
10
  "./package.json": "./package.json"
11
11
  },
12
12
  "dsh": {
13
+ "hostMin": "0.2.1-rc.2",
13
14
  "bundle": {
14
15
  "patch": "./cordis.patch.yml"
15
16
  },
@@ -20,12 +21,13 @@
20
21
  ]
21
22
  },
22
23
  "compatibility": {
23
- "hostMin": "0.2.1-rc.2",
24
24
  "dshReleases": {
25
25
  "0.2.0-rc.1": "unknown",
26
26
  "0.2.0-rc.2": "unknown",
27
27
  "0.2.1-alpha.1": "unknown",
28
- "0.2.1-rc.2": "compatible"
28
+ "0.2.1-rc.2": "compatible",
29
+ "0.2.1-rc.5": "compatible",
30
+ "0.2.2-rc.1": "compatible"
29
31
  }
30
32
  }
31
33
  },
@@ -33,6 +35,10 @@
33
35
  "test": "node --test \"test/*.test.mjs\"",
34
36
  "npmPublish": "npm publish --access public --provenance=false --registry=https://registry.npmjs.org"
35
37
  },
38
+ "publishConfig": {
39
+ "access": "public",
40
+ "registry": "https://registry.npmjs.org"
41
+ },
36
42
  "license": "MIT",
37
43
  "repository": {
38
44
  "type": "git",
package/src/client.js CHANGED
@@ -136,6 +136,11 @@ const STATUS_URL = '/api/session-manager/status'
136
136
  // 删除确认链文案:两击行内确认后弹窗强提示,弹窗内确认才发请求。
137
137
  // 删除产物不可再生,其他插件(用量统计等)依赖会话产物,删后其会话级数据一并消失
138
138
  const DELETE_BUTTON_TITLE = '删除会话产物会影响其他插件的工作(用量统计等依赖会话产物);若无特殊需求,请不要删除。删除需再次行内确认与弹窗确认。'
139
+ // 行操作按钮悬停说明:文案与行为同源维护,由源码契约测试锁定
140
+ const RESTORE_BUTTON_TITLE = '把该会话从归档移回会话列表;恢复后 10 分钟内暂停自动归档,保护「恢复 → 首条消息」窗口,期间发言即恢复正常活跃计时。'
141
+ const REMOUNT_BUTTON_TITLE = '把这条已删除记录对应的会话重新挂回列表:产物仍在原位置时直接挂载,产物已进系统回收站时先还原再挂载。'
142
+ const FORGET_BUTTON_TITLE = '只移除这条已删除记录,不动磁盘上的会话产物;用于产物已手动找回、或不再需要追踪的情况。'
143
+ const MORE_BUTTON_TITLE = '展开该工作区剩余的归档会话;只改变面板显示条数,不改变任何会话的归档状态。'
139
144
  const DELETE_DIALOG_TITLE = '确认删除该会话?'
140
145
  const DELETE_DIALOG_BODY_PREFIX = '即将删除会话「'
141
146
  const DELETE_DIALOG_BODY_SUFFIX = '」。'
@@ -347,7 +352,7 @@ function ArchiveRow(props) {
347
352
  ]
348
353
  : [
349
354
  h('button', { key: 'delete', className: 'sm-btn sm-btn--danger', disabled: busy, title: DELETE_BUTTON_TITLE, onClick: props.onDelete }, '删除'),
350
- h('button', { key: 'restore', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onUnarchive }, '恢复'),
355
+ h('button', { key: 'restore', className: 'sm-btn sm-btn--restore', disabled: busy, title: RESTORE_BUTTON_TITLE, onClick: props.onUnarchive }, '恢复'),
351
356
  ]
352
357
  return h('div', {
353
358
  className: 'sm-row' + (armed ? ' sm-row--armed' : '') + (busy ? ' sm-row--busy' : ''),
@@ -402,8 +407,8 @@ function DeletedRow(props) {
402
407
  h('span', { key: 'meta', className: 'sm-row__time', title: new Date(row.deletedAt).toLocaleString() }, fmtTime(row.deletedAt)),
403
408
  h('span', { className: 'sm-row__title', title: row.title }, row.title),
404
409
  h('div', { className: 'sm-row__actions' },
405
- h('button', { key: 'remount', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onRemount }, '重新挂载'),
406
- h('button', { key: 'forget', className: 'sm-btn', disabled: busy, onClick: props.onForget }, '移除记录'),
410
+ h('button', { key: 'remount', className: 'sm-btn sm-btn--restore', disabled: busy, title: REMOUNT_BUTTON_TITLE, onClick: props.onRemount }, '重新挂载'),
411
+ h('button', { key: 'forget', className: 'sm-btn', disabled: busy, title: FORGET_BUTTON_TITLE, onClick: props.onForget }, '移除记录'),
407
412
  ),
408
413
  h('span', { className: 'sm-row__path', title: row.path }, '原位置 ' + row.path),
409
414
  )
@@ -554,7 +559,7 @@ function SessionManagerApp(props) {
554
559
  // 免得数组子节点内无 key 元素随页扩位触发不必要的卸载重挂
555
560
  const expandMore = (remaining, onMore) => remaining <= 0
556
561
  ? null
557
- : h('button', { key: 'more', className: 'sm-more', onClick: onMore }, '展开更多(剩 ' + remaining + ' 条)')
562
+ : h('button', { key: 'more', className: 'sm-more', title: MORE_BUTTON_TITLE, onClick: onMore }, '展开更多(剩 ' + remaining + ' 条)')
558
563
 
559
564
  // 主列表(最近归档):固定首页不翻页,尾部「按工作区浏览」入口跳转二级视图看全部
560
565
  const recentPage = pageArchiveRows(rows, ARCHIVE_PAGE_SIZE)
@@ -685,29 +690,28 @@ function classifyAutoArchiveInput(committedText, text) {
685
690
  return { action: 'post', value }
686
691
  }
687
692
 
693
+ // 面板状态:数值项取字符串供受控输入
694
+ const configStateOf = (archive) => ({
695
+ days: String(archive.days),
696
+ intervalHours: String(archive.intervalHours),
697
+ })
698
+
688
699
  function AutoArchiveConfig() {
689
700
  const [state, setState] = useState(null)
690
701
  // 已提交值快照:非法输入与保存失败的回滚基准,与编辑中的显示值分离
691
702
  const committedRef = React.useRef(null)
692
703
  useEffect(() => {
693
- api(AUTO_ARCHIVE_URL)
694
- .then((payload) => {
695
- if (!payload) return
696
- const next = { days: String(payload.days), intervalHours: String(payload.intervalHours) }
697
- committedRef.current = next
698
- setState(next)
699
- })
700
- .catch((error) => { console.warn('[session-manager] 自动归档配置读取失败', error) })
704
+ void refresh()
701
705
  }, [])
702
706
  function refresh() {
703
707
  return api(AUTO_ARCHIVE_URL)
704
- .then((payload) => {
705
- if (!payload) return
706
- const next = { days: String(payload.days), intervalHours: String(payload.intervalHours) }
708
+ .then((archive) => {
709
+ if (!archive) return
710
+ const next = configStateOf(archive)
707
711
  committedRef.current = next
708
712
  setState(next)
709
713
  })
710
- .catch((error) => { console.warn('[session-manager] 自动归档配置回读失败', error) })
714
+ .catch((error) => { console.warn('[session-manager] 面板配置回读失败', error) })
711
715
  }
712
716
  function commit(field, text) {
713
717
  const committed = committedRef.current
package/src/index.js CHANGED
@@ -160,6 +160,21 @@ function readAutoArchivePatch(body) {
160
160
  return patch
161
161
  }
162
162
 
163
+ // settings 双形写(maintain 同构,判定延迟到使用点):legacy 方法面以 register 为准
164
+ // (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
165
+ // 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)
166
+ async function persistSettings(ctx, patch) {
167
+ const settings = ctx.get('settings')
168
+ if (typeof settings?.register === 'function' && typeof settings?.update === 'function') {
169
+ await settings.update(NAMESPACE, patch)
170
+ return
171
+ }
172
+ const editor = ctx.get('configEditor')
173
+ const entry = ctx.fiber?.entry
174
+ if (!editor || typeof editor.edit !== 'function' || !entry) throw new Error('宿主设置服务不可用')
175
+ await editor.edit(entry, (current) => ({ ...current, ...patch }))
176
+ }
177
+
163
178
  function readBody(req) {
164
179
  const BODY_MAX_BYTES = 64 * 1024
165
180
  return new Promise((resolve, reject) => {
@@ -215,6 +230,38 @@ function rejectMethod(req, res, method) {
215
230
  return false
216
231
  }
217
232
 
233
+ // 写路由守卫(全仓同构):浏览器跨站表单可发 text/plain 且载荷为合法 JSON,
234
+ // 仅方法守卫不足以阻断 drive-by 写,补同源比对与 JSON 内容类型双校验。
235
+ // 无 Origin 的非浏览器调用放行(本地 CLI/脚本);无 headers 的直调桩同样放行。
236
+ function rejectCrossOrigin(req, res) {
237
+ const origin = req.headers ? req.headers.origin : undefined
238
+ if (!origin) return false
239
+ let sameOrigin = false
240
+ try {
241
+ sameOrigin = new URL(origin).host === req.headers.host
242
+ } catch {
243
+ sameOrigin = false
244
+ }
245
+ if (sameOrigin) return false
246
+ sendJson(res, 403, { error: '跨源请求被拒绝' })
247
+ return true
248
+ }
249
+
250
+ // 内容类型守卫:浏览器发起的跨站请求(表单/fetch 字符串体)必带 text/plain 等类型,
251
+ // 借此阻断 drive-by 写;完全不带 content-type 的调用(本地 CLI/无头脚本)放行,
252
+ // 与"无 Origin 放行"同口径
253
+ function rejectNonJson(req, res) {
254
+ const type = req.headers ? req.headers['content-type'] : undefined
255
+ if (typeof type !== 'string' || type.trim() === '') return false
256
+ if (type.split(';')[0].trim().toLowerCase() === 'application/json') return false
257
+ sendJson(res, 415, { error: '仅接受 application/json 请求体' })
258
+ return true
259
+ }
260
+
261
+ function guardWrite(req, res) {
262
+ return rejectCrossOrigin(req, res) || rejectNonJson(req, res)
263
+ }
264
+
218
265
  /** @param {import('@deepseek-ai/cordis').Context} ctx */
219
266
  export function apply(ctx, config) {
220
267
  let evaluating = false
@@ -445,6 +492,7 @@ export function apply(ctx, config) {
445
492
  path: '/api/session-manager/unarchive',
446
493
  handler: async (req, res) => {
447
494
  if (!rejectMethod(req, res, 'POST')) return
495
+ if (guardWrite(req, res)) return
448
496
  try {
449
497
  const sessionId = await requireSessionId(req)
450
498
  await removeArchivedId(sessionId)
@@ -459,6 +507,7 @@ export function apply(ctx, config) {
459
507
  path: '/api/session-manager/info',
460
508
  handler: async (req, res) => {
461
509
  if (!rejectMethod(req, res, 'POST')) return
510
+ if (guardWrite(req, res)) return
462
511
  try {
463
512
  const sessionId = await requireSessionId(req)
464
513
  const header = await findHeader(ctx, sessionId, headerCache)
@@ -493,6 +542,7 @@ export function apply(ctx, config) {
493
542
  path: '/api/session-manager/delete',
494
543
  handler: async (req, res) => {
495
544
  if (!rejectMethod(req, res, 'POST')) return
545
+ if (guardWrite(req, res)) return
496
546
  const sessionId = await requireSessionId(req).catch((error) => {
497
547
  respondError(ctx, res, error)
498
548
  return undefined
@@ -644,6 +694,7 @@ export function apply(ctx, config) {
644
694
  return
645
695
  }
646
696
  if (!rejectMethod(req, res, 'POST')) return
697
+ if (guardWrite(req, res)) return
647
698
  let body
648
699
  try {
649
700
  body = JSON.parse(await readBody(req))
@@ -651,19 +702,7 @@ export function apply(ctx, config) {
651
702
  throw new Error(MESSAGES.badJsonBody)
652
703
  }
653
704
  const patch = readAutoArchivePatch(body)
654
- // 双形写(maintain 同构,判定延迟到使用点):legacy 方法面以 register 为准
655
- // (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
656
- // 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)。
657
- // await 保证回显为新值,persist 失败走 respondError
658
- const settings = ctx.get('settings')
659
- if (typeof settings?.register === 'function' && typeof settings?.update === 'function') {
660
- await settings.update(NAMESPACE, patch)
661
- } else {
662
- const editor = ctx.get('configEditor')
663
- const entry = ctx.fiber?.entry
664
- if (!editor || typeof editor.edit !== 'function' || !entry) throw new Error('宿主设置服务不可用')
665
- await editor.edit(entry, (current) => ({ ...current, ...patch }))
666
- }
705
+ await persistSettings(ctx, patch)
667
706
  const current = readSettings(ctx, config)
668
707
  sendJson(res, 200, { days: current.days, intervalHours: current.intervalHours })
669
708
  } catch (error) {
@@ -687,6 +726,7 @@ export function apply(ctx, config) {
687
726
  path: '/api/session-manager/remount',
688
727
  handler: async (req, res) => {
689
728
  if (!rejectMethod(req, res, 'POST')) return
729
+ if (guardWrite(req, res)) return
690
730
  try {
691
731
  const sessionId = await requireSessionId(req)
692
732
  // 移回依据是台账条目,台账域不可用时整个重挂载拒绝:
@@ -730,6 +770,7 @@ export function apply(ctx, config) {
730
770
  path: '/api/session-manager/forget',
731
771
  handler: async (req, res) => {
732
772
  if (!rejectMethod(req, res, 'POST')) return
773
+ if (guardWrite(req, res)) return
733
774
  try {
734
775
  await removeLedgerEntry(await requireSessionId(req))
735
776
  sendJson(res, 200, { ok: true })
@@ -44,3 +44,23 @@ test('源码契约:已删除区保持悬停显现且减弱动效覆盖新选择
44
44
  assert.ok(/prefers-reduced-motion: reduce.*\{ \.sm-btn--danger, \.sm-deleted \.sm-row__actions \{ transition:none/.test(CLIENT_SRC),
45
45
  '减弱动效未覆盖删除按钮与已删除区过渡')
46
46
  })
47
+
48
+ // Given 归档行与已删除行的操作按钮, When 渲染, Then 每个按钮都带 title 悬停说明
49
+ test('源码契约:行操作按钮都带 title 悬停说明', () => {
50
+ const buttons = [
51
+ ['restore', 'RESTORE_BUTTON_TITLE'],
52
+ ['remount', 'REMOUNT_BUTTON_TITLE'],
53
+ ['forget', 'FORGET_BUTTON_TITLE'],
54
+ ['delete', 'DELETE_BUTTON_TITLE'],
55
+ ['more', 'MORE_BUTTON_TITLE'],
56
+ ]
57
+ for (const [key, constant] of buttons) {
58
+ const pattern = new RegExp("key: '" + key + "'[^\\n]*title: " + constant)
59
+ assert.ok(pattern.test(CLIENT_SRC), key + ' 按钮缺少 title(' + constant + ')')
60
+ }
61
+ for (const constant of ['RESTORE_BUTTON_TITLE', 'REMOUNT_BUTTON_TITLE', 'FORGET_BUTTON_TITLE', 'MORE_BUTTON_TITLE']) {
62
+ const matched = CLIENT_SRC.match(new RegExp('const ' + constant + " = '([^']+)'"))
63
+ assert.ok(matched, constant + ' 未定义')
64
+ assert.ok(matched[1].length >= 20, constant + ' 文案须讲清用途')
65
+ }
66
+ })
@@ -1888,3 +1888,62 @@ test('0.1.7 形态:settings.configure 在场即关闭原生自动页', skipMissi
1888
1888
  assert.equal(configureCalls.length, 1)
1889
1889
  assert.deepEqual(configureCalls[0].presentation, { auto: false })
1890
1890
  })
1891
+
1892
+ // ── 写路由同源 + JSON 守卫回归 ──
1893
+
1894
+ // POST 夹具:带 headers 的请求体走 readBody 的 data/end 事件流
1895
+ function guardedRequest(routePath, headers, body = { sessionId: 's1' }) {
1896
+ const req = new EventEmitter()
1897
+ req.method = 'POST'
1898
+ req.url = routePath
1899
+ req.headers = headers
1900
+ process.nextTick(() => {
1901
+ req.emit('data', Buffer.from(JSON.stringify(body)))
1902
+ req.emit('end')
1903
+ })
1904
+ return req
1905
+ }
1906
+
1907
+ test('写路由守卫:跨源 POST 403 / 非 JSON 内容类型 415 / 同源 JSON 正常受理', skipMissingDeps, async () => {
1908
+ // Given 全部写路由(浏览器表单可发 text/plain 且载荷为合法 JSON,仅方法守卫不足)
1909
+ const POST_ROUTES = [
1910
+ '/api/session-manager/unarchive',
1911
+ '/api/session-manager/info',
1912
+ '/api/session-manager/delete',
1913
+ '/api/session-manager/auto-archive',
1914
+ '/api/session-manager/remount',
1915
+ '/api/session-manager/forget',
1916
+ ]
1917
+ const ORIGIN = 'http://127.0.0.1:9291'
1918
+ const sameOrigin = {
1919
+ origin: ORIGIN,
1920
+ host: '127.0.0.1:9291',
1921
+ 'content-type': 'application/json; charset=utf-8',
1922
+ }
1923
+
1924
+ for (const routePath of POST_ROUTES) {
1925
+ const { handlers } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map() })
1926
+
1927
+ // When Origin 与 Host 不同 Then 403,且响应为统一拒绝文案
1928
+ const crossOrigin = response()
1929
+ await handlers.get(routePath)(guardedRequest(routePath, { ...sameOrigin, origin: 'http://evil.example' }), crossOrigin)
1930
+ assert.equal(crossOrigin.status, 403, `${routePath} 跨源 POST 必须 403`)
1931
+
1932
+ // When 同源但 content-type: text/plain Then 415(drive-by 表单形态)
1933
+ const textPlain = response()
1934
+ await handlers.get(routePath)(guardedRequest(routePath, { ...sameOrigin, 'content-type': 'text/plain' }), textPlain)
1935
+ assert.equal(textPlain.status, 415, `${routePath} text/plain POST 必须 415`)
1936
+
1937
+ // When 无 Origin(本地 CLI)但带 text/plain Then 仍 415(内容类型守卫独立生效)
1938
+ const cliTextPlain = response()
1939
+ await handlers.get(routePath)(guardedRequest(routePath, { host: '127.0.0.1:9291', 'content-type': 'text/plain' }), cliTextPlain)
1940
+ assert.equal(cliTextPlain.status, 415, `${routePath} 无 Origin 的 text/plain 仍必须 415`)
1941
+ }
1942
+
1943
+ // When 同源 application/json Then 正常受理(unarchive 可幂等成功)
1944
+ const { handlers } = makeCtx({ archivedIds: ['s1'], headers: [HEADER], agents: new Map() })
1945
+ const accepted = response()
1946
+ await handlers.get('/api/session-manager/unarchive')(guardedRequest('/api/session-manager/unarchive', sameOrigin), accepted)
1947
+ assert.equal(accepted.status, 200)
1948
+ assert.deepEqual(accepted.body, { ok: true })
1949
+ })