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 +11 -0
- package/client.js +522 -0
- package/dist/dashboard/index.js +213 -0
- package/dist/hooks/index.js +8 -2
- package/dist/index.js +10 -1
- package/package.json +18 -6
- package/test/dashboard.mjs +113 -0
- package/test/governance.mjs +8 -1
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
|
+
}
|
package/dist/hooks/index.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
"
|
|
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);
|
package/test/governance.mjs
CHANGED
|
@@ -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
|
|
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, {});
|