dsh-hitl 0.1.0 → 0.1.2
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 +52 -21
- package/README.zh.md +40 -15
- package/index.js +21 -11
- package/lib/resolve.js +13 -4
- package/package.json +1 -2
- package/tests/docs.test.js +21 -16
- package/tests/manifest.test.js +16 -0
- package/tests/resolve.test.js +19 -0
- package/docs/01-panel-cn.png +0 -0
- package/docs/01-panel-en.png +0 -0
- package/docs/02-fields-cn.png +0 -0
- package/docs/02-fields-en.png +0 -0
- package/docs/03-subagent-cn.png +0 -0
- package/docs/03-subagent-en.png +0 -0
- package/docs/04-overlay-cn.png +0 -0
- package/docs/04-overlay-en.png +0 -0
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# dsh-hitl · Human-in-the-loop for any tool
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-hitl) [](LICENSE) [](https://yunpengdon.github.io/dsh-hitl-landing/)
|
|
4
|
+
|
|
3
5
|
English | [中文](README.zh.md)
|
|
4
6
|
|
|
5
7
|
> **Let the agent ask you before it acts.**
|
|
@@ -7,7 +9,7 @@ English | [中文](README.zh.md)
|
|
|
7
9
|
|
|
8
10
|
`dsh-hitl` is an installable DeepSeek Harness (DSH) plugin (bundle): **zero dependencies, zero build** — `index.js` (host half) + `client.js` (browser half) + `lib/` are plain JS that loads as it is. Install it, refresh the page once, and it works.
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
**[View Website](https://yunpengdon.github.io/dsh-hitl-landing/)**
|
|
11
13
|
|
|
12
14
|
## Features
|
|
13
15
|
|
|
@@ -18,24 +20,26 @@ English | [中文](README.zh.md)
|
|
|
18
20
|
- **Rejection with feedback**: 【Reject】 can carry a **feedback box**, and what you write there reaches the agent together with the rejection — it knows "don't do it" *and* "why not";
|
|
19
21
|
- **Configurable countdown**: a **countdown** plus three endings (**approve automatically / reject automatically / report the timeout to the agent**).
|
|
20
22
|
|
|
21
|
-

|
|
22
23
|
|
|
23
24
|
## Made for DSH
|
|
24
25
|
|
|
25
26
|
**Subagent pass-through: a tool call inside a subagent raises its approval request in the main conversation too.** Every tool call inside an in-process subagent (`spawn` / `fork`) goes through the same gate — the request is filed **both** under the child Session that made it and under the Session you are currently looking at: whichever conversation you are in, the card can appear there, labelled 「from subagent Session xxx」 with an 【Open that Session】 button.
|
|
26
27
|
(Out-of-process backends never reach this gate — see §6.1.)
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
[See this card on the landing page →](https://yunpengdon.github.io/dsh-hitl-landing/)
|
|
29
30
|
|
|
30
31
|
**A mini approval request even when you are not in a conversation.** Switch to the plugins page, open settings, or select no Session at all, and a notice floats at the top of the page, so HITL does not have to block the run while you are elsewhere (count, tool name, source Session, remaining time + 【Approve】【Reject】【Open that Session】).
|
|
31
32
|
|
|
32
|
-
|
|
33
|
+
[See the notice on the landing page →](https://yunpengdon.github.io/dsh-hitl-landing/)
|
|
33
34
|
|
|
34
35
|
---
|
|
35
36
|
|
|
36
37
|
## 1. Install
|
|
37
38
|
|
|
38
39
|
```sh
|
|
40
|
+
# From npm (the canonical install)
|
|
41
|
+
dsh plugin --profile <your profile> add dsh-hitl
|
|
42
|
+
|
|
39
43
|
# Straight from GitHub (nothing to build)
|
|
40
44
|
dsh plugin --profile <your profile> add github:YunpengDon/dsh-hitl
|
|
41
45
|
|
|
@@ -66,7 +70,7 @@ Once installed:
|
|
|
66
70
|
config:
|
|
67
71
|
protect:
|
|
68
72
|
- tool: bash # tool name; patterns like 'mcp__*' work too
|
|
69
|
-
title:
|
|
73
|
+
title: Confirm before running # panel title (bold, wraps)
|
|
70
74
|
countdown: { seconds: 30, action: reject }
|
|
71
75
|
reject: { feedback: true } # show the feedback box on reject
|
|
72
76
|
```
|
|
@@ -82,13 +86,13 @@ export const inject = ['hitl'] // wait for the hitl service before ap
|
|
|
82
86
|
export function apply(ctx) {
|
|
83
87
|
// Passing your own ctx as the third argument binds the mount to this plugin's lifetime
|
|
84
88
|
ctx.hitl.protect('bash', {
|
|
85
|
-
title: '
|
|
89
|
+
title: 'Run this command?',
|
|
86
90
|
countdown: { seconds: 30, action: 'reject' },
|
|
87
91
|
reject: { feedback: true },
|
|
88
92
|
}, ctx)
|
|
89
93
|
|
|
90
94
|
ctx.hitl.protect(['write', 'edit'], { // arrays, globs, RegExp, and predicates all work
|
|
91
|
-
title: '
|
|
95
|
+
title: 'Confirm before editing',
|
|
92
96
|
diff: { path: 'file_path', before: 'old_string', after: 'new_string' }, // folded into a side-by-side diff
|
|
93
97
|
countdown: null, // no countdown = wait forever (still cancellable)
|
|
94
98
|
}, ctx)
|
|
@@ -139,23 +143,23 @@ Without `fields`, the proposal is **every parameter of this call**:
|
|
|
139
143
|
| `reject.feedbackPrompt` | string | built-in copy | Feedback box placeholder |
|
|
140
144
|
| `reject.requireFeedback` | boolean | `false` | Feedback is required (an empty box is refused with a hint) |
|
|
141
145
|
| `modify.mode` | `revise-request` \| `allow-and-inform` | `revise-request` | What 【Modify】 means after an edit; see §5 |
|
|
142
|
-
| `whenUnavailable` | `reject` \| `wait` | `reject` | Behaviour with no browser connected
|
|
146
|
+
| `whenUnavailable` | `reject` \| `wait` | `reject` | Behaviour with no browser connected: `reject` = fail-closed; `wait` = wait indefinitely, so **pair it with a `countdown`** or that call hangs until its Session ends (the host warns at mount time); see §7.1 |
|
|
143
147
|
| `enabled` | boolean \| `(exec) => boolean` | `true` | Turn a mount off temporarily / make it conditional |
|
|
144
148
|
| `maxFieldChars` | number | `20000` | Per-field render cap; longer values are truncated with a note |
|
|
145
149
|
|
|
146
150
|
### FieldSpec
|
|
147
151
|
|
|
148
152
|
```js
|
|
149
|
-
{ param: 'content', title: '
|
|
153
|
+
{ param: 'content', title: 'New content', description: 'Text that will be written to the file',
|
|
150
154
|
render: 'markdown', // markdown | text | diff | json | hidden
|
|
151
155
|
editable: true, // overrides the default editability
|
|
152
|
-
labels: ['
|
|
156
|
+
labels: ['irreversible'], // field-level labels
|
|
153
157
|
diff: { before: 'old_string', after: 'new_string', path: 'file_path' } } // used when render: 'diff'
|
|
154
158
|
```
|
|
155
159
|
|
|
156
160
|
- With no `render`, a field renders as a diff when one can be configured, and as a markdown input otherwise.
|
|
157
161
|
- When `fields` is given, **only** the listed fields are shown (parameters you leave out never reach the panel).
|
|
158
|
-
- Config rows may shorten a spec to a bare string: `fields: ['command', { param: 'cwd', title: '
|
|
162
|
+
- Config rows may shorten a spec to a bare string: `fields: ['command', { param: 'cwd', title: 'Working directory' }]`.
|
|
159
163
|
|
|
160
164
|
### The three endings of a countdown
|
|
161
165
|
|
|
@@ -271,12 +275,36 @@ The panel follows the **application's current language** — not the mounting pl
|
|
|
271
275
|
- **With no browser connected the default is fail-closed**: a tool mounted behind HITL is rejected and the model sees `Error: HITL: no browser is connected to decide, so tool "x" did not run (fail-closed).` Set `whenUnavailable` to `wait` if it should wait forever in unattended runs instead.
|
|
272
276
|
- In a composition with no Web service at all (headless/TUI) the plugin **stays active and keeps gating** — "no UI, therefore no gate" is not this plugin's semantics.
|
|
273
277
|
|
|
278
|
+
### 7.1 The security boundary: what HITL stops and what it cannot
|
|
279
|
+
|
|
280
|
+
- **It stops a call from *starting*; it does not stop the world.** If a tool has already produced a side effect inside its own
|
|
281
|
+
execution layer (a concurrent race, an asynchronous write, a request already sent), a rejection prevents "next", never "already" —
|
|
282
|
+
**HITL is neither a transaction nor a sandbox**.
|
|
283
|
+
- So how safe you actually are depends on two things: **① how dangerous the operations your matcher covers are; ② whether the tool
|
|
284
|
+
puts its irreversible step last.** Mounting `bash` / `write` / `edit` — tools that are dangerous the moment they start — is the
|
|
285
|
+
right use. If a tool only reveals its risk halfway through, HITL can only help you decide whether to begin.
|
|
286
|
+
- **A human's approval is not a privilege escalation**: the sandbox, guards, hooks and approval still run afterwards (§8); and the
|
|
287
|
+
reverse holds too — another pre-execution policy can refuse *before* this plugin is even asked. That was measured once:
|
|
288
|
+
`fs-observation-policy` stopped a call before the human pressed 【Approve】, so no HITL panel ever appeared (§8).
|
|
289
|
+
- **`whenUnavailable: wait` is fail-open**: with no browser connected the call simply waits, and without a `countdown` it waits until
|
|
290
|
+
its Session ends. Use the fail-closed default (`reject`) when an unattended run must not stall, or pair `wait` with a `countdown`
|
|
291
|
+
(the host warns at mount time when you do not).
|
|
292
|
+
- **The local trust boundary**: the decision channel and `/status` answer loopback addresses only. Any process on this machine can
|
|
293
|
+
read `/status`, which carries the **mount list** and, per pending decision, the **tool name / Session id / remaining milliseconds /
|
|
294
|
+
hold state** — **no tool arguments and no token**. To size that hole precisely: a local process can already read the session logs
|
|
295
|
+
under `~/.dsh/sessions/**`, which do contain every tool argument, so `/status` exposes a strictly smaller subset of what is already
|
|
296
|
+
readable locally. The hole is deliberate: without it, "what is HITL holding right now?" could only be answered by opening the page.
|
|
297
|
+
|
|
274
298
|
---
|
|
275
299
|
|
|
276
300
|
## 8. Relationship to other pre-execution policies
|
|
277
301
|
|
|
278
302
|
- This plugin listens on `tools/pre-execute` with `prepend: true`, i.e. **ask the human first**; once the human agrees, the sandbox, guards, hooks, approval, and every other policy still run afterwards. **A human's approval is not a privilege escalation.**
|
|
279
303
|
- So "the human approved but the sandbox or a guard still refused" is by design, not a bug.
|
|
304
|
+
- **The reverse holds too**: another pre-execution policy can refuse **before** this plugin is ever asked, in which case no HITL panel
|
|
305
|
+
appears at all — so "it ran (or failed) without asking me" is a question for the other policies first. Measured once:
|
|
306
|
+
`fs-observation-policy` demands a re-read before overwriting a deleted file, and it stopped a call both *after* the human pressed
|
|
307
|
+
【Approve】 (the panel had appeared as usual) and, in another round, *before* this plugin was reached (no panel at all).
|
|
280
308
|
- This plugin only asks before execution: it never rewrites a tool's arguments and never rewrites a tool's result.
|
|
281
309
|
|
|
282
310
|
---
|
|
@@ -284,6 +312,7 @@ The panel follows the **application's current language** — not the mounting pl
|
|
|
284
312
|
## 9. Known limitations
|
|
285
313
|
|
|
286
314
|
- **Tool arguments cannot be rewritten** (see §5), so 【Modify】 defaults to "refuse and hand the edit back".
|
|
315
|
+
- **HITL is not a side-effect boundary** (see §7.1): it stops a call from starting and cannot roll back a side effect that already happened; how much it protects you depends on the matcher and on the tool's own design.
|
|
287
316
|
- **Mount copy is not localized**: `title` / `labels` / field titles are strings the mounting plugin supplies; this plugin does not translate them and does not accept a `{zh, en}` map (see §6.3).
|
|
288
317
|
- **No session audit event**: DSH does not let a plugin append new event types, so the decision trail lives only in the tool result (and, under `allow-and-inform`, in one user message).
|
|
289
318
|
- **Only the Web UI has a panel**: under ACP/TUI/headless the mount's `whenUnavailable` decides (reject by default).
|
|
@@ -356,28 +385,30 @@ Reply: `{ ok: true, accepted: true, held?, expiresAt? }` or
|
|
|
356
385
|
|
|
357
386
|
```text
|
|
358
387
|
dsh-hitl/
|
|
359
|
-
├── index.js
|
|
388
|
+
├── index.js 639 host half: service + gate + countdown/hold + SSE & decision routes + token injection
|
|
360
389
|
├── client.js 1918 browser half: decision panel + frame-level notice (self-contained classic script, no imports)
|
|
361
390
|
├── lib/ the host half's pure logic: no Cordis, no DOM, testable on its own
|
|
362
391
|
│ ├── protocol.js 182 frame and uplink validation, limits, error codes (the single source of the protocol)
|
|
363
|
-
│ ├── resolve.js
|
|
392
|
+
│ ├── resolve.js 311 matcher compilation (name/glob/RegExp/predicate), option normalization and diagnostics
|
|
364
393
|
│ ├── fields.js 244 default proposal derivation, diff pairing, decision → model-visible text
|
|
365
394
|
│ └── pending.js 217 the pending-decision state machine (injectable clock): countdown, hold, settle-once
|
|
366
395
|
├── locale/ the plugin list's name and description (shape must be {"meta":{...}}, see §6.3)
|
|
367
396
|
│ ├── en.json 6
|
|
368
397
|
│ └── zh.json 6
|
|
369
|
-
├── docs/
|
|
370
|
-
├── tests/ zero-dependency Node tests,
|
|
398
|
+
├── docs/ screenshot sources, deliberately NOT shipped: the README links the landing page instead
|
|
399
|
+
├── tests/ zero-dependency Node tests, 153 cases in total
|
|
371
400
|
│ ├── client.test.js 818 (65) the browser half's pure helpers + store / connection / seat
|
|
372
401
|
│ ├── fields.test.js 269 (26) proposal derivation and decision text
|
|
373
402
|
│ ├── pending.test.js 240 (18) the state machine (injected clock, no real waiting)
|
|
374
|
-
│ ├── resolve.test.js
|
|
403
|
+
│ ├── resolve.test.js 181 (19) matchers and option normalization
|
|
375
404
|
│ ├── protocol.test.js 117 (16) frame and uplink validation
|
|
376
|
-
│ ├── docs.test.js
|
|
377
|
-
│ └── manifest.test.js
|
|
378
|
-
├──
|
|
405
|
+
│ ├── docs.test.js 121 (5) the two READMEs, the landing-page link, and the numbers in this very table
|
|
406
|
+
│ └── manifest.test.js 77 (4) package metadata and the locale resource shape
|
|
407
|
+
├── .github/workflows/release.yml tag-triggered publish via npm trusted publishing (OIDC)
|
|
408
|
+
├── package.json 68 manifest: exports / dsh.bundle.patch / dsh.client / icon
|
|
379
409
|
├── cordis.patch.yml 17 the bundle's configuration layer (inserts the row with id `hitl`)
|
|
380
410
|
├── icon.svg 6 the plugin list icon
|
|
411
|
+
├── LICENSE MIT
|
|
381
412
|
├── README.md this document
|
|
382
413
|
└── README.zh.md the Chinese twin of this document
|
|
383
414
|
```
|
|
@@ -389,7 +420,7 @@ dsh-hitl/
|
|
|
389
420
|
| `index.js`, `lib/*.js` | host half (Node) | **restart dsh**. Node's ESM module cache is not invalidated by "disable → enable on that row" |
|
|
390
421
|
| `client.js` | browser half | **client hot-reload** picks it up (the host stats each row's client artefact every 500ms and broadcasts over `/plugins/events`); no page refresh needed |
|
|
391
422
|
| `locale/*.json` | plugin-list metadata | when the plugin manager re-reads metadata (opening the plugins page / restarting dsh) |
|
|
392
|
-
| `docs/*.png` |
|
|
423
|
+
| `docs/*.png` | screenshot sources | kept in the repository only; the published package excludes them (see the appendix note) |
|
|
393
424
|
| `package.json`, `cordis.patch.yml` | manifest and config layer | a restart or a reload of that row; an installed package is not swapped in place |
|
|
394
425
|
| `tests/*.js` | development | never runs in production; executed by `npm test` / `node --test` |
|
|
395
426
|
|
|
@@ -405,7 +436,7 @@ dsh-hitl/
|
|
|
405
436
|
|
|
406
437
|
```sh
|
|
407
438
|
node --check index.js client.js lib/*.js # syntax
|
|
408
|
-
npm test #
|
|
439
|
+
npm test # 153 zero-dependency cases: pure functions, state machines, the channel
|
|
409
440
|
npm run check # both of the above (syntax + the whole suite)
|
|
410
441
|
```
|
|
411
442
|
|
package/README.zh.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# dsh-hitl · 给任意工具挂上人工决策(Human-in-the-loop)
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-hitl) [](LICENSE) [](https://yunpengdon.github.io/dsh-hitl-landing/)
|
|
4
|
+
|
|
3
5
|
[English](README.md) | 中文
|
|
4
6
|
|
|
5
7
|
> **让 Agent 在动手之前,先问你一句。**
|
|
@@ -8,7 +10,7 @@
|
|
|
8
10
|
`dsh-hitl` 是 DeepSeek Harness (DSH) 的可安装插件(bundle):**零依赖、零构建**——`index.js`(宿主半)+ `client.js`(浏览器半)+ `lib/`
|
|
9
11
|
全是可直接加载的纯 JS,装上刷新一次页面就能用。
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
**[访问网站](https://yunpengdon.github.io/dsh-hitl-landing/)**
|
|
12
14
|
|
|
13
15
|
## 主要功能
|
|
14
16
|
|
|
@@ -19,7 +21,6 @@
|
|
|
19
21
|
- **带反馈文本的拒绝**:【拒绝】可以带一个**反馈栏**,在反馈栏中写下的意见会随拒绝一起交给 Agent——它知道"不要做",也知道"为什么不要做";
|
|
20
22
|
- **支持倒计时配置**:可配置**倒计时**与三种结局(**自动同意 / 自动拒绝 / 把超时事件反馈给 Agent**);
|
|
21
23
|
|
|
22
|
-

|
|
23
24
|
|
|
24
25
|
## 为DSH定制的适配
|
|
25
26
|
|
|
@@ -28,18 +29,21 @@
|
|
|
28
29
|
「来自子代理会话 xxx」+【前往该会话】。
|
|
29
30
|
(跨进程后端不进这道门禁,见 §6.1。)
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
[在 landing page 上看这张卡 →](https://yunpengdon.github.io/dsh-hitl-landing/)
|
|
32
33
|
|
|
33
34
|
**不在对话界面上时,支持弹出迷你批准请求** 切到插件页、打开设置、甚至没选会话时,页面顶部会浮出一条提醒事项,减少用户不在对话页时HITL对流程的阻塞。
|
|
34
35
|
(条数、工具名、来源会话、剩余时间 +【同意】【拒绝】【前往该会话】)。
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
[在 landing page 上看这条浮层 →](https://yunpengdon.github.io/dsh-hitl-landing/)
|
|
37
38
|
|
|
38
39
|
---
|
|
39
40
|
|
|
40
41
|
## 1. 安装
|
|
41
42
|
|
|
42
43
|
```sh
|
|
44
|
+
# 从 npm 装(推荐方式)
|
|
45
|
+
dsh plugin --profile <你的 profile> add dsh-hitl
|
|
46
|
+
|
|
43
47
|
# 直接从 GitHub 装(无需构建)
|
|
44
48
|
dsh plugin --profile <你的 profile> add github:YunpengDon/dsh-hitl
|
|
45
49
|
|
|
@@ -152,7 +156,7 @@ export function apply(ctx) {
|
|
|
152
156
|
| `reject.feedbackPrompt` | string | 内置文案 | 反馈栏 placeholder |
|
|
153
157
|
| `reject.requireFeedback` | boolean | `false` | 反馈必填(为空时拒绝按钮会提示) |
|
|
154
158
|
| `modify.mode` | `revise-request` \| `allow-and-inform` | `revise-request` | 用户改了文本后点【修改】的语义,见 §5 |
|
|
155
|
-
| `whenUnavailable` | `reject` \| `wait` | `reject` |
|
|
159
|
+
| `whenUnavailable` | `reject` \| `wait` | `reject` | 没有任何浏览器连接时的行为:`reject` = fail-closed;`wait` = 一直等,**务必配 `countdown`**,否则那次调用会挂到会话结束(挂载时宿主会给 `dsh-hitl:` 警告),见 §7.1 |
|
|
156
160
|
| `enabled` | boolean \| `(exec) => boolean` | `true` | 临时关闭 / 条件挂载 |
|
|
157
161
|
| `maxFieldChars` | number | `20000` | 单字段渲染上限,超出会截断并提示 |
|
|
158
162
|
|
|
@@ -337,6 +341,21 @@ Error: HITL: no human decision arrived within 75s, so tool "glob" did not run. T
|
|
|
337
341
|
想让它在无人值守时一直等,把 `whenUnavailable` 设为 `wait`。
|
|
338
342
|
- 插件在没有任何 Web 服务的组合里(headless/TUI)**仍然激活并继续拦**——"没有 UI 就不拦"不是本插件的语义。
|
|
339
343
|
|
|
344
|
+
### 7.1 安全边界:HITL 拦得住什么、拦不住什么
|
|
345
|
+
|
|
346
|
+
- **它拦的是"这一次调用在真正执行之前",不是世界状态。** 如果工具在自己的执行层已经产生了副作用(并发竞态、异步落盘、
|
|
347
|
+
已经发出去的外部请求),拒绝只能阻止"接下来",回滚不了"已经"——**HITL 不是事务,也不是沙箱**。
|
|
348
|
+
- 所以实际安全程度取决于两件事:**① 你的 matcher 覆盖了多危险的操作;② 工具是否把不可逆动作放在最后一刻。**
|
|
349
|
+
把 `bash` / `write` / `edit` 这类"入口即危险"的工具挂上是对的;若某个工具的风险要到执行中途才出现,HITL 只能替你决定"要不要开始"。
|
|
350
|
+
- **用户的同意 ≠ 越权**:同意之后沙箱、guard、hooks、审批照常随后生效(§8);反过来,别的执行前策略也可能**先于**本插件拒绝——
|
|
351
|
+
实测过一次:`fs-observation-policy` 在用户点【同意】之前就把调用挡了,HITL 连面板都没弹(见 §8)。
|
|
352
|
+
- **`whenUnavailable: wait` 是 fail-open**:没有任何浏览器连接时会一直等,没配 `countdown` 就是等到会话结束。
|
|
353
|
+
想让"无人值守也不卡死",用默认的 `reject`,或者 `wait` + `countdown`(挂载时宿主会对这种组合给出警告)。
|
|
354
|
+
- **本机信任边界**:决策通道与 `/status` 都只认回环地址。同机任意进程可以读 `/status`,它只含**挂载列表**与**待决策的
|
|
355
|
+
工具名 / 会话 id / 剩余毫秒 / 是否暂停**——**没有工具参数,也没有令牌**。要把这个口子量准:同机进程本来就能读
|
|
356
|
+
`~/.dsh/sessions/**` 的会话日志,而那里有完整的工具参数,所以 `/status` 的暴露面是它严格更小的子集。
|
|
357
|
+
这个口子是有意保留的:没有它,在终端里回答"HITL 现在拦着什么"就只能靠翻页面。
|
|
358
|
+
|
|
340
359
|
---
|
|
341
360
|
|
|
342
361
|
## 8. 与其他执行前策略的关系
|
|
@@ -344,6 +363,9 @@ Error: HITL: no human decision arrived within 75s, so tool "glob" did not run. T
|
|
|
344
363
|
- 本插件用 `tools/pre-execute` 且 `prepend: true`,也就是**先问人**;用户同意后,
|
|
345
364
|
沙箱、guard、hooks、审批等其它策略照常随后生效。**用户的同意不等于越权**。
|
|
346
365
|
- 因此可能出现"用户同意了,但工具仍被沙箱/守卫拒绝"——这是设计使然,不是 bug。
|
|
366
|
+
- **反过来也成立**:别的执行前策略可能在**本插件之前**就拒绝,此时 HITL 面板根本不会出现——所以"没弹面板就直接执行了/就失败了"
|
|
367
|
+
两种现象都要先看别的策略。实测过一次:`fs-observation-policy` 要求"重写已删除文件前先重读",
|
|
368
|
+
它在用户点【同意】**之后**把调用挡了下来(面板照常弹过),也在另一轮里**先于**本插件直接拒绝(面板没弹)。
|
|
347
369
|
- 本插件只做"执行前问人",不改变工具参数、不改变工具结果。
|
|
348
370
|
|
|
349
371
|
---
|
|
@@ -351,6 +373,7 @@ Error: HITL: no human decision arrived within 75s, so tool "glob" did not run. T
|
|
|
351
373
|
## 9. 已知限制
|
|
352
374
|
|
|
353
375
|
- **不能改写工具参数**(见 §5),所以【修改】默认走"拒绝并交回修改内容"。
|
|
376
|
+
- **HITL 不是副作用边界**(见 §7.1):它拦的是"调用开始之前",回滚不了已经发生的副作用;安全性取决于 matcher 覆盖面与工具自身的设计。
|
|
354
377
|
- **挂载文案不本地化**:`title` / `labels` / 字段标题都是挂载方给的字符串,本插件不翻译,也不接受 `{zh, en}` 映射(见 §6.3)。
|
|
355
378
|
- **不写会话审计事件**:DSH 不允许插件追加新的事件类型,决策痕迹只存在于工具结果(和 `allow-and-inform` 的那条 user 消息)里。
|
|
356
379
|
- **只有 Web 界面有面板**:ACP/TUI/headless 下按 `whenUnavailable` 处理(默认拒绝)。
|
|
@@ -425,28 +448,30 @@ Error: HITL: no human decision arrived within 75s, so tool "glob" did not run. T
|
|
|
425
448
|
|
|
426
449
|
```text
|
|
427
450
|
dsh-hitl/
|
|
428
|
-
├── index.js
|
|
451
|
+
├── index.js 639 宿主半:服务 + 门禁 + 倒计时/持握 + SSE/决策路由 + 令牌注入
|
|
429
452
|
├── client.js 1918 浏览器半:决策面板 + 帧级浮层(自包含 classic script,无 import)
|
|
430
453
|
├── lib/ 宿主半的纯逻辑:不碰 Cordis、不碰 DOM,可单独跑测试
|
|
431
454
|
│ ├── protocol.js 182 帧与上行校验、上限、错误码(协议规范的唯一出处)
|
|
432
|
-
│ ├── resolve.js
|
|
455
|
+
│ ├── resolve.js 311 matcher 编译(名字/通配/RegExp/谓词)、挂载选项归一化与诊断
|
|
433
456
|
│ ├── fields.js 244 默认提案推导、diff 配对、决策 → 模型可见文本
|
|
434
457
|
│ └── pending.js 217 待决策状态机(可注入时钟):倒计时、持握、恰好一次的结算
|
|
435
458
|
├── locale/ 插件列表里的名字与简介(形状必须是 {"meta":{...}},见 §6.3)
|
|
436
459
|
│ ├── en.json 6
|
|
437
460
|
│ └── zh.json 6
|
|
438
|
-
├── docs/
|
|
439
|
-
├── tests/ 零依赖 Node 测试,共
|
|
461
|
+
├── docs/ 截图源文件,**故意不进 npm 包**:README 改为链接 landing page
|
|
462
|
+
├── tests/ 零依赖 Node 测试,共 153 个用例
|
|
440
463
|
│ ├── client.test.js 818 (65)浏览器半的纯函数 + store / connection / seat
|
|
441
464
|
│ ├── fields.test.js 269 (26)提案推导与决策文本
|
|
442
465
|
│ ├── pending.test.js 240 (18)状态机(注入时钟,无真实等待)
|
|
443
|
-
│ ├── resolve.test.js
|
|
466
|
+
│ ├── resolve.test.js 181 (19)matcher 与选项归一化
|
|
444
467
|
│ ├── protocol.test.js 117 (16)帧与上行校验
|
|
445
|
-
│ ├── docs.test.js
|
|
446
|
-
│ └── manifest.test.js
|
|
447
|
-
├──
|
|
468
|
+
│ ├── docs.test.js 121 (5)两份 README、landing page 链接,以及上面这张表里的每个数字
|
|
469
|
+
│ └── manifest.test.js 77 (4)包元数据与 locale 资源形状
|
|
470
|
+
├── .github/workflows/release.yml tag-triggered publish via npm trusted publishing (OIDC)
|
|
471
|
+
├── package.json 68 清单:exports / dsh.bundle.patch / dsh.client / icon
|
|
448
472
|
├── cordis.patch.yml 17 bundle 的配置层(插入 id 为 hitl 的那一行)
|
|
449
473
|
├── icon.svg 6 插件列表图标
|
|
474
|
+
├── LICENSE MIT
|
|
450
475
|
├── README.md 本文档(英文版)
|
|
451
476
|
└── README.zh.md 本文档的中文版
|
|
452
477
|
```
|
|
@@ -458,7 +483,7 @@ dsh-hitl/
|
|
|
458
483
|
| `index.js`、`lib/*.js` | 宿主半(Node) | 必须**重启 dsh**。Node 的 ESM 模块缓存不会因为"禁用→启用这一行"而失效 |
|
|
459
484
|
| `client.js` | 浏览器半 | **客户端热重载**自动拾取(宿主每 500ms stat 一次各行的 client 产物并广播 `/plugins/events`),不用刷新页面 |
|
|
460
485
|
| `locale/*.json` | 插件列表元数据 | 插件管理器重读元数据时生效(打开插件页 / 重启 dsh) |
|
|
461
|
-
| `docs/*.png` |
|
|
486
|
+
| `docs/*.png` | 截图源文件 | 只留在仓库里;发布的包里不再包含(见附录说明) |
|
|
462
487
|
| `package.json`、`cordis.patch.yml` | 清单与配置层 | 需要重启或重新加载该行;已安装包不会被就地替换 |
|
|
463
488
|
| `tests/*.js` | 开发期 | 不参与运行,只由 `npm test` / `node --test` 执行 |
|
|
464
489
|
|
|
@@ -480,7 +505,7 @@ dsh-hitl/
|
|
|
480
505
|
|
|
481
506
|
```sh
|
|
482
507
|
node --check index.js client.js lib/*.js # 语法
|
|
483
|
-
npm test #
|
|
508
|
+
npm test # 153 个纯函数/状态机/通道用例,零依赖
|
|
484
509
|
npm run check # 上面两步合起来(语法 + 全部用例)
|
|
485
510
|
```
|
|
486
511
|
|
package/index.js
CHANGED
|
@@ -107,7 +107,14 @@ export function apply(ctx, config = {}) {
|
|
|
107
107
|
const clients = new Set()
|
|
108
108
|
const resolvers = new Map()
|
|
109
109
|
const cleanups = new Map()
|
|
110
|
-
|
|
110
|
+
// Revision context for `allow-and-inform`, keyed by the execution object the
|
|
111
|
+
// pipeline carries from `tools/pre-execute` through `tools/post-execute`.
|
|
112
|
+
// A WeakMap, not a callId-keyed Map: if a call is allowed and then never
|
|
113
|
+
// reaches post-execute (an aborted turn, a process torn down mid-execution),
|
|
114
|
+
// a Map entry would outlive the call for the rest of the process. Keyed by
|
|
115
|
+
// the execution itself, the entry dies with the call that owns it, so there
|
|
116
|
+
// is nothing to leak and nothing to remember to clean up.
|
|
117
|
+
const revisions = new WeakMap()
|
|
111
118
|
|
|
112
119
|
const registry = createPendingRegistry({
|
|
113
120
|
holdGraceMs: positiveInteger(options.holdGraceMs, DEFAULT_HOLD_GRACE_MS),
|
|
@@ -293,7 +300,9 @@ export function apply(ctx, config = {}) {
|
|
|
293
300
|
if (settlement.decision.kind === 'cancel') return { kind: 'cancel' }
|
|
294
301
|
const revising = isRevision(settlement.decision)
|
|
295
302
|
if (revising && mount.options.modify.mode === 'allow-and-inform' && settlement.source === OUTCOMES.user) {
|
|
296
|
-
|
|
303
|
+
// The call is about to run with its original arguments; the edit rides
|
|
304
|
+
// along as context on the result instead (see the post-execute listener).
|
|
305
|
+
revisions.set(exec, { request, decision: settlement.decision })
|
|
297
306
|
return { kind: 'allow' }
|
|
298
307
|
}
|
|
299
308
|
const info = settlement.source === OUTCOMES.timeout
|
|
@@ -342,17 +351,17 @@ export function apply(ctx, config = {}) {
|
|
|
342
351
|
|
|
343
352
|
/**
|
|
344
353
|
* Hand the user's revision to the model next to the result it approved, in
|
|
345
|
-
* the `allow-and-inform` modify mode.
|
|
346
|
-
*
|
|
347
|
-
*
|
|
354
|
+
* the `allow-and-inform` modify mode. Lookup is by the execution object, so a
|
|
355
|
+
* call that never reaches this stage costs nothing but its own lifetime; the
|
|
356
|
+
* delete keeps a second post-execute for the same execution from attaching
|
|
357
|
+
* the same context twice.
|
|
348
358
|
*/
|
|
349
359
|
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
350
360
|
const decision = await next()
|
|
351
|
-
|
|
352
|
-
const key = String(exec.callId)
|
|
353
|
-
const revision = revisions.get(key)
|
|
361
|
+
const revision = revisions.get(exec)
|
|
354
362
|
if (revision === undefined) return decision
|
|
355
|
-
revisions.delete(
|
|
363
|
+
revisions.delete(exec)
|
|
364
|
+
const label = exec.callId === undefined ? String(exec.name) : String(exec.callId)
|
|
356
365
|
try {
|
|
357
366
|
const message = {
|
|
358
367
|
id: randomUUID(),
|
|
@@ -362,7 +371,7 @@ export function apply(ctx, config = {}) {
|
|
|
362
371
|
}
|
|
363
372
|
return { ...decision, additionalContexts: [...(decision.additionalContexts ?? []), message] }
|
|
364
373
|
} catch (error) {
|
|
365
|
-
warn(`the revision context for ${
|
|
374
|
+
warn(`the revision context for ${label} was dropped: ${String(error)}`)
|
|
366
375
|
return decision
|
|
367
376
|
}
|
|
368
377
|
})
|
|
@@ -622,7 +631,8 @@ export function apply(ctx, config = {}) {
|
|
|
622
631
|
}
|
|
623
632
|
}
|
|
624
633
|
mounts.length = 0
|
|
625
|
-
revisions
|
|
634
|
+
// `revisions` is a WeakMap: shutdown has nothing to clear, and a call that
|
|
635
|
+
// never came back takes its own entry with it.
|
|
626
636
|
resolvers.clear()
|
|
627
637
|
cleanups.clear()
|
|
628
638
|
}, 'dsh-hitl: shutdown')
|
package/lib/resolve.js
CHANGED
|
@@ -216,6 +216,17 @@ export function normalizeMount(input, source) {
|
|
|
216
216
|
const labels = Array.isArray(input.labels)
|
|
217
217
|
? input.labels.filter(label => typeof label === 'string' && label !== '')
|
|
218
218
|
: []
|
|
219
|
+
const countdown = normalizeCountdown(input.countdown, report)
|
|
220
|
+
const unavailable = WHEN_UNAVAILABLE.includes(whenUnavailable)
|
|
221
|
+
? whenUnavailable
|
|
222
|
+
: DEFAULT_OPTIONS.whenUnavailable
|
|
223
|
+
// `wait` is fail-open by design: with no browser attached the call sits there
|
|
224
|
+
// until something ends it. Without a countdown, "something" means the session
|
|
225
|
+
// or the plugin, so an unattended run blocks an agent for as long as it lives.
|
|
226
|
+
// Say so at mount time rather than letting the first unattended run discover it.
|
|
227
|
+
if (unavailable === 'wait' && countdown === null) {
|
|
228
|
+
report('whenUnavailable: wait without a countdown blocks an unattended call until its session ends; pair it with countdown.seconds or use the fail-closed default')
|
|
229
|
+
}
|
|
219
230
|
const mount = {
|
|
220
231
|
describe: matcher.describe,
|
|
221
232
|
test: matcher.test,
|
|
@@ -226,12 +237,10 @@ export function normalizeMount(input, source) {
|
|
|
226
237
|
buttons: normalizeButtons(input.buttons, report),
|
|
227
238
|
fields,
|
|
228
239
|
diff: input.diff,
|
|
229
|
-
countdown
|
|
240
|
+
countdown,
|
|
230
241
|
reject: normalizeReject(input.reject, report),
|
|
231
242
|
modify: normalizeModify(input.modify, report),
|
|
232
|
-
whenUnavailable:
|
|
233
|
-
? whenUnavailable
|
|
234
|
-
: DEFAULT_OPTIONS.whenUnavailable,
|
|
243
|
+
whenUnavailable: unavailable,
|
|
235
244
|
enabled: input.enabled === undefined ? true : input.enabled,
|
|
236
245
|
maxFieldChars,
|
|
237
246
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hitl",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Human-in-the-loop gate for DSH tool calls: mount any tool behind an approve / modify / reject decision card in the DSH Web UI, with an optional countdown.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -39,7 +39,6 @@
|
|
|
39
39
|
"lib",
|
|
40
40
|
"tests",
|
|
41
41
|
"locale",
|
|
42
|
-
"docs",
|
|
43
42
|
"icon.svg",
|
|
44
43
|
"cordis.patch.yml",
|
|
45
44
|
"README.md",
|
package/tests/docs.test.js
CHANGED
|
@@ -15,6 +15,8 @@ import { fileURLToPath } from 'node:url'
|
|
|
15
15
|
*/
|
|
16
16
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
17
17
|
|
|
18
|
+
const LANDING_PAGE = 'https://yunpengdon.github.io/dsh-hitl-landing/'
|
|
19
|
+
|
|
18
20
|
/** The English README is the package's front page; the Chinese one is its twin. */
|
|
19
21
|
const READMES = [
|
|
20
22
|
{ language: 'en', file: 'README.md', other: 'README.zh.md', suffix: '-en' },
|
|
@@ -62,21 +64,22 @@ describe('docs: the English and Chinese READMEs', () => {
|
|
|
62
64
|
}
|
|
63
65
|
})
|
|
64
66
|
|
|
65
|
-
it('
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
it('sends the reader to the landing page instead of shipping screenshots', () => {
|
|
68
|
+
// The four screenshots used to be the bulk of the published tarball. They
|
|
69
|
+
// live on the landing page now (its own repository carries them, in both
|
|
70
|
+
// languages), so the READMEs must link it and must not reference a local
|
|
71
|
+
// copy — and the package must not carry one either.
|
|
72
|
+
const manifest = JSON.parse(read('package.json'))
|
|
73
|
+
assert.equal(manifest.files.includes('docs'), false, 'screenshots must stay out of the tarball')
|
|
68
74
|
for (const readme of READMES) {
|
|
69
|
-
const
|
|
70
|
-
assert.equal(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
for (const name of available) {
|
|
79
|
-
assert.equal(referenced.has(name), true, `docs/${name} is shipped but never shown`)
|
|
75
|
+
const text = read(readme.file)
|
|
76
|
+
assert.equal(text.includes(LANDING_PAGE), true, `${readme.file} must link the landing page`)
|
|
77
|
+
assert.equal(
|
|
78
|
+
/!\[[^\]]*\]\(docs\//.test(text), false,
|
|
79
|
+
`${readme.file} must not embed a local screenshot`,
|
|
80
|
+
)
|
|
81
|
+
const header = text.split('\n').slice(0, 8).join('\n')
|
|
82
|
+
assert.equal(header.includes(LANDING_PAGE), true, `${readme.file} must offer the tour near the top`)
|
|
80
83
|
}
|
|
81
84
|
})
|
|
82
85
|
|
|
@@ -108,9 +111,11 @@ describe('docs: the English and Chinese READMEs', () => {
|
|
|
108
111
|
}
|
|
109
112
|
})
|
|
110
113
|
|
|
111
|
-
it('keeps the screenshot
|
|
114
|
+
it('publishes both READMEs and keeps the screenshot sources in the repository only', () => {
|
|
112
115
|
const manifest = JSON.parse(read('package.json'))
|
|
113
|
-
assert.equal(manifest.files.includes('docs'), true, '`docs` must be published with the screenshots')
|
|
114
116
|
assert.equal(manifest.files.includes('README.zh.md'), true, 'the Chinese README must be published too')
|
|
117
|
+
// The landing page's own repository carries the screenshots, so a second
|
|
118
|
+
// copy inside the package would only double the download for every install.
|
|
119
|
+
assert.equal(manifest.files.includes('docs'), false, 'screenshot sources must stay out of the tarball')
|
|
115
120
|
})
|
|
116
121
|
})
|
package/tests/manifest.test.js
CHANGED
|
@@ -24,6 +24,22 @@ const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
|
24
24
|
const LANGUAGES = ['en', 'zh']
|
|
25
25
|
|
|
26
26
|
describe('manifest: what a profile reads about this plugin', () => {
|
|
27
|
+
it('keeps the tag-triggered release on stage-only trusted publishing', () => {
|
|
28
|
+
const workflow = readFileSync(join(root, '.github', 'workflows', 'release.yml'), 'utf8')
|
|
29
|
+
assert.equal(workflow.includes("tags: ['v*']"), true, 'the workflow must trigger on version tags')
|
|
30
|
+
assert.equal(workflow.includes('id-token: write'), true, 'OIDC publishing needs the id-token permission')
|
|
31
|
+
assert.equal(workflow.includes('contents: read'), true)
|
|
32
|
+
// Stage-only: the workflow may upload a release, but a maintainer approves
|
|
33
|
+
// it with 2FA before it becomes public, so it must never publish directly.
|
|
34
|
+
assert.equal(/run: npm stage publish/.test(workflow), true, 'the release step must stage')
|
|
35
|
+
assert.equal(/run: npm publish\b/.test(workflow), false, 'a direct publish would need "publish directly" granted')
|
|
36
|
+
// Provenance is automatic under trusted publishing — but this file *talks*
|
|
37
|
+
// about that, so assert on the command, not on the words.
|
|
38
|
+
assert.equal(/npm stage publish[^\n]*--provenance/.test(workflow), false, 'no --provenance flag on the command')
|
|
39
|
+
// npm matches the configured trusted publisher against this exact filename.
|
|
40
|
+
assert.equal(workflow.includes('release.yml'), true, 'the trusted publisher is configured with this filename')
|
|
41
|
+
})
|
|
42
|
+
|
|
27
43
|
it('resolves the resources the Loader asks for by name', () => {
|
|
28
44
|
assert.equal(manifest.name, 'dsh-hitl')
|
|
29
45
|
assert.equal(typeof manifest.description, 'string')
|
package/tests/resolve.test.js
CHANGED
|
@@ -79,6 +79,25 @@ describe('resolve: normalizeMount', () => {
|
|
|
79
79
|
assert.match(warnings[0], /countdown.seconds/)
|
|
80
80
|
})
|
|
81
81
|
|
|
82
|
+
it('warns when a mount waits forever with nothing to end the wait', () => {
|
|
83
|
+
// Fail-open plus no deadline is legal, and it blocks an unattended call for
|
|
84
|
+
// as long as its session lives. The mount is still installed; the warning is
|
|
85
|
+
// the whole point, because the first unattended run must not be the discovery.
|
|
86
|
+
const open = normalizeMount({ tool: 'bash', whenUnavailable: 'wait' }, 'src')
|
|
87
|
+
assert.equal(open.ok, true)
|
|
88
|
+
assert.equal(open.mount.options.whenUnavailable, 'wait')
|
|
89
|
+
assert.equal(open.warnings.length, 1)
|
|
90
|
+
assert.match(open.warnings[0], /whenUnavailable: wait without a countdown/)
|
|
91
|
+
|
|
92
|
+
// A deadline is what bounds it, so the pairing is silent — and so is the
|
|
93
|
+
// fail-closed default, which needs no deadline to be safe.
|
|
94
|
+
const bounded = normalizeMount({ tool: 'bash', whenUnavailable: 'wait', countdown: { seconds: 300, action: 'reject' } }, 'src')
|
|
95
|
+
assert.equal(bounded.warnings.length, 0)
|
|
96
|
+
const closed = normalizeMount({ tool: 'bash' }, 'src')
|
|
97
|
+
assert.equal(closed.mount.options.whenUnavailable, 'reject')
|
|
98
|
+
assert.equal(closed.warnings.length, 0)
|
|
99
|
+
})
|
|
100
|
+
|
|
82
101
|
it('normalizes field entries and drops invalid ones', () => {
|
|
83
102
|
const { mount, warnings } = normalizeMount({
|
|
84
103
|
tool: 'edit',
|
package/docs/01-panel-cn.png
DELETED
|
Binary file
|
package/docs/01-panel-en.png
DELETED
|
Binary file
|
package/docs/02-fields-cn.png
DELETED
|
Binary file
|
package/docs/02-fields-en.png
DELETED
|
Binary file
|
package/docs/03-subagent-cn.png
DELETED
|
Binary file
|
package/docs/03-subagent-en.png
DELETED
|
Binary file
|
package/docs/04-overlay-cn.png
DELETED
|
Binary file
|
package/docs/04-overlay-en.png
DELETED
|
Binary file
|