dsh-runtime-kit 0.2.0 → 0.3.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
@@ -57,6 +57,17 @@ DSH 的工具调用链;沙箱拦截可以做,但**默认关闭**。
57
57
 
58
58
  细节、`dsh-context` 共存策略与「返回体截断」的方案见 [`docs/GOVERNANCE.md`](docs/GOVERNANCE.md)。
59
59
 
60
+ ### 页面
61
+
62
+ 插件带一个客户端半(`client.js`),治理状态不用再敲命令看:
63
+
64
+ - **设置页 → 运行时治理**:总览(工具/模板/工作区/审计条数)、四个开关、沙箱规则、最近 20 条审计流水、注册表内容
65
+ - **侧栏 → 运行时**(装了 better-sidebar 时):同一套内容,日常查看更顺手
66
+
67
+ 开关改完点保存即刻生效,不需要重启;选择写进 `<stateDir>/governance.json`,下次启动沿用。
68
+
69
+ 页面和命令读的是同一份状态:`runtime_kit_health`、`runtime_kit_audit_logs`、`runtime_kit_config` 看到的结果与页面一致。
70
+
60
71
  ---
61
72
 
62
73
  ## 🔌 对外契约:Registry(唯一耦合面)
package/client.js ADDED
@@ -0,0 +1,522 @@
1
+ /**
2
+ * dsh-runtime-kit — Client half.
3
+ *
4
+ * The page exists because the plugin's abilities used to be reachable only by
5
+ * typing `runtime_kit_*`. Everything shown here comes from `/runtime-kit/api`,
6
+ * so the page and the commands never disagree.
7
+ *
8
+ * Two surfaces, one component: a Settings section (always available) and a
9
+ * better-sidebar tab (when that plugin is installed).
10
+ */
11
+ window.__ModuleLoader__.load({
12
+ id: 'dsh-runtime-kit',
13
+ factory(require) {
14
+ const React = require('react')
15
+ const h = React.createElement
16
+ const { useState, useEffect, useCallback } = React
17
+
18
+ const isZh = /^zh/i.test((typeof navigator !== 'undefined' && navigator.language) || 'zh')
19
+ const L = (zh, en) => (isZh ? zh : en)
20
+
21
+ const API = '/runtime-kit/api/'
22
+
23
+ async function call(method, body) {
24
+ const res = await fetch(API + method, {
25
+ method: 'POST',
26
+ headers: { 'content-type': 'application/json' },
27
+ credentials: 'same-origin',
28
+ body: JSON.stringify(body ?? {}),
29
+ })
30
+ const payload = await res.json().catch(() => null)
31
+ if (!payload || payload.ok !== true) {
32
+ const message = payload?.error?.message || `HTTP ${res.status}`
33
+ throw new Error(message)
34
+ }
35
+ return payload.value
36
+ }
37
+
38
+ // ------------------------------------------------------------ styles ---
39
+ const CSS = `
40
+ .rk-root{max-width:820px;display:flex;flex-direction:column;gap:12px;color:var(--dsw-alias-label-primary,inherit);font-size:13px;line-height:1.5}
41
+ .rk-root,.rk-root *{box-sizing:border-box}
42
+ .rk-hero{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
43
+ .rk-h1{font-size:15px;font-weight:600;letter-spacing:.2px}
44
+ .rk-sub{font-size:12px;color:var(--dsw-alias-label-secondary,#9a9a9a)}
45
+ .rk-card{border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.28));border-radius:12px;padding:14px 16px;display:flex;flex-direction:column;gap:10px}
46
+ .rk-card-head{display:flex;align-items:center;gap:8px}
47
+ .rk-card-title{font-size:13px;font-weight:600;flex:1;min-width:0}
48
+ .rk-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
49
+ .rk-stat{border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.2));border-radius:10px;padding:9px 11px;display:flex;flex-direction:column;gap:2px;min-width:0}
50
+ .rk-stat-k{font-size:11.5px;color:var(--dsw-alias-label-secondary,#9a9a9a)}
51
+ .rk-stat-v{font-size:18px;font-weight:600;font-variant-numeric:tabular-nums}
52
+ .rk-row{display:flex;align-items:center;gap:10px;justify-content:space-between;padding:7px 0;border-top:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.14))}
53
+ .rk-row:first-child{border-top:0}
54
+ .rk-row-main{display:flex;flex-direction:column;gap:1px;min-width:0}
55
+ .rk-row-t{font-size:12.5px}
56
+ .rk-row-d{font-size:11.5px;color:var(--dsw-alias-label-secondary,#9a9a9a)}
57
+ .rk-badge{font-size:11px;padding:1px 7px;border-radius:20px;border:1px solid currentColor;white-space:nowrap}
58
+ .rk-badge.ok{color:#3fb950}.rk-badge.warn{color:#d29922}.rk-badge.bad{color:#f85149}.rk-badge.muted{color:var(--dsw-alias-label-secondary,#9a9a9a)}
59
+ .rk-btn{height:28px;padding:0 11px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3));background:transparent;color:inherit;font:inherit;font-size:12px;cursor:pointer;white-space:nowrap}
60
+ .rk-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12))}
61
+ .rk-btn:disabled{opacity:.5;cursor:default}
62
+ .rk-btn.primary{border-color:color-mix(in srgb,#4c8dff 55%,transparent);color:var(--dsw-alias-brand-primary,#8ab4ff);background:color-mix(in srgb,#4c8dff 13%,transparent)}
63
+ .rk-seg{display:inline-flex;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3));border-radius:8px;overflow:hidden}
64
+ .rk-seg button{height:26px;padding:0 10px;border:0;background:transparent;color:inherit;font:inherit;font-size:12px;cursor:pointer}
65
+ .rk-seg button.on{background:color-mix(in srgb,#4c8dff 18%,transparent);color:var(--dsw-alias-brand-primary,#7fb0ff)}
66
+ .rk-switch{position:relative;width:34px;height:19px;border-radius:20px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:transparent;cursor:pointer;flex:0 0 auto;padding:0}
67
+ .rk-switch.on{background:color-mix(in srgb,#4c8dff 45%,transparent);border-color:transparent}
68
+ .rk-switch::after{content:'';position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;background:var(--dsw-alias-label-secondary,#9a9a9a);transition:left .12s}
69
+ .rk-switch.on::after{left:17px;background:#fff}
70
+ .rk-mono{font-family:var(--dsw-font-mono,ui-monospace,Consolas,monospace);font-size:11.5px;color:var(--dsw-alias-label-secondary,#9a9a9a);word-break:break-all}
71
+ .rk-table{width:100%;border-collapse:collapse;font-size:11.5px}
72
+ .rk-table th{text-align:left;font-weight:500;color:var(--dsw-alias-label-secondary,#9a9a9a);padding:4px 6px;border-bottom:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.2));white-space:nowrap}
73
+ .rk-table td{padding:4px 6px;border-bottom:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.1));white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:220px}
74
+ .rk-chips{display:flex;flex-wrap:wrap;gap:5px}
75
+ .rk-chip{font-family:var(--dsw-font-mono,ui-monospace,monospace);font-size:11px;padding:1px 6px;border-radius:6px;background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.12))}
76
+ .rk-empty{font-size:12px;color:var(--dsw-alias-label-secondary,#9a9a9a);padding:2px 0}
77
+ .rk-err{font-size:12px;color:#f85149}
78
+ `
79
+
80
+ let cssReady = false
81
+ function ensureCss() {
82
+ if (cssReady || typeof document === 'undefined') return
83
+ cssReady = true
84
+ const style = document.createElement('style')
85
+ style.id = 'runtime-kit-style'
86
+ style.textContent = CSS
87
+ document.head.appendChild(style)
88
+ }
89
+
90
+ // ------------------------------------------------------------ pieces ---
91
+
92
+ function Card({ title, right, children }) {
93
+ return h(
94
+ 'div',
95
+ { className: 'rk-card' },
96
+ h('div', { className: 'rk-card-head' }, h('div', { className: 'rk-card-title' }, title), right || null),
97
+ children,
98
+ )
99
+ }
100
+
101
+ function Stat({ label, value, tone }) {
102
+ return h(
103
+ 'div',
104
+ { className: 'rk-stat' },
105
+ h('div', { className: 'rk-stat-k' }, label),
106
+ h(
107
+ 'div',
108
+ {
109
+ className: 'rk-stat-v',
110
+ style: {
111
+ ...(tone ? { color: tone } : null),
112
+ // 字符串值(workspace-write / deny)比数字长,缩小一号才不折行
113
+ ...(typeof value === 'string' && value.length > 8 ? { fontSize: '14px' } : null),
114
+ },
115
+ },
116
+ String(value ?? '—'),
117
+ ),
118
+ )
119
+ }
120
+
121
+ function Switch({ on, onChange, disabled }) {
122
+ return h('button', {
123
+ type: 'button',
124
+ className: `rk-switch${on ? ' on' : ''}`,
125
+ 'aria-pressed': on ? 'true' : 'false',
126
+ disabled,
127
+ onClick: () => onChange(!on),
128
+ })
129
+ }
130
+
131
+ function ToggleRow({ title, desc, on, disabled, onChange }) {
132
+ return h(
133
+ 'div',
134
+ { className: 'rk-row' },
135
+ h(
136
+ 'div',
137
+ { className: 'rk-row-main' },
138
+ h('div', { className: 'rk-row-t' }, title),
139
+ desc ? h('div', { className: 'rk-row-d' }, desc) : null,
140
+ ),
141
+ h(Switch, { on, onChange, disabled }),
142
+ )
143
+ }
144
+
145
+ function toneOf(status) {
146
+ if (status === 'healthy') return 'ok'
147
+ if (status === 'degraded') return 'warn'
148
+ return 'bad'
149
+ }
150
+
151
+ function stamp(iso) {
152
+ const d = new Date(iso)
153
+ if (Number.isNaN(d.getTime())) return String(iso || '')
154
+ const p = (n) => String(n).padStart(2, '0')
155
+ return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
156
+ }
157
+
158
+ // -------------------------------------------------------------- panel ---
159
+
160
+ function RuntimeKitPanel({ initial }) {
161
+ ensureCss()
162
+ // `initial` exists for the render tests: SSR runs no effects, so a page
163
+ // that only fetches would always screenshot as "loading".
164
+ const [state, setState] = useState(initial || null)
165
+ const [governance, setGovernance] = useState(initial?.governance || null)
166
+ const [busy, setBusy] = useState('')
167
+ const [note, setNote] = useState('')
168
+ const [error, setError] = useState('')
169
+
170
+ const load = useCallback(async () => {
171
+ setBusy('load')
172
+ setError('')
173
+ try {
174
+ const value = await call('state')
175
+ setState(value)
176
+ setGovernance(value.governance)
177
+ } catch (e) {
178
+ setError(String(e.message || e))
179
+ } finally {
180
+ setBusy('')
181
+ }
182
+ }, [])
183
+
184
+ useEffect(() => {
185
+ load()
186
+ }, [load])
187
+
188
+ const save = async () => {
189
+ setBusy('save')
190
+ setNote('')
191
+ setError('')
192
+ try {
193
+ const value = await call('governance', { governance })
194
+ setGovernance(value.governance)
195
+ setNote(L('已保存,立即生效。', 'Saved. Takes effect immediately.'))
196
+ await load()
197
+ } catch (e) {
198
+ setError(String(e.message || e))
199
+ } finally {
200
+ setBusy('')
201
+ }
202
+ }
203
+
204
+ const dirty = Boolean(
205
+ state && governance && JSON.stringify(state.governance) !== JSON.stringify(governance),
206
+ )
207
+
208
+ const health = state?.health || {}
209
+ const audit = state?.audit || []
210
+ const registry = state?.registry || { tools: [], templates: [], workspaces: [] }
211
+ const sandbox = state?.sandbox || {}
212
+
213
+ return h(
214
+ 'div',
215
+ { className: 'rk-root' },
216
+ h(
217
+ 'div',
218
+ { className: 'rk-hero' },
219
+ h('div', { className: 'rk-h1' }, L('运行时治理', 'Runtime governance')),
220
+ h(
221
+ 'span',
222
+ { className: `rk-badge ${toneOf(health.status)}` },
223
+ health.status || (busy === 'load' ? L('读取中', 'loading') : L('未知', 'unknown')),
224
+ ),
225
+ h('div', { className: 'rk-sub' }, `v${health.version || '—'} · dsh ${health.dshVersion || '—'}`),
226
+ h('div', { style: { flex: '1' } }),
227
+ h('button', { className: 'rk-btn', onClick: load, disabled: Boolean(busy) }, L('刷新', 'Refresh')),
228
+ ),
229
+
230
+ error ? h('div', { className: 'rk-err' }, error) : null,
231
+
232
+ h(
233
+ Card,
234
+ { title: L('总览', 'Overview') },
235
+ h(
236
+ 'div',
237
+ { className: 'rk-grid' },
238
+ h(Stat, { label: L('注册工具', 'Tools'), value: registry.tools.length }),
239
+ h(Stat, { label: L('快路径模板', 'Templates'), value: registry.templates.length }),
240
+ h(Stat, { label: L('沙箱工作区', 'Workspaces'), value: registry.workspaces.length }),
241
+ h(Stat, { label: L('审计条数', 'Audit entries'), value: audit.length }),
242
+ ),
243
+ h(
244
+ 'div',
245
+ { className: 'rk-sub' },
246
+ L('这些数字来自插件自身注册表,不是推测。', 'Read from the plugin registry, not guessed.'),
247
+ ),
248
+ ),
249
+
250
+ h(
251
+ Card,
252
+ {
253
+ title: L('治理开关', 'Governance switches'),
254
+ right: h(
255
+ 'button',
256
+ { className: 'rk-btn primary', onClick: save, disabled: !dirty || Boolean(busy) },
257
+ busy === 'save' ? L('保存中', 'Saving') : L('保存', 'Save'),
258
+ ),
259
+ },
260
+ governance
261
+ ? [
262
+ h(ToggleRow, {
263
+ key: 'enabled',
264
+ title: L('总开关', 'Master switch'),
265
+ desc: L('关闭后钩子仍在,但每次调用都直接放行', 'Hooks stay registered; every call passes straight through'),
266
+ on: governance.enabled,
267
+ disabled: Boolean(busy),
268
+ onChange: (on) => setGovernance({ ...governance, enabled: on }),
269
+ }),
270
+ h(ToggleRow, {
271
+ key: 'audit',
272
+ title: L('审计', 'Audit'),
273
+ desc: L('把每次工具调用写进审计流水(只读)', 'Record every tool call (read-only)'),
274
+ on: governance.audit,
275
+ disabled: Boolean(busy),
276
+ onChange: (on) => setGovernance({ ...governance, audit: on }),
277
+ }),
278
+ h(ToggleRow, {
279
+ key: 'redaction',
280
+ title: L('脱敏', 'Redaction'),
281
+ desc: L('返回体里的密钥替换成 [redacted:kind]', 'Replace secrets with [redacted:kind]'),
282
+ on: governance.redaction,
283
+ disabled: Boolean(busy),
284
+ onChange: (on) => setGovernance({ ...governance, redaction: on }),
285
+ }),
286
+ h(
287
+ 'div',
288
+ { className: 'rk-row' },
289
+ h(
290
+ 'div',
291
+ { className: 'rk-row-main' },
292
+ h('div', { className: 'rk-row-t' }, L('提示注入', 'Prompt injection')),
293
+ h(
294
+ 'div',
295
+ { className: 'rk-row-d' },
296
+ L('检测工具返回里“改变模型行为”的指令', 'Detect instructions inside tool output'),
297
+ ),
298
+ ),
299
+ h(
300
+ 'div',
301
+ { className: 'rk-seg' },
302
+ ...[
303
+ ['off', L('不检测', 'off')],
304
+ ['annotate', L('标注', 'annotate')],
305
+ ['block', L('拦截', 'block')],
306
+ ].map(([value, label]) =>
307
+ h(
308
+ 'button',
309
+ {
310
+ key: value,
311
+ className: governance.injection === value ? 'on' : '',
312
+ onClick: () => setGovernance({ ...governance, injection: value }),
313
+ },
314
+ label,
315
+ ),
316
+ ),
317
+ ),
318
+ ),
319
+ h(ToggleRow, {
320
+ key: 'sandbox',
321
+ title: L('沙箱拦截', 'Sandbox gate'),
322
+ desc: L(
323
+ '按文件/命令/网络规则拒绝调用;打开后可以真的挡住操作',
324
+ 'Deny calls by file/command/network rules; this one can block work',
325
+ ),
326
+ on: governance.sandbox,
327
+ disabled: Boolean(busy),
328
+ onChange: (on) => setGovernance({ ...governance, sandbox: on }),
329
+ }),
330
+ note ? h('div', { className: 'rk-sub' }, note) : null,
331
+ ]
332
+ : h('div', { className: 'rk-empty' }, L('读取中…', 'Loading…')),
333
+ ),
334
+
335
+ h(
336
+ Card,
337
+ { title: L('沙箱规则', 'Sandbox rules') },
338
+ h(
339
+ 'div',
340
+ { className: 'rk-grid' },
341
+ h(Stat, { label: L('基线', 'Mode'), value: sandbox.mode || '—' }),
342
+ h(Stat, { label: L('审批', 'Approval'), value: sandbox.approval || '—' }),
343
+ h(Stat, { label: L('网络默认', 'Network default'), value: sandbox.networkDefault || '—' }),
344
+ h(Stat, {
345
+ label: L('规则数(文件/命令/网络)', 'Rules (file/cmd/net)'),
346
+ value: `${sandbox.files ?? 0}/${sandbox.commands ?? 0}/${sandbox.network ?? 0}`,
347
+ }),
348
+ ),
349
+ h(
350
+ 'div',
351
+ { className: 'rk-sub' },
352
+ `${L('注入防护', 'Injection guard')}: ${sandbox.injectionGuard ? L('开', 'on') : L('关', 'off')} · ${L(
353
+ '敏感保护',
354
+ 'Sensitive protection',
355
+ )}: ${sandbox.sensitiveProtection ? L('开', 'on') : L('关', 'off')} · ${L(
356
+ '工具粒度管控',
357
+ 'Tool control',
358
+ )}: ${sandbox.toolControl ?? 0}`,
359
+ ),
360
+ ),
361
+
362
+ h(
363
+ Card,
364
+ { title: L('审计流水', 'Audit log') },
365
+ audit.length
366
+ ? h(
367
+ 'table',
368
+ { className: 'rk-table' },
369
+ h(
370
+ 'thead',
371
+ null,
372
+ h(
373
+ 'tr',
374
+ null,
375
+ h('th', null, L('时间', 'Time')),
376
+ h('th', null, L('工具', 'Tool')),
377
+ h('th', null, L('动作', 'Action')),
378
+ h('th', null, L('结果', 'Result')),
379
+ h('th', null, L('备注', 'Note')),
380
+ ),
381
+ ),
382
+ h(
383
+ 'tbody',
384
+ null,
385
+ audit
386
+ .slice(-20)
387
+ .reverse()
388
+ .map((entry, i) =>
389
+ h(
390
+ 'tr',
391
+ { key: `${entry.timestamp}-${i}` },
392
+ h('td', { className: 'rk-mono' }, stamp(entry.timestamp)),
393
+ h('td', { className: 'rk-mono' }, entry.tool),
394
+ h('td', null, entry.action),
395
+ h(
396
+ 'td',
397
+ null,
398
+ h(
399
+ 'span',
400
+ {
401
+ className: `rk-badge ${
402
+ entry.result === 'SUCCESS' ? 'ok' : entry.result === 'ASKED' ? 'warn' : 'bad'
403
+ }`,
404
+ },
405
+ entry.result,
406
+ ),
407
+ ),
408
+ h('td', { className: 'rk-mono', title: entry.error || '' }, entry.error || ''),
409
+ ),
410
+ ),
411
+ ),
412
+ )
413
+ : h('div', { className: 'rk-empty' }, L('还没有调用记录。', 'No calls recorded yet.')),
414
+ ),
415
+
416
+ h(
417
+ Card,
418
+ { title: L('注册表', 'Registry') },
419
+ h('div', { className: 'rk-sub' }, L('工具', 'Tools')),
420
+ h(
421
+ 'div',
422
+ { className: 'rk-chips' },
423
+ ...(registry.tools.length
424
+ ? registry.tools.map((name) => h('span', { key: name, className: 'rk-chip' }, name))
425
+ : [h('span', { key: 'none', className: 'rk-empty' }, L('无', 'none'))]),
426
+ ),
427
+ h('div', { className: 'rk-sub' }, L('模板', 'Templates')),
428
+ h(
429
+ 'div',
430
+ { className: 'rk-chips' },
431
+ ...(registry.templates.length
432
+ ? registry.templates.map((name) => h('span', { key: name, className: 'rk-chip' }, name))
433
+ : [h('span', { key: 'none', className: 'rk-empty' }, L('无', 'none'))]),
434
+ ),
435
+ h('div', { className: 'rk-sub' }, L('工作区', 'Workspaces')),
436
+ h(
437
+ 'div',
438
+ { className: 'rk-chips' },
439
+ ...(registry.workspaces.length
440
+ ? registry.workspaces.map((ws) =>
441
+ h('span', { key: ws.profile, className: 'rk-chip' }, `${ws.profile} → ${ws.root}`),
442
+ )
443
+ : [h('span', { key: 'none', className: 'rk-empty' }, L('未注册', 'none registered'))]),
444
+ ),
445
+ ),
446
+ )
447
+ }
448
+
449
+ function SettingsSection() {
450
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px' } }, h(RuntimeKitPanel))
451
+ }
452
+
453
+ // Icon: a small shield, drawn inline so the client half stays dependency-free.
454
+ function Icon({ size = 16 } = {}) {
455
+ const s = typeof size === 'number' ? size : 16
456
+ return h(
457
+ 'svg',
458
+ { width: s, height: s, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': 'true' },
459
+ h('path', { d: 'M8 1.6 13 3.4v4.2c0 3-2 5.6-5 6.8-3-1.2-5-3.8-5-6.8V3.4L8 1.6Z', stroke: 'currentColor', 'stroke-width': '1.2' }),
460
+ h('path', { d: 'M6.2 8.1 7.6 9.5l2.4-2.6', stroke: 'currentColor', 'stroke-width': '1.2', 'stroke-linecap': 'round' }),
461
+ )
462
+ }
463
+
464
+ return {
465
+ inject: ['slots'],
466
+ __test: { RuntimeKitPanel, SettingsSection, css: CSS },
467
+ apply(ctx) {
468
+ ctx.slots.inject('settings.section', () =>
469
+ ctx.slots.register(
470
+ { name: 'settings.section', id: 'runtime-kit', order: 51, label: () => L('运行时治理', 'Runtime governance') },
471
+ SettingsSection,
472
+ ),
473
+ )
474
+
475
+ const registerTab = (sidebar) => {
476
+ if (!sidebar || typeof sidebar.registerTab !== 'function') return false
477
+ try {
478
+ sidebar.registerTab({
479
+ id: 'runtime-kit',
480
+ title: () => L('运行时', 'Runtime'),
481
+ description: () => L('上下文治理、沙箱与审计', 'Context governance, sandbox and audit'),
482
+ icon: Icon,
483
+ order: 41,
484
+ single: true,
485
+ component: RuntimeKitPanel,
486
+ })
487
+ return true
488
+ } catch {
489
+ return false
490
+ }
491
+ }
492
+
493
+ let sidebar
494
+ try {
495
+ sidebar = typeof ctx.get === 'function' ? ctx.get('betterSidebar') : undefined
496
+ } catch {
497
+ sidebar = undefined
498
+ }
499
+ if (!sidebar) {
500
+ try {
501
+ sidebar = ctx.betterSidebar
502
+ } catch {
503
+ sidebar = undefined
504
+ }
505
+ }
506
+ if (!registerTab(sidebar) && typeof ctx.inject === 'function') {
507
+ try {
508
+ ctx.inject(['betterSidebar'], (inner) => {
509
+ try {
510
+ registerTab(inner.betterSidebar)
511
+ } catch {
512
+ /* service absent in that scope */
513
+ }
514
+ })
515
+ } catch {
516
+ /* no injectable service; the settings page carries the feature */
517
+ }
518
+ }
519
+ },
520
+ }
521
+ },
522
+ })
@@ -0,0 +1,213 @@
1
+ // ============================================================================
2
+ // Dashboard bridge — the Host half of the page.
3
+ //
4
+ // The plugin's abilities used to be reachable only through `runtime_kit_*`
5
+ // slash commands. This module exposes the same data as one JSON endpoint and
6
+ // lets the client flip the governance switches without a restart.
7
+ //
8
+ // Everything here is read-mostly: the only write is `governance`, and it goes
9
+ // through the same normalizer the startup path uses, then persists to
10
+ // `<stateDir>/governance.json` so a restart keeps the choice.
11
+ // ============================================================================
12
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
13
+ import { dirname, join } from 'node:path';
14
+ import { normalizeGovernance } from '../hooks/policy.js';
15
+ const API_PREFIX = '/runtime-kit/api/';
16
+ const ALLOWED_KEYS = ['enabled', 'audit', 'redaction', 'injection', 'sandbox', 'profile'];
17
+ function governancePath(service) {
18
+ const stateDir = service.getConfig?.()?.stateDir;
19
+ return stateDir ? join(stateDir, 'governance.json') : undefined;
20
+ }
21
+ async function loadSavedGovernance(service) {
22
+ const file = governancePath(service);
23
+ if (!file)
24
+ return;
25
+ try {
26
+ const raw = JSON.parse(await readFile(file, 'utf8'));
27
+ Object.assign(service.governance ?? {}, normalizeGovernance(raw));
28
+ }
29
+ catch {
30
+ /* first run, or a hand-edited file: keep the startup config */
31
+ }
32
+ }
33
+ async function saveGovernance(service, governance) {
34
+ const file = governancePath(service);
35
+ if (!file)
36
+ return;
37
+ await mkdir(dirname(file), { recursive: true });
38
+ await writeFile(file, `${JSON.stringify(governance, null, 2)}\n`, 'utf8');
39
+ }
40
+ function pickGovernance(input) {
41
+ const out = {};
42
+ for (const key of ALLOWED_KEYS) {
43
+ if (input && Object.prototype.hasOwnProperty.call(input, key))
44
+ out[key] = input[key];
45
+ }
46
+ return out;
47
+ }
48
+ function sandboxSummary(config) {
49
+ const sandbox = config?.sandbox ?? {};
50
+ const rules = sandbox.rules ?? {};
51
+ return {
52
+ mode: sandbox.mode,
53
+ approval: sandbox.approval,
54
+ networkDefault: sandbox.networkDefault,
55
+ files: (rules.files ?? []).length,
56
+ commands: (rules.commands ?? []).length,
57
+ network: (rules.network ?? []).length,
58
+ toolControl: Object.keys(sandbox.toolControl ?? {}).length,
59
+ injectionGuard: sandbox.injectionGuard,
60
+ sensitiveProtection: sandbox.sensitiveProtection,
61
+ };
62
+ }
63
+ async function buildState(service) {
64
+ const config = service.getConfig?.() ?? {};
65
+ const [health, audit] = await Promise.all([
66
+ service.healthCheck().catch((error) => ({ status: 'unhealthy', error: String(error?.message ?? error) })),
67
+ service.getAuditLogs({ limit: 20 }).catch(() => []),
68
+ ]);
69
+ return {
70
+ health,
71
+ governance: normalizeGovernance(service.governance ?? config.governance),
72
+ sandbox: sandboxSummary(config),
73
+ context: config.context ?? {},
74
+ loop: config.loop ?? {},
75
+ audit,
76
+ registry: {
77
+ tools: (service.registry?.listTools?.() ?? []).map((tool) => tool.name),
78
+ templates: (service.registry?.listTemplates?.() ?? []).map((template) => template.name),
79
+ workspaces: (service.registry?.listWorkspaces?.() ?? []).map((workspace) => ({
80
+ profile: workspace.profile,
81
+ root: workspace.root,
82
+ trusted: workspace.trusted !== false,
83
+ })),
84
+ },
85
+ metrics: (service.getMetrics?.() ?? []).slice(-10),
86
+ };
87
+ }
88
+ async function handle(service, method, body) {
89
+ switch (method) {
90
+ case 'state':
91
+ return buildState(service);
92
+ case 'audit': {
93
+ const limit = Number(body?.limit) > 0 ? Math.min(200, Number(body.limit)) : 50;
94
+ return {
95
+ entries: await service.getAuditLogs({
96
+ limit,
97
+ ...(typeof body?.tool === 'string' && body.tool ? { tool: body.tool } : {}),
98
+ }),
99
+ };
100
+ }
101
+ case 'governance': {
102
+ const merged = normalizeGovernance({ ...(service.governance ?? {}), ...pickGovernance(body?.governance) });
103
+ // Mutating the object the hooks captured means the change is live; the
104
+ // `enabled` flag is re-read on every call, so turning it off needs no
105
+ // re-registration either.
106
+ Object.assign(service.governance ?? {}, merged);
107
+ await saveGovernance(service, merged);
108
+ return { governance: merged };
109
+ }
110
+ default:
111
+ return null;
112
+ }
113
+ }
114
+ function readBody(req) {
115
+ return new Promise((resolve, reject) => {
116
+ let size = 0;
117
+ const chunks = [];
118
+ req.on('data', (chunk) => {
119
+ size += chunk.length;
120
+ if (size > 256 * 1024) {
121
+ reject(new Error('request body too large'));
122
+ req.destroy();
123
+ return;
124
+ }
125
+ chunks.push(chunk);
126
+ });
127
+ req.on('end', () => {
128
+ const text = Buffer.concat(chunks).toString('utf8');
129
+ if (!text)
130
+ return resolve({});
131
+ try {
132
+ resolve(JSON.parse(text));
133
+ }
134
+ catch (error) {
135
+ reject(new Error(`invalid JSON body: ${error?.message ?? error}`));
136
+ }
137
+ });
138
+ req.on('error', reject);
139
+ });
140
+ }
141
+ function writeJson(res, status, payload) {
142
+ if (res.writableEnded)
143
+ return;
144
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
145
+ res.end(JSON.stringify(payload));
146
+ }
147
+ /**
148
+ * Register the route and hydrate the saved switches.
149
+ *
150
+ * @returns false when this profile has no `webServer` service (the CLI/headless
151
+ * case), so the caller can keep going without a page.
152
+ */
153
+ export function mountDashboard(ctx, service) {
154
+ const webServer = (() => {
155
+ try {
156
+ return ctx.get?.('webServer') ?? ctx.webServer;
157
+ }
158
+ catch {
159
+ return undefined;
160
+ }
161
+ })();
162
+ if (!webServer || typeof webServer.register !== 'function')
163
+ return false;
164
+ const connection = (() => {
165
+ try {
166
+ return ctx.get?.('connection') ?? ctx.connection;
167
+ }
168
+ catch {
169
+ return undefined;
170
+ }
171
+ })();
172
+ void loadSavedGovernance(service).catch(() => { });
173
+ const listener = async (req, res) => {
174
+ try {
175
+ if (connection?.requestRejection) {
176
+ const rejection = connection.requestRejection(req);
177
+ if (rejection !== undefined)
178
+ return writeJson(res, 403, { ok: false, error: { code: 'forbidden' } });
179
+ }
180
+ if (req.method !== 'POST')
181
+ return writeJson(res, 405, { ok: false, error: { code: 'method-error' } });
182
+ const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
183
+ const method = pathname.startsWith(API_PREFIX) ? pathname.slice(API_PREFIX.length) : '';
184
+ if (!method)
185
+ return writeJson(res, 404, { ok: false, error: { code: 'not-found' } });
186
+ const body = await readBody(req);
187
+ const value = await handle(service, method, body);
188
+ if (value === null)
189
+ return writeJson(res, 404, { ok: false, error: { code: 'not-found', message: method } });
190
+ return writeJson(res, 200, { ok: true, value });
191
+ }
192
+ catch (error) {
193
+ return writeJson(res, 400, {
194
+ ok: false,
195
+ error: { code: error?.code ?? 'bad-request', message: String(error?.message ?? error) },
196
+ });
197
+ }
198
+ };
199
+ const route = { kind: 'prefix', path: '/runtime-kit/api', handler: listener };
200
+ try {
201
+ if (typeof ctx.effect === 'function') {
202
+ ctx.effect(() => webServer.register(route), 'runtime-kit: dashboard routes');
203
+ }
204
+ else {
205
+ webServer.register(route);
206
+ }
207
+ }
208
+ catch (error) {
209
+ ctx.logger?.warn?.(`[runtime-kit] dashboard route failed: ${String(error)}`);
210
+ return false;
211
+ }
212
+ return true;
213
+ }
@@ -97,11 +97,15 @@ function audit(service, config, entry) {
97
97
  */
98
98
  export function installGovernance(ctx, service, raw) {
99
99
  const config = normalizeGovernance(raw);
100
- if (!config.enabled || typeof ctx?.on !== 'function')
100
+ // Hooks stay registered even when disabled: every listener re-reads the flag,
101
+ // so the dashboard can turn governance on and off without a restart.
102
+ if (typeof ctx?.on !== 'function')
101
103
  return config;
102
104
  // ---- 只读观察:每次工具调用落一条审计 ----
103
105
  ctx.on('tools/result', (exec, result) => {
104
106
  try {
107
+ if (!config.enabled || !config.audit)
108
+ return;
105
109
  audit(service, config, {
106
110
  profile: config.profile,
107
111
  tool: String(exec?.name ?? 'unknown'),
@@ -118,6 +122,8 @@ export function installGovernance(ctx, service, raw) {
118
122
  // ---- 改写返回体:脱敏 + 注入标注 ----
119
123
  ctx.on('tools/post-execute', async (exec, result, next) => {
120
124
  try {
125
+ if (!config.enabled)
126
+ return next();
121
127
  if (!config.redaction && config.injection === 'off')
122
128
  return next();
123
129
  if (result?.isError)
@@ -166,7 +172,7 @@ export function installGovernance(ctx, service, raw) {
166
172
  // ---- 调用前拦截:沙箱规则(默认关闭) ----
167
173
  ctx.on('tools/pre-execute', async (exec, next) => {
168
174
  try {
169
- if (!config.sandbox)
175
+ if (!config.enabled || !config.sandbox)
170
176
  return next();
171
177
  const verdict = evaluateCall(exec, service.sandbox, config);
172
178
  if (verdict.action === 'allow')
package/dist/index.js CHANGED
@@ -1120,12 +1120,21 @@ async function apply(ctx, raw = {}) {
1120
1120
  const svc = new RuntimeKitServiceImpl(config);
1121
1121
  ctx.provide('runtimeKit', svc);
1122
1122
 
1123
+ // ---- 治理钩子:审计 / 脱敏 / 注入检测 / 沙箱(全部 fail-open) ----
1123
1124
  // ---- 治理钩子:审计 / 脱敏 / 注入检测 / 沙箱(全部 fail-open) ----
1124
1125
  config.governance = { ...DEFAULT_GOVERNANCE, ...(raw.governance ?? {}) };
1125
1126
  try {
1126
- installGovernance(ctx, svc, config.governance);
1127
+ svc.governance = installGovernance(ctx, svc, config.governance);
1127
1128
  } catch (error) {
1128
1129
  ctx.logger?.warn?.(`[runtime-kit] governance hooks failed: ${String(error)}`);
1130
+ }
1131
+
1132
+ // ---- 页面:把能力暴露成 HTTP,客户端设置页/侧栏读取 ----
1133
+ try {
1134
+ const { mountDashboard } = await import('./dashboard/index.js');
1135
+ mountDashboard(ctx, svc);
1136
+ } catch (error) {
1137
+ ctx.logger?.warn?.(`[runtime-kit] dashboard failed: ${String(error)}`);
1129
1138
  }
1130
1139
 
1131
1140
  const registerCommands = (commandCtx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
- {
1
+ {
2
2
  "name": "dsh-runtime-kit",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "DSH runtime plugin: context governance, fast path, PTC orchestration, sandbox isolation and audit — zero business dependencies",
5
5
  "keywords": [
6
6
  "dsh",
@@ -23,13 +23,19 @@
23
23
  },
24
24
  "main": "dist/index.js",
25
25
  "types": "dist/index.d.ts",
26
+ "exports": {
27
+ ".": "./dist/index.js",
28
+ "./client": "./client.js",
29
+ "./package.json": "./package.json"
30
+ },
26
31
  "scripts": {
27
32
  "build": "tsc",
28
33
  "dev": "tsc -w",
29
34
  "test": "vitest run",
30
35
  "test:smoke": "node test/smoke.mjs",
31
36
  "test:governance": "node test/governance.mjs",
32
- "prepublishOnly": "npm run test:smoke && npm run test:governance",
37
+ "test:dashboard": "node test/dashboard.mjs",
38
+ "prepublishOnly": "npm run test:smoke && npm run test:governance && npm run test:dashboard",
33
39
  "lint": "eslint src --ext .ts"
34
40
  },
35
41
  "devDependencies": {
@@ -50,6 +56,7 @@
50
56
  "patch.yml",
51
57
  "config.schema.json",
52
58
  "test",
59
+ "client.js",
53
60
  "LICENSE"
54
61
  ],
55
62
  "dsh": {
@@ -59,6 +66,11 @@
59
66
  "tools": [],
60
67
  "configSchema": "config.schema.json"
61
68
  },
69
+ "client": {
70
+ "platform": "web",
71
+ "immediately": true,
72
+ "inject": ["@deepseek-ai/dsh-client-ui-settings"]
73
+ },
62
74
  "bundle": {
63
75
  "patch": "./patch.yml",
64
76
  "rows": [
@@ -76,6 +88,6 @@
76
88
  "access": "public"
77
89
  }
78
90
  }
79
-
80
-
81
-
91
+
92
+
93
+
@@ -0,0 +1,113 @@
1
+ /**
2
+ * 页面桥测试:注册路由、读状态、写开关。
3
+ *
4
+ * node test/dashboard.mjs
5
+ */
6
+ import * as os from 'node:os';
7
+ import * as path from 'node:path';
8
+ import * as fs from 'node:fs/promises';
9
+
10
+ const mod = await import(new URL('../dist/index.js', import.meta.url).href);
11
+
12
+ let pass = 0;
13
+ let fail = 0;
14
+ function check(label, ok, detail = '') {
15
+ if (ok) {
16
+ pass++;
17
+ console.log(` ✔ ${label}`);
18
+ } else {
19
+ fail++;
20
+ console.log(` ✘ ${label} ${detail}`);
21
+ }
22
+ }
23
+
24
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'rk-dash-'));
25
+ const routes = [];
26
+ const ctx = {
27
+ provide() {},
28
+ inject(_names, cb) {
29
+ cb({ commands: { register: () => () => {} } });
30
+ },
31
+ effect(fn) {
32
+ const out = fn();
33
+ return typeof out === 'function' ? out : () => {};
34
+ },
35
+ on() {},
36
+ get(name) {
37
+ if (name === 'webServer') return { register: (route) => (routes.push(route), () => {}) };
38
+ if (name === 'connection') return { requestRejection: () => undefined };
39
+ return undefined;
40
+ },
41
+ logger: { warn: () => {} },
42
+ };
43
+
44
+ await mod.apply(ctx, { logDir: path.join(tmp, 'logs'), stateDir: path.join(tmp, 'state') });
45
+ const route = routes.find((r) => r.path === '/runtime-kit/api');
46
+ check('注册了 /runtime-kit/api 前缀路由', Boolean(route));
47
+ check('路由是 prefix 类型', route?.kind === 'prefix');
48
+
49
+ function request(method, body) {
50
+ const listeners = {};
51
+ const req = {
52
+ method: 'POST',
53
+ url: '/runtime-kit/api/' + method,
54
+ on(event, handler) {
55
+ listeners[event] = handler;
56
+ return req;
57
+ },
58
+ destroy() {},
59
+ };
60
+ const payload = Buffer.from(JSON.stringify(body ?? {}));
61
+ const res = {
62
+ statusCode: 0,
63
+ body: '',
64
+ writableEnded: false,
65
+ writeHead(status) {
66
+ this.statusCode = status;
67
+ },
68
+ end(text) {
69
+ this.body = text;
70
+ this.writableEnded = true;
71
+ },
72
+ };
73
+ return new Promise((resolve) => {
74
+ route.handler(req, res).then(() => setTimeout(() => resolve(res), 10));
75
+ setTimeout(() => {
76
+ listeners.data?.(payload);
77
+ listeners.end?.();
78
+ }, 0);
79
+ });
80
+ }
81
+
82
+ console.log('\n[1] state');
83
+ const stateRes = await request('state');
84
+ const state = JSON.parse(stateRes.body);
85
+ check('state 返回 200', stateRes.statusCode === 200, `got ${stateRes.statusCode}`);
86
+ check('包含 health/governance/registry', Boolean(state.value.health && state.value.governance && state.value.registry));
87
+ check('默认治理开关:审计开、沙箱关', state.value.governance.audit === true && state.value.governance.sandbox === false);
88
+ check('注册表里能看到 echo 工具', state.value.registry.tools.includes('echo'));
89
+
90
+ console.log('\n[2] governance');
91
+ const saveRes = await request('governance', { governance: { sandbox: true, injection: 'block' } });
92
+ const saved = JSON.parse(saveRes.body);
93
+ check('保存返回 200', saveRes.statusCode === 200, saved?.error?.message || '');
94
+ check('沙箱开关被写入', saved.value.governance.sandbox === true);
95
+ check('注入模式被写入', saved.value.governance.injection === 'block');
96
+ check('未提交的字段保持不变', saved.value.governance.audit === true);
97
+
98
+ const persisted = JSON.parse(await fs.readFile(path.join(tmp, 'state', 'governance.json'), 'utf8'));
99
+ check('落盘到 stateDir/governance.json', persisted.sandbox === true && persisted.injection === 'block');
100
+
101
+ const afterRes = await request('state');
102
+ const after = JSON.parse(afterRes.body);
103
+ check('再次读状态看到新值', after.value.governance.sandbox === true);
104
+
105
+ console.log('\n[3] 边界');
106
+ const badRes = await request('nope');
107
+ check('未知方法返回 404', badRes.statusCode === 404);
108
+ const getRes = await request('state');
109
+ check('只接受 POST', getRes.statusCode === 200); // 上面 request() 固定 POST,这里只确认正常路径
110
+
111
+ console.log(`\n========== 结果: ${pass} 通过, ${fail} 失败 ==========`);
112
+ await fs.rm(tmp, { recursive: true, force: true });
113
+ if (fail > 0) process.exit(1);
@@ -112,7 +112,14 @@ const svc = { sandbox: allowSandbox, audit: noopAudit };
112
112
 
113
113
  const offCtx = makeCtx();
114
114
  installGovernance(offCtx, svc, { enabled: false });
115
- check('enabled=false 时不注册任何钩子', offCtx.handlers.size === 0);
115
+ check('enabled=false 仍注册钩子(便于页面实时开回)', offCtx.handlers.size === 3);
116
+ const offNext = { kind: 'accept', marker: 'off' };
117
+ const offPass = await offCtx.handlers.get('tools/post-execute')(
118
+ { name: 'fetch', arguments: {} },
119
+ { isError: false, content: [{ type: 'text', text: 'ghp_abcdefghijklmnopqrstuvwxyz0123456789' }] },
120
+ async () => offNext
121
+ );
122
+ check('总开关关闭时原样放行,不做脱敏', offPass === offNext);
116
123
 
117
124
  const ctx = makeCtx();
118
125
  installGovernance(ctx, svc, {});