dsh-workspace-plus 0.6.1 → 0.7.1

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
@@ -3,11 +3,11 @@
3
3
  [![npm version](https://img.shields.io/npm/v/dsh-workspace-plus.svg)](https://www.npmjs.com/package/dsh-workspace-plus)
4
4
  [![Listed on dsh-plugin.org](https://dsh-plugin.org/badges/listed.svg)](https://dsh-plugin.org/plugins/sen70s/dsh-workspace-plus)
5
5
  [![license](https://img.shields.io/npm/l/dsh-workspace-plus.svg)](https://github.com/Sen70s/dsh-workspace-plus/blob/main/LICENSE)
6
-
7
- **当前版本 `v0.6.1`** · 要求 DSH `>=0.1.7-rc.2`
8
-
6
+ **当前版本 `v0.7.1`** · 要求 DSH `>=0.1.7-rc.2`
9
7
  DeepSeek Harness(DSH)的插件。一个插件,两个子功能,插件页里各占一行、可单独启停。
10
8
 
9
+ DeepSeek Harness(DSH)的插件。一个插件,三个子功能,插件页里各占一行、可单独启停。
10
+
11
11
  | 子功能 | 说明 |
12
12
  | --- | --- |
13
13
  | **workspace+** | 一个工作区登记多个目录,每个目录一个标签。标签表注入每次请求,输入框打 `@` 也能直接引用 |
@@ -58,6 +58,8 @@ agent 直接去 D:\proj\backend,不会因为旁边有个 backend-legacy 而改
58
58
 
59
59
  装好即用。请求权限、询问用户、任务完成、中断、出错都会弹 toast,点一下就打开对应的对话。
60
60
 
61
+ 正常情况下,点通知不会再先闪一个终端窗口。首次注册 `dsh-notice:` 协议时(插件加载后就会发生),宿主会用系统自带的 `csc.exe` 把一个小助手(约 10 KB 的 `click-host.exe`)编译到 `~/.dsh/notice/` 并缓存;之后点击由这个助手在无窗口的情况下完成,窗口被别的程序挡住、最小化甚至收进托盘,它都能恢复并切到前台(最大化状态保持不变)。编译不可用时自动退化为无窗口 PowerShell(改由 Harness 自己把窗口切到前台),再不行就退化为普通 PowerShell 兼容模式(可能有窗口闪烁);当前用的是哪一种,设置 → 通知 的状态区会直接写明。
62
+
61
63
  设置 → 通知 里可以选通知类型、声音、时长、注册协议、发一条测试通知。这里还有一个「启用桌面通知」开关,不动插件页也能静音。
62
64
 
63
65
  设置存在 `~/.dsh/notice/`,协议 scheme 是 `dsh-notice:` —— 从独立 dsh-notice 插件升级过来时,已有设置与协议注册自动生效。
@@ -70,12 +72,13 @@ agent 直接去 D:\proj\backend,不会因为旁边有个 backend-legacy 而改
70
72
  node tools/verify.mjs
71
73
  ```
72
74
 
73
- 479 项断言,覆盖 manifest、patch 层、宿主半、浏览器半、工作区行按钮、`@` 候选源与弹窗形状。
75
+ 521 项断言,覆盖 manifest、patch 层、宿主半(含 `/workspaceplus/api` 在认证服务缺失时的拒绝)、浏览器半、工作区行按钮、`@` 候选源、弹窗形状,以及通知点击启动器的编译与回退(含真实 `csc.exe` 编译一次并自检)。
74
76
 
75
77
  ## 其它
76
78
 
77
- - `lib/` 是手写源码,**不需要任何构建步骤**。从 npm、GitHub、本地目录三种方式装到的都是可直接加载的代码。
79
+ - `lib/` 是手写源码,**不需要打包步骤**(没有 bundler):从 npm、GitHub、本地目录三种方式装到的都是可直接加载的代码。唯一的例外是通知点击助手 —— 它由宿主在首次注册协议跳转时(插件加载后就会发生)用系统自带的 `csc.exe` 从 `lib/notice/click-host.cs` 现编到 `~/.dsh/notice/` 并缓存;编译不可用时自动退化为 PowerShell 启动器,插件其余部分不受影响。
78
80
  - workspace+ 的状态文件在 `~/.dsh/workspaceplus/directories.json`,可以直接手改,改完刷新页面即可。
81
+ - workspace+ 的浏览器接口 `/workspaceplus/api` 每次请求都要过宿主的 `connection.admit` 认证。认证服务缺失、`admit` 不存在或报错时一律 **503**,读写都不放行——这个接口写下的目录备注会进入模型上下文,不能让未认证的调用方有机会修改它。
79
82
  - [LICENSE](./LICENSE) · MIT
80
83
 
81
84
  ## Star History
package/cordis.patch.yml CHANGED
@@ -26,7 +26,7 @@
26
26
  # regardless and degrades silently when this row is off.
27
27
  #
28
28
  # Turning one row off removes exactly that half from the host while the bundle
29
- # and the other sub-feature keep working. The 通知 settings page also carries a
29
+ # and the other sub-features keep working. The 通知 settings page also carries a
30
30
  # runtime "启用桌面通知" switch, so notifications can be silenced without
31
31
  # touching the row at all.
32
32
  - insert:
package/lib/client.js CHANGED
@@ -388,8 +388,36 @@ window.__ModuleLoader__.load({
388
388
  } AUMID 发言:${toast.appId ?? '(未知)'}`
389
389
  })()
390
390
 
391
+ // How a click is actually delivered, as the host reports it after
392
+ // registering the protocol. The host prefers a compiled windowless
393
+ // helper and falls back to windowless PowerShell, then to plain
394
+ // PowerShell, so the status line names the mode instead of promising the
395
+ // best case. The helper restores and focuses the window itself, but only
396
+ // when `focusOnClick` is on — with the switch off the host registers the
397
+ // same helper without that step — so the sentence must not claim a
398
+ // foreground window in that state. A host that predates `launcher` has no
399
+ // value here, and then this returns '' so the sentence reads exactly as
400
+ // it did before.
401
+ const launcherNote = (() => {
402
+ const focus = settings.focusOnClick === true
403
+ if (protocol.launcher === 'helper') {
404
+ return focus ? '点击助手会在无窗口的情况下把 Harness 切到前台' : '由无窗口的点击助手处理,不改变窗口的前后顺序'
405
+ }
406
+ if (protocol.launcher === 'windowless-powershell') {
407
+ return focus
408
+ ? '使用无窗口 PowerShell 助手,由 Harness 负责切到前台;首次点击可能稍慢'
409
+ : '使用无窗口 PowerShell 助手;首次点击可能稍慢'
410
+ }
411
+ if (protocol.launcher === 'powershell') return '兼容模式:由 PowerShell 启动,可能有窗口闪烁'
412
+ return ''
413
+ })()
414
+
391
415
  const protocolLine = (() => {
392
- if (protocol.registered === true) return `点击通知可打开对应对话(已注册 ${protocol.scheme}:)。`
416
+ if (protocol.registered === true) {
417
+ return launcherNote
418
+ ? `点击通知可打开对应对话(已注册 ${protocol.scheme}:,${launcherNote})。`
419
+ : `点击通知可打开对应对话(已注册 ${protocol.scheme}:)。`
420
+ }
393
421
  if (settings.registerProtocol === false) return '已关闭协议注册:点击通知只会把 Harness 窗口切到前台。'
394
422
  return `尚未注册 ${protocol.scheme ?? 'dsh-notice'}:,点击通知只会把 Harness 窗口切到前台。`
395
423
  })()
@@ -434,7 +462,7 @@ window.__ModuleLoader__.load({
434
462
  }),
435
463
  React.createElement(NoticeSwitchRow, {
436
464
  title: '点击时把 Harness 窗口切到前台',
437
- description: '关闭后只切换对话,不改变窗口的前后顺序。',
465
+ description: '开启后点击通知会尽量把窗口从最小化、被遮挡或收进托盘的状态恢复并切到前台;关闭则只切换对话,不改变窗口的前后顺序。',
438
466
  checked: settings.focusOnClick === true,
439
467
  disabled,
440
468
  onChange: (next) => void send({ op: 'patch', patch: { focusOnClick: next } }),
@@ -827,6 +855,7 @@ window.__ModuleLoader__.load({
827
855
  document.head.appendChild(tag)
828
856
  }
829
857
 
858
+
830
859
  ensureStyles()
831
860
 
832
861
  // The notice page's stylesheet follows the same one-tag rule; calling it in
@@ -1871,11 +1900,16 @@ window.__ModuleLoader__.load({
1871
1900
  const bodyBlocks = (() => {
1872
1901
  if (state.status === 'error') {
1873
1902
  const unauthorized = state.message.includes('401')
1903
+ const unavailable = state.message.includes('503')
1874
1904
  const unrouted = state.message.includes('404')
1875
1905
  /**
1876
- * The three failures need different advice:
1906
+ * The four failures need different advice:
1877
1907
  * - 401: this document never completed the token exchange, so the
1878
1908
  * connection layer has no authority to admit the request.
1909
+ * - 503: the Host has no usable authentication service, so the route
1910
+ * refused the request instead of accepting an unauthenticated
1911
+ * change. Restarting is the ordinary fix; the route stays shut until
1912
+ * the connection service is mounted.
1879
1913
  * - 404: the request reached the Host and nothing claimed the path.
1880
1914
  * The browser half hot-reloads from the served bundle, the host half
1881
1915
  * does not, so a stale Host process is the ordinary cause.
@@ -1883,14 +1917,16 @@ window.__ModuleLoader__.load({
1883
1917
  */
1884
1918
  const diagnosis = unauthorized
1885
1919
  ? '这个页面没有带授权的会话凭据。请用 `dsh` 启动时打印的带 token 的地址重新打开 GUI(它会写入 cookie,然后跳转到干净地址)。'
1886
- : unrouted
1887
- ? '请求到达了 DSH 宿主进程,但宿主里没有注册 ' +
1888
- API_PATH +
1889
- ' 这条路由——通常意味着宿主进程还在跑旧版插件代码。浏览器半会随 bundle 热更新,宿主半不会:重启 dsh 即可。' +
1890
- '重启后终端会出现 [dsh-workspace-plus] host half loaded 一行;若没有,请到 设置 → 插件 查看该插件行的状态。'
1891
- : '这个面板通过 ' +
1892
- API_PATH +
1893
- ' 与宿主进程通信,而当前页面没有可用的 HTTP 通道(例如页面不是 dsh 提供的、或宿主进程还没起来)。'
1920
+ : unavailable
1921
+ ? '宿主进程没有可用的认证服务,接口按安全默认拒绝了这次请求(503)。重启 dsh 通常即可恢复;如果一直出现,说明这个宿主没有挂载 connection 认证服务,这个接口在它上面不会放行任何读写。'
1922
+ : unrouted
1923
+ ? '请求到达了 DSH 宿主进程,但宿主里没有注册 ' +
1924
+ API_PATH +
1925
+ ' 这条路由——通常意味着宿主进程还在跑旧版插件代码。浏览器半会随 bundle 热更新,宿主半不会:重启 dsh 即可。' +
1926
+ '重启后终端会出现 [dsh-workspace-plus] host half loaded 一行;若没有,请到 设置 → 插件 查看该插件行的状态。'
1927
+ : '这个面板通过 ' +
1928
+ API_PATH +
1929
+ ' 与宿主进程通信,而当前页面没有可用的 HTTP 通道(例如页面不是 dsh 提供的、或宿主进程还没起来)。'
1894
1930
  return [
1895
1931
  noticeBlock(
1896
1932
  'error',
package/lib/index.js CHANGED
@@ -12,7 +12,9 @@
12
12
  * agent sees the mapping without spending a tool call
13
13
  * 3. a `workspace_dirs` Tool the agent uses to inspect or edit the mapping
14
14
  * when the user asks in natural language
15
- * 4. a small JSON HTTP route the browser half uses to manage the same store
15
+ * 4. a small JSON HTTP route the browser half uses to manage the same store —
16
+ * every request must pass the Host's `connection.admit` check, and the
17
+ * route refuses (503) when that service is unavailable
16
18
  *
17
19
  * The whole point is routing by label instead of by guesswork: the injected
18
20
  * table gives absolute paths, so a similar-looking directory name elsewhere on
@@ -362,6 +364,55 @@ export function validateLabel(label) {
362
364
  return { ok: true, label: text }
363
365
  }
364
366
 
367
+ /**
368
+ * Run the Host's browser trust check for one plugin request.
369
+ *
370
+ * `connection.admit(req)` is the only authority a plugin route has: the Host
371
+ * fence plus the GUI session cookie. This route both publishes the mapping and
372
+ * lets the caller rewrite it, and whatever it stores is rendered into every
373
+ * request's model context — so it fails closed when that authority is absent
374
+ * instead of falling through to "admitted":
375
+ *
376
+ * - no `connection` service mounted, or a service without `admit` — the host
377
+ * composition has no auth layer (or its API drifted): refuse with 503;
378
+ * - `admit` throwing, rejecting, or failing to answer: the decision is
379
+ * unavailable, so refuse with 503;
380
+ * - a rejection from `admit`: pass its 401 through, everything else is 403.
381
+ *
382
+ * The result is awaited, so a synchronous or an asynchronous `admit` is honored
383
+ * the same way — but only an answer that actually carries no rejection counts
384
+ * as an admission.
385
+ *
386
+ * The notice sub-feature deliberately grants a loopback exemption. This route
387
+ * does not: an unauthenticated writer could otherwise plant arbitrary text in
388
+ * the agent's prompt through a directory note.
389
+ *
390
+ * @param connection - the resolved `connection` service, or undefined.
391
+ * @param req - incoming request.
392
+ * @returns a promise of `{ ok: true }` or `{ ok: false, status, message }`.
393
+ */
394
+ export async function checkAdmission(connection, req) {
395
+ const unavailable = (message) => ({ ok: false, status: 503, message })
396
+ if (connection === undefined || connection === null) {
397
+ return unavailable('host authentication service is not mounted; refusing to answer')
398
+ }
399
+ if (typeof connection.admit !== 'function') {
400
+ return unavailable('host authentication service has no admit(); refusing to answer')
401
+ }
402
+ let admission
403
+ try {
404
+ admission = await connection.admit(req)
405
+ } catch (error) {
406
+ return unavailable(`host authentication service failed: ${String(error?.message ?? error)}`)
407
+ }
408
+ const rejection = admission !== null && typeof admission === 'object' ? admission.rejection : undefined
409
+ if (rejection !== undefined) {
410
+ const status = rejection === 401 ? 401 : 403
411
+ return { ok: false, status, message: status === 401 ? 'unauthorized' : 'forbidden' }
412
+ }
413
+ return { ok: true }
414
+ }
415
+
365
416
  /**
366
417
  * Apply one store operation. Every branch validates first and returns a
367
418
  * discriminated result, so both the Tool and the HTTP route share one behavior
@@ -709,11 +760,17 @@ export function apply(ctx) {
709
760
  kind: 'exact',
710
761
  path: API_PATH,
711
762
  handler: async (req, res) => {
712
- const connection = ctx.get('connection')
713
- const admission = connection?.admit?.(req)
714
- if (admission !== undefined && 'rejection' in admission) {
715
- res.writeHead(admission.rejection)
716
- res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden')
763
+ // Fail closed. A request that cannot be admitted by the Host's
764
+ // connection service gets no answer at all, read or write: the
765
+ // mapping this route returns — and the notes it stores — become
766
+ // model context, so an unauthenticated caller must never reach it.
767
+ const admission = await checkAdmission(ctx.get('connection'), req)
768
+ if (!admission.ok) {
769
+ res.writeHead(admission.status, {
770
+ 'content-type': 'application/json; charset=utf-8',
771
+ 'cache-control': 'no-store',
772
+ })
773
+ res.end(JSON.stringify({ ok: false, message: admission.message }))
717
774
  return
718
775
  }
719
776
  const send = (status, payload) => {