@hyzyn/dsh-docker 0.6.4 → 0.7.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.en.md +31 -16
- package/README.md +31 -18
- package/client-src/current-session.js +52 -0
- package/client-src/docker.css +2180 -0
- package/client-src/index.js +6691 -0
- package/client-src/session-target.js +92 -0
- package/client.js +46 -24
- package/lib/docker.d.ts +62 -5
- package/lib/docker.js +287 -48
- package/lib/docker.js.map +1 -1
- package/lib/index.d.ts +19 -1
- package/lib/index.js +595 -80
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.d.ts +30 -11
- package/lib/ssh-exec.js +360 -143
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +4 -2
- package/scripts/build-client.mjs +33 -0
- package/scripts/client-smoke.mjs +1851 -0
- package/scripts/route-smoke.mjs +1091 -0
- package/scripts/smoke.mjs +874 -0
|
@@ -0,0 +1,1091 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @hyzyn/dsh-docker — 宿主端集成回归(假 ctx + 假 docker CLI,无需真 daemon)。
|
|
4
|
+
*
|
|
5
|
+
* 覆盖:
|
|
6
|
+
* - 插件挂载:settings 命名空间注册、agent 工具注册、HTTP 路由注册、能力公告
|
|
7
|
+
* - 路由端到端:/config(含凭证脱敏与未知键拒绝)、/targets、/probe、/containers、
|
|
8
|
+
* /inspect、/logs、/logs/stream(SSE 事件序列与参数校验)、/stats、/images、
|
|
9
|
+
* /action、/exec
|
|
10
|
+
* - 信任模型:默认只读 → /action 与 /exec 403,且对应 agent 工具不注册;
|
|
11
|
+
* 打开开关后(含 settings/updated 热更新路径)立刻解锁
|
|
12
|
+
* - 禁用热生效:enabled=false → 工具清空、公告撤下、数据路由 403,/config 保持
|
|
13
|
+
* 可读写(卡片渲染与重新启用的唯一入口);含 settings 已禁用时的挂载路径
|
|
14
|
+
* - 安全:非 loopback 请求 403;容器名/ID 注入尝试被白名单拒绝
|
|
15
|
+
*
|
|
16
|
+
* 用法:pnpm --filter @hyzyn/dsh-docker build && node scripts/route-smoke.mjs
|
|
17
|
+
*/
|
|
18
|
+
import { strict as assert } from 'node:assert'
|
|
19
|
+
import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs'
|
|
20
|
+
import { tmpdir } from 'node:os'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
|
|
23
|
+
const host = await import('../lib/index.js')
|
|
24
|
+
|
|
25
|
+
/* ------------------------------------------------------------------ *
|
|
26
|
+
* 假 docker CLI(本机目标的 dockerBin 指向它)
|
|
27
|
+
* ------------------------------------------------------------------ */
|
|
28
|
+
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-docker-smoke-'))
|
|
30
|
+
const fakeBin = join(dir, 'fake-docker')
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* SSH 目标一律指向 127.0.0.1:1(D25):连接被立即拒绝(ECONNREFUSED),
|
|
34
|
+
* 断言的是「错误路径要可用」而不是「某个内网 IP 不可达」。此前写死
|
|
35
|
+
* 10.0.0.5:2222 / 10.0.0.9 —— 每次运行都发起真实 SSH 握手、干等 20s readyTimeout
|
|
36
|
+
* (脚本号称离线,实测 2 分钟),在 10.0.0.5 真有主机的网段上还会假红。
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** 需关注用例:已退出且非零退出码(inspect 会补上 OOMKilled)。 */
|
|
40
|
+
const PS_BAD_LINE = JSON.stringify({
|
|
41
|
+
ID: 'bad0c0ffee1234',
|
|
42
|
+
Image: 'app/broken:latest',
|
|
43
|
+
Labels: '',
|
|
44
|
+
Names: 'broken-worker',
|
|
45
|
+
Ports: '',
|
|
46
|
+
State: 'exited',
|
|
47
|
+
Status: 'Exited (137) 4 minutes ago',
|
|
48
|
+
})
|
|
49
|
+
const PS_LINE = JSON.stringify({
|
|
50
|
+
ID: '9f2c1d4e5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
|
|
51
|
+
Image: 'nginx:1.27',
|
|
52
|
+
Labels: 'com.docker.compose.project=shop,com.docker.compose.service=web',
|
|
53
|
+
Names: 'shop-web-1',
|
|
54
|
+
Ports: '0.0.0.0:8080->80/tcp',
|
|
55
|
+
State: 'running',
|
|
56
|
+
Status: 'Up 8 days (healthy)',
|
|
57
|
+
})
|
|
58
|
+
const INSPECT_JSON = JSON.stringify([{
|
|
59
|
+
Id: '9f2c1d4e5a6b',
|
|
60
|
+
Name: '/shop-web-1',
|
|
61
|
+
Image: 'sha256:deadbeef',
|
|
62
|
+
RestartCount: 1,
|
|
63
|
+
State: { Status: 'running', ExitCode: 0, Pid: 123, Health: { Status: 'healthy', Log: [] } },
|
|
64
|
+
Config: { Image: 'nginx:1.27', Cmd: ['nginx'], Labels: {} },
|
|
65
|
+
HostConfig: { RestartPolicy: { Name: 'unless-stopped' } },
|
|
66
|
+
Mounts: [],
|
|
67
|
+
NetworkSettings: { Ports: { '80/tcp': [{ HostIp: '0.0.0.0', HostPort: '8080' }] }, Networks: {} },
|
|
68
|
+
}])
|
|
69
|
+
/** OOM 被杀容器的 inspect(ExitCode 137 + OOMKilled true)。 */
|
|
70
|
+
const INSPECT_BAD_JSON = JSON.stringify([{
|
|
71
|
+
Id: 'bad0c0ffee1234',
|
|
72
|
+
Name: '/broken-worker',
|
|
73
|
+
Image: 'sha256:cafe',
|
|
74
|
+
RestartCount: 7,
|
|
75
|
+
State: { Status: 'exited', ExitCode: 137, OOMKilled: true, StartedAt: '2026-09-09T10:00:00Z', FinishedAt: '2026-09-09T10:05:00Z' },
|
|
76
|
+
Config: { Image: 'app/broken:latest', Cmd: ['node'], Labels: {} },
|
|
77
|
+
HostConfig: {},
|
|
78
|
+
Mounts: [],
|
|
79
|
+
NetworkSettings: { Ports: {}, Networks: {} },
|
|
80
|
+
}])
|
|
81
|
+
const STATS_LINE = JSON.stringify({ ID: '9f2c1d4e5a6b', Name: 'shop-web-1', CPUPerc: '0.50%', MemUsage: '128MiB / 7.66GiB', MemPerc: '1.60%', NetIO: '1kB / 2kB', BlockIO: '0B / 0B', PIDs: '5' })
|
|
82
|
+
const IMAGES_LINE = JSON.stringify({ ID: 'sha256:aaaa1111bbbb2222', Repository: 'nginx', Tag: '1.27', Size: '142MB', CreatedSince: '2 weeks ago' })
|
|
83
|
+
const IMAGE_INSPECT_JSON = JSON.stringify([{
|
|
84
|
+
Id: 'sha256:aaaa1111bbbb2222cccc3333',
|
|
85
|
+
RepoTags: ['nginx:1.27'],
|
|
86
|
+
RepoDigests: ['nginx@sha256:deadbeef'],
|
|
87
|
+
Size: 142000000,
|
|
88
|
+
Created: '2026-08-20T09:00:00.000000000Z',
|
|
89
|
+
Architecture: 'amd64',
|
|
90
|
+
Os: 'linux',
|
|
91
|
+
Config: { Cmd: ['nginx'], Entrypoint: [], Labels: { maintainer: 'ops' } },
|
|
92
|
+
RootFS: { Type: 'layers', Layers: ['sha256:l1', 'sha256:l2'] },
|
|
93
|
+
}])
|
|
94
|
+
const IMAGE_HISTORY_LINE = JSON.stringify({ ID: 'sha256:l2', CreatedSince: '2 weeks ago', CreatedBy: '/bin/sh -c #(nop) CMD ["nginx"]', Size: '0B' })
|
|
95
|
+
const EVENT_START_LINE = JSON.stringify({ Action: 'start', Type: 'container', Actor: { ID: 'aaaaaaaaaaaaaaaa', Attributes: { name: 'shop-web-1', image: 'nginx:1.27', 'com.docker.compose.project': 'shop' } }, time: 1700000000 })
|
|
96
|
+
// 白名单外的噪音:服务端必须丢掉它,客户端才不会为每次 docker exec 刷一次列表
|
|
97
|
+
const EVENT_NOISE_LINE = JSON.stringify({ Action: 'exec_start', Type: 'container', Actor: { ID: 'cccc', Attributes: { name: 'shop-web-1' } }, time: 1700000001 })
|
|
98
|
+
const EVENT_DIE_LINE = JSON.stringify({ Action: 'die', Type: 'container', Actor: { ID: 'bbbb', Attributes: { name: 'shop-web-1', image: 'nginx:1.27', exitCode: '137' } }, time: 1700000005 })
|
|
99
|
+
const NETWORK_LS_LINE = JSON.stringify({ ID: '8d5028a7e8b1', Name: 'shop_default', Driver: 'bridge', Scope: 'local', IPv4: 'false', IPv6: 'false', Internal: 'false', Labels: '' })
|
|
100
|
+
const NETWORK_INSPECT_JSON = JSON.stringify([{
|
|
101
|
+
Id: '8d5028a7e8b1',
|
|
102
|
+
Name: 'shop_default',
|
|
103
|
+
Driver: 'bridge',
|
|
104
|
+
Scope: 'local',
|
|
105
|
+
Created: '2026-09-01T02:00:00Z',
|
|
106
|
+
Internal: false,
|
|
107
|
+
Attachable: false,
|
|
108
|
+
Ingress: false,
|
|
109
|
+
EnableIPv6: false,
|
|
110
|
+
IPAM: { Config: [{ Subnet: '172.20.0.0/16', Gateway: '172.20.0.1' }] },
|
|
111
|
+
Options: {},
|
|
112
|
+
Labels: { 'com.docker.compose.project': 'shop' },
|
|
113
|
+
Containers: { '9f2c1d4e5a6b': { Name: 'shop-web-1', IPv4Address: '172.20.0.3/16', IPv6Address: '', MacAddress: '02:42:ac:14:00:03' } },
|
|
114
|
+
}])
|
|
115
|
+
const VOLUME_LS_LINE = JSON.stringify({ Name: 'pgdata', Driver: 'local', Scope: 'local', Mountpoint: '/var/lib/docker/volumes/pgdata/_data' })
|
|
116
|
+
const VOLUME_INSPECT_JSON = JSON.stringify([{ Name: 'pgdata', Driver: 'local', Scope: 'local', Mountpoint: '/var/lib/docker/volumes/pgdata/_data', CreatedAt: '2026-09-01T02:00:00Z', Options: {}, Labels: { keep: 'true' } }])
|
|
117
|
+
|
|
118
|
+
writeFileSync(fakeBin, `#!/bin/sh
|
|
119
|
+
cmd="$1"
|
|
120
|
+
case "$cmd" in
|
|
121
|
+
version) echo "27.3.1" ;;
|
|
122
|
+
ps) printf '%s\\n' '${PS_LINE}'; printf '%s\\n' '${PS_BAD_LINE}' ;;
|
|
123
|
+
inspect) case "$*" in *bad0c0ffee*) printf '%s' '${INSPECT_BAD_JSON}' ;; *) printf '%s' '${INSPECT_JSON}' ;; esac ;;
|
|
124
|
+
logs) echo "stdout line 1"; echo "stderr line 1" >&2 ;;
|
|
125
|
+
stats) printf '%s\\n' '${STATS_LINE}' ;;
|
|
126
|
+
images) printf '%s\\n' '${IMAGES_LINE}' ;;
|
|
127
|
+
image)
|
|
128
|
+
case "$2" in
|
|
129
|
+
inspect) printf '%s' '${IMAGE_INSPECT_JSON}' ;;
|
|
130
|
+
rm) echo "Untagged: nginx:1.27"; echo "deleted: sha256:aaaa" ;;
|
|
131
|
+
prune) echo "Total reclaimed space: 1.2GB" ;;
|
|
132
|
+
*) echo "fake-docker: unsupported image $2" >&2; exit 1 ;;
|
|
133
|
+
esac
|
|
134
|
+
;;
|
|
135
|
+
history) printf '%s\\n' '${IMAGE_HISTORY_LINE}' ;;
|
|
136
|
+
pull) echo "Pulling from library/nginx"; echo "Status: Downloaded newer image for nginx:1.27" ;;
|
|
137
|
+
events) printf '%s\\n' '${EVENT_START_LINE}'; printf '%s\\n' '${EVENT_NOISE_LINE}'; printf '%s\\n' '${EVENT_DIE_LINE}' ;;
|
|
138
|
+
network)
|
|
139
|
+
case "$2" in
|
|
140
|
+
ls) printf '%s\\n' '${NETWORK_LS_LINE}' ;;
|
|
141
|
+
inspect) printf '%s' '${NETWORK_INSPECT_JSON}' ;;
|
|
142
|
+
rm) echo "shop_default" ;;
|
|
143
|
+
prune) echo "Deleted Networks:"; echo "shop_default" ;;
|
|
144
|
+
*) echo "fake-docker: unsupported network $2" >&2; exit 1 ;;
|
|
145
|
+
esac
|
|
146
|
+
;;
|
|
147
|
+
volume)
|
|
148
|
+
case "$2" in
|
|
149
|
+
ls) printf '%s\\n' '${VOLUME_LS_LINE}' ;;
|
|
150
|
+
inspect) printf '%s' '${VOLUME_INSPECT_JSON}' ;;
|
|
151
|
+
rm) echo "pgdata" ;;
|
|
152
|
+
prune) echo "Deleted Volumes:"; echo "pgdata" ;;
|
|
153
|
+
*) echo "fake-docker: unsupported volume $2" >&2; exit 1 ;;
|
|
154
|
+
esac
|
|
155
|
+
;;
|
|
156
|
+
start|stop|restart|rm) echo "shop-web-1" ;;
|
|
157
|
+
exec) echo "hi" ;;
|
|
158
|
+
*) echo "fake-docker: unsupported $cmd" >&2; exit 1 ;;
|
|
159
|
+
esac
|
|
160
|
+
`)
|
|
161
|
+
chmodSync(fakeBin, 0o755)
|
|
162
|
+
|
|
163
|
+
/* ------------------------------------------------------------------ *
|
|
164
|
+
* 假 cordis ctx
|
|
165
|
+
* ------------------------------------------------------------------ */
|
|
166
|
+
|
|
167
|
+
function makeCtx(config, options = {}) {
|
|
168
|
+
const state = {
|
|
169
|
+
tools: [],
|
|
170
|
+
routes: [],
|
|
171
|
+
prompts: [],
|
|
172
|
+
listeners: new Map(),
|
|
173
|
+
settingsStored: {},
|
|
174
|
+
ttyConfig: options.ttyConfig,
|
|
175
|
+
disposers: [],
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const scopeFor = (base) => ({
|
|
179
|
+
get: () => ({ ...base, ...state.settingsStored }),
|
|
180
|
+
update: async (patch) => {
|
|
181
|
+
Object.assign(state.settingsStored, patch)
|
|
182
|
+
// 真实实现会 emit settings/updated;这里同步触发,验证热更新路径
|
|
183
|
+
const listeners = state.listeners.get('settings/updated') ?? []
|
|
184
|
+
for (const listener of listeners) listener('docker', { ...base, ...state.settingsStored }, undefined, 'update')
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const makeChild = (names) => {
|
|
189
|
+
const child = {
|
|
190
|
+
logger: { info: () => {}, warn: () => {} },
|
|
191
|
+
effect: (callback, _name) => {
|
|
192
|
+
const dispose = callback()
|
|
193
|
+
state.disposers.push(typeof dispose === 'function' ? dispose : () => {})
|
|
194
|
+
return () => {}
|
|
195
|
+
},
|
|
196
|
+
inject: (childNames, cb) => {
|
|
197
|
+
const grand = makeChild(childNames)
|
|
198
|
+
cb(grand)
|
|
199
|
+
return () => {}
|
|
200
|
+
},
|
|
201
|
+
events: {
|
|
202
|
+
on: (name, listener) => {
|
|
203
|
+
const list = state.listeners.get(name) ?? []
|
|
204
|
+
list.push(listener)
|
|
205
|
+
state.listeners.set(name, list)
|
|
206
|
+
return () => {}
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
if (names.includes('tools')) {
|
|
211
|
+
child.tools = {
|
|
212
|
+
register: (definition) => {
|
|
213
|
+
state.tools.push(definition)
|
|
214
|
+
return () => {
|
|
215
|
+
const index = state.tools.indexOf(definition)
|
|
216
|
+
if (index >= 0) state.tools.splice(index, 1)
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (names.includes('webServer')) {
|
|
222
|
+
child.webServer = {
|
|
223
|
+
register: (route) => {
|
|
224
|
+
state.routes.push(route)
|
|
225
|
+
return () => {}
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (names.includes('settings')) {
|
|
230
|
+
child.settings = {
|
|
231
|
+
register: (ns, schema, options_) => scopeFor(options_?.base ?? {}),
|
|
232
|
+
get: (ns) => (ns === 'tty' ? state.ttyConfig : undefined),
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (names.includes('systemPrompt')) {
|
|
236
|
+
child.systemPrompt = {
|
|
237
|
+
section: (options_) => {
|
|
238
|
+
state.prompts.push(options_)
|
|
239
|
+
// 与 tools.register 对称:dispose 从列表摘除,禁用撤公告的路径才可断言
|
|
240
|
+
return () => {
|
|
241
|
+
const index = state.prompts.indexOf(options_)
|
|
242
|
+
if (index >= 0) state.prompts.splice(index, 1)
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return child
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const root = makeChild([])
|
|
251
|
+
root.inject = (names, cb) => {
|
|
252
|
+
cb(makeChild(names))
|
|
253
|
+
return () => {}
|
|
254
|
+
}
|
|
255
|
+
return { ctx: root, state }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* ------------------------------------------------------------------ *
|
|
259
|
+
* 假 HTTP 请求 / 响应
|
|
260
|
+
* ------------------------------------------------------------------ */
|
|
261
|
+
|
|
262
|
+
function makeReq(method, path, body, remoteAddress = '127.0.0.1') {
|
|
263
|
+
const payload = body === undefined ? '' : JSON.stringify(body)
|
|
264
|
+
const chunks = payload === '' ? [] : [Buffer.from(payload)]
|
|
265
|
+
return {
|
|
266
|
+
method,
|
|
267
|
+
url: path,
|
|
268
|
+
headers: { host: '127.0.0.1:3080', origin: 'http://127.0.0.1:3080', 'content-type': 'application/json' },
|
|
269
|
+
socket: { remoteAddress },
|
|
270
|
+
async *[Symbol.asyncIterator]() {
|
|
271
|
+
for (const chunk of chunks) yield chunk
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function makeRes() {
|
|
277
|
+
return {
|
|
278
|
+
status: 0,
|
|
279
|
+
headers: {},
|
|
280
|
+
body: null,
|
|
281
|
+
// SSE 长连接需要的最小面(write / flushHeaders / on('close')):正常的 JSON
|
|
282
|
+
// 路由仍走 writeHead + end,不受影响
|
|
283
|
+
frames: [],
|
|
284
|
+
flushed: false,
|
|
285
|
+
closeListeners: [],
|
|
286
|
+
ended: false,
|
|
287
|
+
writeHead(status, headers) {
|
|
288
|
+
this.status = status
|
|
289
|
+
this.headers = headers ?? {}
|
|
290
|
+
},
|
|
291
|
+
write(chunk) { this.frames.push(chunk) },
|
|
292
|
+
flushHeaders() { this.flushed = true },
|
|
293
|
+
on(event, listener) { if (event === 'close') this.closeListeners.push(listener) },
|
|
294
|
+
end(text) {
|
|
295
|
+
this.ended = true
|
|
296
|
+
try {
|
|
297
|
+
this.body = text === undefined ? null : JSON.parse(text)
|
|
298
|
+
} catch {
|
|
299
|
+
this.body = text
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const results = []
|
|
306
|
+
/*
|
|
307
|
+
* 看门狗(D71):任一用例挂起不该把整个脚本永久挂住(CI 会一直转到超时被杀)。
|
|
308
|
+
* 两层:单用例 15s(挂起的用例标失败,其余继续跑)+ 全局 90s(兜底,直接退出 1)。
|
|
309
|
+
*/
|
|
310
|
+
// 25s > ssh 建连超时 20s(D122):否则「目标不回 RST」的环境里,
|
|
311
|
+
// 用例会被看门狗报成「用例超时」,把真因盖住
|
|
312
|
+
const CASE_TIMEOUT_MS = 25_000
|
|
313
|
+
const WATCHDOG_MS = 90_000
|
|
314
|
+
const watchdog = setTimeout(() => {
|
|
315
|
+
const last = [...results].reverse().find((result) => result.ok)
|
|
316
|
+
console.error(`\n[dsh-docker] 看门狗超时(${String(WATCHDOG_MS / 1000)}s),脚本挂起;最后通过的用例:${last === undefined ? '(无)' : last.name}`)
|
|
317
|
+
process.exit(1)
|
|
318
|
+
}, WATCHDOG_MS)
|
|
319
|
+
async function test(name, fn) {
|
|
320
|
+
let timer = null
|
|
321
|
+
try {
|
|
322
|
+
await Promise.race([
|
|
323
|
+
Promise.resolve().then(fn),
|
|
324
|
+
new Promise((_, reject) => {
|
|
325
|
+
timer = setTimeout(() => reject(new Error(`用例超时(${String(CASE_TIMEOUT_MS / 1000)}s)`)), CASE_TIMEOUT_MS)
|
|
326
|
+
}),
|
|
327
|
+
])
|
|
328
|
+
results.push({ name, ok: true })
|
|
329
|
+
} catch (error) {
|
|
330
|
+
results.push({ name, ok: false, message: error instanceof Error ? error.message : String(error) })
|
|
331
|
+
} finally {
|
|
332
|
+
if (timer !== null) clearTimeout(timer)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/* ------------------------------------------------------------------ *
|
|
337
|
+
* 挂载插件
|
|
338
|
+
* ------------------------------------------------------------------ */
|
|
339
|
+
|
|
340
|
+
const TTY_CONFIG = {
|
|
341
|
+
sshHosts: [{ name: 'prod-a', host: '127.0.0.1', port: 1, username: 'root', auth: 'agent' }],
|
|
342
|
+
hostKeys: [{ host: '127.0.0.1', port: 1, fingerprint: 'seeded-fingerprint' }],
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const MOUNT_TARGETS = [
|
|
346
|
+
{ name: '本机', kind: 'local' },
|
|
347
|
+
{ name: '远程', kind: 'ssh', book: 'prod-a' },
|
|
348
|
+
// 故意放一个明文密码(env: 引用):验证 /config 不回显;端口 1 = 立即 ECONNREFUSED(D25)
|
|
349
|
+
{ name: '直连', kind: 'ssh', host: '127.0.0.1', port: 1, username: 'ops', auth: 'password', password: 'env:FAKE_PW' },
|
|
350
|
+
]
|
|
351
|
+
|
|
352
|
+
const { ctx, state } = makeCtx({ dockerBin: fakeBin, targets: MOUNT_TARGETS }, { ttyConfig: TTY_CONFIG })
|
|
353
|
+
|
|
354
|
+
host.apply({ ...ctx }, { dockerBin: fakeBin, targets: MOUNT_TARGETS })
|
|
355
|
+
|
|
356
|
+
const route = state.routes.find((item) => item.path === '/api/dsh-docker')
|
|
357
|
+
assert.ok(route !== undefined, '未注册 /api/dsh-docker 路由')
|
|
358
|
+
|
|
359
|
+
async function call(method, sub, body, remoteAddress) {
|
|
360
|
+
const res = makeRes()
|
|
361
|
+
await route.handler(makeReq(method, '/api/dsh-docker' + sub, body, remoteAddress), res)
|
|
362
|
+
return res
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function toolNames() {
|
|
366
|
+
return state.tools.map((item) => item.name)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/* ------------------------------------------------------------------ *
|
|
370
|
+
* 1. 挂载面
|
|
371
|
+
* ------------------------------------------------------------------ */
|
|
372
|
+
|
|
373
|
+
await test('挂载:注册 11 个只读 agent 工具', () => {
|
|
374
|
+
const names = toolNames().sort()
|
|
375
|
+
assert.deepEqual(names, ['docker_attention', 'docker_events', 'docker_image_inspect', 'docker_images', 'docker_inspect', 'docker_logs', 'docker_networks', 'docker_ps', 'docker_stats', 'docker_targets', 'docker_volumes'])
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
await test('挂载:注册能力公告 section', () => {
|
|
379
|
+
assert.equal(state.prompts.length, 1)
|
|
380
|
+
assert.equal(state.prompts[0].name, 'plugin:dsh-docker')
|
|
381
|
+
assert.match(state.prompts[0].text, /默认只读/)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
await test('挂载:路由为 prefix /api/dsh-docker', () => {
|
|
385
|
+
assert.equal(route.kind, 'prefix')
|
|
386
|
+
assert.equal(route.path, '/api/dsh-docker')
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
/* ------------------------------------------------------------------ *
|
|
390
|
+
* 2. 配置路由
|
|
391
|
+
* ------------------------------------------------------------------ */
|
|
392
|
+
|
|
393
|
+
await test('GET /config:凭证脱敏 + 只读默认 + 复用 tty 连接簿名', async () => {
|
|
394
|
+
const res = await call('GET', '/config')
|
|
395
|
+
assert.equal(res.status, 200)
|
|
396
|
+
const config = res.body.config
|
|
397
|
+
assert.equal(config.allowMutations, false)
|
|
398
|
+
assert.equal(config.allowExec, false)
|
|
399
|
+
assert.equal(config.dockerBin, fakeBin)
|
|
400
|
+
assert.deepEqual(config.ttyBooks, ['prod-a'])
|
|
401
|
+
assert.equal(config.ttyAvailable, true)
|
|
402
|
+
assert.equal(JSON.stringify(config).includes('FAKE_PW'), false)
|
|
403
|
+
for (const target of config.targets) {
|
|
404
|
+
assert.equal(Object.hasOwn(target, 'password'), false)
|
|
405
|
+
assert.equal(Object.hasOwn(target, 'passphrase'), false)
|
|
406
|
+
}
|
|
407
|
+
const direct = config.targets.find((item) => item.name === '直连')
|
|
408
|
+
assert.equal(direct.passwordSet, true)
|
|
409
|
+
assert.equal(direct.passphraseSet, false)
|
|
410
|
+
assert.deepEqual(config.toolsRegistered.sort(), ['docker_attention', 'docker_events', 'docker_image_inspect', 'docker_images', 'docker_inspect', 'docker_logs', 'docker_networks', 'docker_ps', 'docker_stats', 'docker_targets', 'docker_volumes'])
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
await test('POST /config:未知键被拒绝', async () => {
|
|
414
|
+
const res = await call('POST', '/config', { nope: 1 })
|
|
415
|
+
assert.equal(res.status, 400)
|
|
416
|
+
assert.match(res.body.error, /未知配置项/)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
await test('GET /targets:连接簿条目解析出 user@host', async () => {
|
|
420
|
+
const res = await call('GET', '/targets')
|
|
421
|
+
assert.equal(res.status, 200)
|
|
422
|
+
const rows = res.body.targets
|
|
423
|
+
assert.equal(rows.length, 3)
|
|
424
|
+
assert.equal(rows[0].label, '本机')
|
|
425
|
+
assert.equal(rows[1].label, 'root@127.0.0.1:1')
|
|
426
|
+
assert.equal(rows[2].label, 'ops@127.0.0.1:1')
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
await test('POST /targets:解析连接簿(与 GET 等价)', async () => {
|
|
430
|
+
const res = await call('POST', '/targets', {})
|
|
431
|
+
assert.equal(res.status, 200)
|
|
432
|
+
assert.equal(res.body.targets.length, 3)
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
/* ------------------------------------------------------------------ *
|
|
436
|
+
* 3. Docker 操作路由(假 CLI)
|
|
437
|
+
* ------------------------------------------------------------------ */
|
|
438
|
+
|
|
439
|
+
await test('POST /probe:返回服务端版本', async () => {
|
|
440
|
+
const res = await call('POST', '/probe', { target: '本机' })
|
|
441
|
+
assert.equal(res.status, 200)
|
|
442
|
+
assert.equal(res.body.probe.ok, true)
|
|
443
|
+
assert.equal(res.body.probe.serverVersion, '27.3.1')
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
await test('POST /containers:解析容器列表', async () => {
|
|
447
|
+
const res = await call('POST', '/containers', { target: '本机', all: true })
|
|
448
|
+
assert.equal(res.status, 200)
|
|
449
|
+
const rows = res.body.containers
|
|
450
|
+
assert.equal(rows.length, 2) // 正常容器 + OOM 容器
|
|
451
|
+
const web = rows.find((row) => row.name === 'shop-web-1')
|
|
452
|
+
assert.ok(web !== undefined)
|
|
453
|
+
assert.equal(rows[0].state, 'running')
|
|
454
|
+
assert.equal(rows[0].health, 'healthy')
|
|
455
|
+
assert.equal(rows[0].composeProject, 'shop')
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
await test('POST /inspect:解析详情', async () => {
|
|
459
|
+
const res = await call('POST', '/inspect', { target: '本机', id: 'shop-web-1' })
|
|
460
|
+
assert.equal(res.status, 200)
|
|
461
|
+
assert.equal(res.body.details[0].name, 'shop-web-1')
|
|
462
|
+
assert.equal(res.body.details[0].health, 'healthy')
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
await test('POST /logs:合并 stdout 与 stderr', async () => {
|
|
466
|
+
const res = await call('POST', '/logs', { target: '本机', id: 'shop-web-1', tail: 50 })
|
|
467
|
+
assert.equal(res.status, 200)
|
|
468
|
+
assert.match(res.body.logs.text, /stdout line 1/)
|
|
469
|
+
assert.match(res.body.logs.text, /stderr line 1/)
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
await test('GET /logs/stream:SSE 事件序列(line stdout/stderr → end)', async () => {
|
|
473
|
+
const res = await call('GET', '/logs/stream?target=本机&id=shop-web-1&tail=50')
|
|
474
|
+
assert.equal(res.status, 200)
|
|
475
|
+
assert.equal(res.headers['content-type'], 'text/event-stream; charset=utf-8')
|
|
476
|
+
assert.equal(res.headers['cache-control'], 'no-cache')
|
|
477
|
+
assert.equal(res.flushed, true)
|
|
478
|
+
const text = res.frames.join('')
|
|
479
|
+
assert.match(text, /event: line\ndata: \{"d":"stdout line 1\\n"\}\n\n/)
|
|
480
|
+
assert.match(text, /event: line\ndata: \{"e":"stderr line 1\\n"\}\n\n/)
|
|
481
|
+
assert.match(text, /event: end\ndata: \{"reason":"container-exit","code":0\}\n\n/)
|
|
482
|
+
assert.equal(res.ended, true)
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
await test('GET /logs/stream:缺 id 400 / 非法 id 400 / 非 GET 405 / 非 loopback 403', async () => {
|
|
486
|
+
const missing = await call('GET', '/logs/stream?target=本机')
|
|
487
|
+
assert.equal(missing.status, 400)
|
|
488
|
+
assert.match(missing.body.error, /id 必填/)
|
|
489
|
+
|
|
490
|
+
const badId = await call('GET', '/logs/stream?target=本机&id=' + encodeURIComponent('shop-web-1; rm -rf /'))
|
|
491
|
+
assert.equal(badId.status, 400)
|
|
492
|
+
assert.match(badId.body.error, /container 含非法字符/)
|
|
493
|
+
|
|
494
|
+
const wrongMethod = await call('POST', '/logs/stream?target=本机&id=shop-web-1')
|
|
495
|
+
assert.equal(wrongMethod.status, 405)
|
|
496
|
+
|
|
497
|
+
const remote = await call('GET', '/logs/stream?target=本机&id=shop-web-1', undefined, '10.0.0.9')
|
|
498
|
+
assert.equal(remote.status, 403)
|
|
499
|
+
assert.match(remote.body.error, /loopback-only/)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
await test('POST /stats:解析 CPU / 内存 / PIDs', async () => {
|
|
503
|
+
const res = await call('POST', '/stats', { target: '本机', ids: ['shop-web-1'] })
|
|
504
|
+
assert.equal(res.status, 200)
|
|
505
|
+
assert.equal(res.body.stats[0].cpuPercent, 0.5)
|
|
506
|
+
assert.equal(res.body.stats[0].pids, 5)
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
await test('POST /images:解析镜像', async () => {
|
|
510
|
+
const res = await call('POST', '/images', { target: '本机' })
|
|
511
|
+
assert.equal(res.status, 200)
|
|
512
|
+
assert.equal(res.body.images[0].reference, 'nginx:1.27')
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
await test('POST /images/inspect:镜像详情 + 构建历史', async () => {
|
|
516
|
+
const res = await call('POST', '/images/inspect', { target: '本机', ref: 'nginx:1.27' })
|
|
517
|
+
assert.equal(res.status, 200)
|
|
518
|
+
assert.equal(res.body.image.ref, 'nginx:1.27')
|
|
519
|
+
assert.equal(res.body.image.detail.layerCount, 2)
|
|
520
|
+
assert.equal(res.body.image.history.length, 1)
|
|
521
|
+
assert.match(res.body.image.history[0].createdBy, /CMD/)
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
await test('POST /images/inspect:缺 ref 400 / 非法 ref 400(不触达 docker;D37 前非法值会错报 500)', async () => {
|
|
525
|
+
const missing = await call('POST', '/images/inspect', { target: '本机' })
|
|
526
|
+
assert.equal(missing.status, 400)
|
|
527
|
+
assert.match(missing.body.error, /ref 必填/)
|
|
528
|
+
const bad = await call('POST', '/images/inspect', { target: '本机', ref: 'nginx; rm -rf /' })
|
|
529
|
+
assert.equal(bad.status, 400)
|
|
530
|
+
assert.match(bad.body.error, /image 含非法字符/)
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
await test('GET /stats/stream:SSE 事件序列(stats → end stats-exit)', async () => {
|
|
534
|
+
const res = await call('GET', '/stats/stream?target=本机&ids=shop-web-1')
|
|
535
|
+
assert.equal(res.status, 200)
|
|
536
|
+
assert.equal(res.headers['content-type'], 'text/event-stream; charset=utf-8')
|
|
537
|
+
assert.equal(res.headers['cache-control'], 'no-cache')
|
|
538
|
+
assert.equal(res.flushed, true)
|
|
539
|
+
const text = res.frames.join('')
|
|
540
|
+
assert.match(text, /event: stats\ndata: \{"id":"9f2c1d4e5a6b"/)
|
|
541
|
+
assert.match(text, /event: end\ndata: \{"reason":"stats-exit","code":0\}\n\n/)
|
|
542
|
+
assert.equal(res.ended, true)
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
await test('GET /stats/stream:非法 ids 400 / 非 GET 405 / 非 loopback 403', async () => {
|
|
546
|
+
const bad = await call('GET', '/stats/stream?target=本机&ids=' + encodeURIComponent('web; rm -rf /'))
|
|
547
|
+
assert.equal(bad.status, 400)
|
|
548
|
+
assert.match(bad.body.error, /container 含非法字符/)
|
|
549
|
+
const wrongMethod = await call('POST', '/stats/stream?target=本机&ids=web')
|
|
550
|
+
assert.equal(wrongMethod.status, 405)
|
|
551
|
+
const remote = await call('GET', '/stats/stream?target=本机&ids=web', undefined, '10.0.0.9')
|
|
552
|
+
assert.equal(remote.status, 403)
|
|
553
|
+
assert.match(remote.body.error, /loopback-only/)
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
await test('GET /events/stream:SSE 事件序列(白名单过滤噪音 → end events-exit)', async () => {
|
|
557
|
+
const res = await call('GET', '/events/stream?target=本机')
|
|
558
|
+
assert.equal(res.status, 200)
|
|
559
|
+
assert.equal(res.headers['content-type'], 'text/event-stream; charset=utf-8')
|
|
560
|
+
assert.equal(res.flushed, true)
|
|
561
|
+
const text = res.frames.join('')
|
|
562
|
+
// 白名单放行 start / die;exec_start 那条噪音必须被服务端丢掉
|
|
563
|
+
assert.match(text, /event: event\ndata: \{"action":"start","name":"shop-web-1","image":"nginx:1\.27","composeProject":"shop","time":1700000000\}/)
|
|
564
|
+
assert.match(text, /event: event\ndata: \{"action":"die","name":"shop-web-1","image":"nginx:1\.27","time":1700000005,"exitCode":137\}/)
|
|
565
|
+
assert.equal(text.includes('exec_start'), false, '白名单外的噪音不该出现在帧里')
|
|
566
|
+
assert.equal((text.match(/event: event/g) ?? []).length, 2)
|
|
567
|
+
assert.match(text, /event: end\ndata: \{"reason":"events-exit","code":0\}\n\n/)
|
|
568
|
+
assert.equal(res.ended, true)
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
await test('GET /events/stream:非 GET 405 / 未知目标 400 / 非 loopback 403(都不建流)', async () => {
|
|
572
|
+
const wrongMethod = await call('POST', '/events/stream?target=本机')
|
|
573
|
+
assert.equal(wrongMethod.status, 405)
|
|
574
|
+
const unknown = await call('GET', '/events/stream?target=nope')
|
|
575
|
+
assert.equal(unknown.status, 400)
|
|
576
|
+
assert.match(unknown.body.error, /未知目标/)
|
|
577
|
+
const remote = await call('GET', '/events/stream?target=本机', undefined, '10.0.0.9')
|
|
578
|
+
assert.equal(remote.status, 403)
|
|
579
|
+
assert.match(remote.body.error, /loopback-only/)
|
|
580
|
+
assert.equal(remote.flushed, false)
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
await test('agent 工具:docker_events 返回过滤后的事件快照', async () => {
|
|
584
|
+
const tool = state.tools.find((item) => item.name === 'docker_events')
|
|
585
|
+
assert.ok(tool !== undefined)
|
|
586
|
+
const value = await tool.execute({ target: '本机', since: '30m' })
|
|
587
|
+
assert.equal(value.target, '本机')
|
|
588
|
+
assert.equal(value.since, '30m')
|
|
589
|
+
// 快照同样过白名单:exec_start 被丢掉,剩 start / die
|
|
590
|
+
assert.deepEqual(value.events.map((row) => row.action), ['start', 'die'])
|
|
591
|
+
assert.equal(value.events[1].exitCode, 137)
|
|
592
|
+
// since 走字符集校验,注入尝试在触达 docker 之前就被拒;错误里要**回显原文**(D99)
|
|
593
|
+
await assert.rejects(() => tool.execute({ target: '本机', since: '10m; rm -rf /' }), /since 无法识别:10m; rm -rf \//)
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
/* ------------------------------------------------------------------ *
|
|
597
|
+
* 3.5 网络 / 卷(列表 / 详情 / 校验)
|
|
598
|
+
* ------------------------------------------------------------------ */
|
|
599
|
+
|
|
600
|
+
await test('POST /networks:解析网络列表(internal 字符串布尔归一)', async () => {
|
|
601
|
+
const res = await call('POST', '/networks', { target: '本机' })
|
|
602
|
+
assert.equal(res.status, 200)
|
|
603
|
+
assert.equal(res.body.networks.length, 1)
|
|
604
|
+
assert.equal(res.body.networks[0].name, 'shop_default')
|
|
605
|
+
assert.equal(res.body.networks[0].driver, 'bridge')
|
|
606
|
+
assert.equal(res.body.networks[0].internal, false)
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
await test('POST /networks/inspect:子网 / 接入的容器', async () => {
|
|
610
|
+
const res = await call('POST', '/networks/inspect', { target: '本机', name: 'shop_default' })
|
|
611
|
+
assert.equal(res.status, 200)
|
|
612
|
+
assert.equal(res.body.network.name, 'shop_default')
|
|
613
|
+
assert.deepEqual(res.body.network.detail.subnets, [{ subnet: '172.20.0.0/16', gateway: '172.20.0.1' }])
|
|
614
|
+
assert.equal(res.body.network.detail.containers[0].name, 'shop-web-1')
|
|
615
|
+
})
|
|
616
|
+
|
|
617
|
+
await test('POST /volumes:解析卷列表(含挂载点)', async () => {
|
|
618
|
+
const res = await call('POST', '/volumes', { target: '本机' })
|
|
619
|
+
assert.equal(res.status, 200)
|
|
620
|
+
assert.equal(res.body.volumes[0].name, 'pgdata')
|
|
621
|
+
assert.equal(res.body.volumes[0].mountpoint, '/var/lib/docker/volumes/pgdata/_data')
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
await test('POST /volumes/inspect:标签与选项', async () => {
|
|
625
|
+
const res = await call('POST', '/volumes/inspect', { target: '本机', name: 'pgdata' })
|
|
626
|
+
assert.equal(res.status, 200)
|
|
627
|
+
assert.equal(res.body.volume.detail.mountpoint, '/var/lib/docker/volumes/pgdata/_data')
|
|
628
|
+
assert.equal(res.body.volume.detail.labels.keep, 'true')
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
await test('网络 / 卷:缺 name 400,非法名 400(/ 与 : 都不允许;D37 前非法值会错报 500)', async () => {
|
|
632
|
+
// 只读的 inspect 走这里;remove 的门控在 name 校验之前(未开门时先 403,与镜像一致),
|
|
633
|
+
// 它的 name 校验放在第 5 节(开门之后)
|
|
634
|
+
for (const sub of ['/networks/inspect', '/volumes/inspect']) {
|
|
635
|
+
const missing = await call('POST', sub, { target: '本机' })
|
|
636
|
+
assert.equal(missing.status, 400, sub)
|
|
637
|
+
assert.match(missing.body.error, /name 必填/)
|
|
638
|
+
}
|
|
639
|
+
const slash = await call('POST', '/networks/inspect', { target: '本机', name: 'a/b' })
|
|
640
|
+
assert.equal(slash.status, 400)
|
|
641
|
+
assert.match(slash.body.error, /含非法字符/)
|
|
642
|
+
const colon = await call('POST', '/volumes/inspect', { target: '本机', name: 'a:b' })
|
|
643
|
+
assert.equal(colon.status, 400)
|
|
644
|
+
assert.match(colon.body.error, /含非法字符/)
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
/* ------------------------------------------------------------------ *
|
|
648
|
+
* 4. 信任模型:默认只读
|
|
649
|
+
* ------------------------------------------------------------------ */
|
|
650
|
+
|
|
651
|
+
await test('POST /action:未开启变更时 403', async () => {
|
|
652
|
+
const res = await call('POST', '/action', { target: '本机', action: 'stop', id: 'shop-web-1' })
|
|
653
|
+
assert.equal(res.status, 403)
|
|
654
|
+
assert.match(res.body.error, /变更操作未启用/)
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
await test('网络 / 卷变更:未开启变更操作时 remove / prune 四个端点全 403', async () => {
|
|
658
|
+
for (const sub of ['/networks/remove', '/networks/prune', '/volumes/remove', '/volumes/prune']) {
|
|
659
|
+
const res = await call('POST', sub, { target: '本机', name: 'shop_default' })
|
|
660
|
+
assert.equal(res.status, 403, sub)
|
|
661
|
+
assert.match(res.body.error, /变更操作未启用/)
|
|
662
|
+
}
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
await test('POST /exec:未开启 exec 时 403', async () => {
|
|
666
|
+
const res = await call('POST', '/exec', { target: '本机', id: 'shop-web-1', command: 'ls' })
|
|
667
|
+
assert.equal(res.status, 403)
|
|
668
|
+
assert.match(res.body.error, /exec 未启用/)
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
await test('镜像变更:未开启变更操作时 /images/remove、/images/prune、pull 流全 403', async () => {
|
|
672
|
+
const remove = await call('POST', '/images/remove', { target: '本机', ref: 'nginx:1.27' })
|
|
673
|
+
assert.equal(remove.status, 403)
|
|
674
|
+
assert.match(remove.body.error, /变更操作未启用/)
|
|
675
|
+
const prune = await call('POST', '/images/prune', { target: '本机' })
|
|
676
|
+
assert.equal(prune.status, 403)
|
|
677
|
+
const pull = await call('GET', '/images/pull/stream?target=本机&ref=nginx:1.27')
|
|
678
|
+
assert.equal(pull.status, 403)
|
|
679
|
+
assert.match(pull.body.error, /变更操作未启用/)
|
|
680
|
+
// 403 时不得建流(没有 SSE 响应头)
|
|
681
|
+
assert.equal(pull.flushed, false)
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
await test('POST /containers:未知目标 400 并列出已配置目标', async () => {
|
|
685
|
+
const res = await call('POST', '/containers', { target: '不存在' })
|
|
686
|
+
assert.equal(res.status, 400)
|
|
687
|
+
assert.match(res.body.error, /未知目标:不存在/)
|
|
688
|
+
assert.match(res.body.error, /已配置:本机、远程、直连/)
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
await test('POST /containers:省略 target 且多目标时要求显式指定', async () => {
|
|
692
|
+
const res = await call('POST', '/containers', {})
|
|
693
|
+
assert.equal(res.status, 400)
|
|
694
|
+
assert.match(res.body.error, /target 必填/)
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
await test('POST /inspect:容器名注入尝试被白名单拒绝(D37 起回 400 而不是 500)', async () => {
|
|
698
|
+
const res = await call('POST', '/inspect', { target: '本机', id: 'shop-web-1; rm -rf /' })
|
|
699
|
+
assert.equal(res.status, 400)
|
|
700
|
+
assert.match(res.body.error, /container 含非法字符/)
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
await test('非 loopback 请求 403', async () => {
|
|
704
|
+
const res = await call('GET', '/config', undefined, '10.0.0.9')
|
|
705
|
+
assert.equal(res.status, 403)
|
|
706
|
+
assert.match(res.body.error, /loopback-only/)
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
await test('未知子路由 404', async () => {
|
|
710
|
+
const res = await call('POST', '/nope', { target: '本机' })
|
|
711
|
+
assert.equal(res.status, 404)
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
/* ------------------------------------------------------------------ *
|
|
715
|
+
* 5. 打开能力开关(含 settings/updated 热更新路径)
|
|
716
|
+
* ------------------------------------------------------------------ */
|
|
717
|
+
|
|
718
|
+
await test('POST /config 打开 allowMutations:工具与路由同时解锁', async () => {
|
|
719
|
+
const res = await call('POST', '/config', { allowMutations: true })
|
|
720
|
+
assert.equal(res.status, 200)
|
|
721
|
+
assert.equal(res.body.config.allowMutations, true)
|
|
722
|
+
assert.equal(toolNames().includes('docker_action'), true)
|
|
723
|
+
|
|
724
|
+
const action = await call('POST', '/action', { target: '本机', action: 'start', id: 'shop-web-1' })
|
|
725
|
+
assert.equal(action.status, 200)
|
|
726
|
+
assert.equal(action.body.result.action, 'start')
|
|
727
|
+
})
|
|
728
|
+
|
|
729
|
+
await test('POST /config 打开 allowExec:docker_exec 注册且 /exec 可用', async () => {
|
|
730
|
+
const res = await call('POST', '/config', { allowExec: true })
|
|
731
|
+
assert.equal(res.status, 200)
|
|
732
|
+
assert.equal(toolNames().includes('docker_exec'), true)
|
|
733
|
+
|
|
734
|
+
const exec = await call('POST', '/exec', { target: '本机', id: 'shop-web-1', command: 'echo hi' })
|
|
735
|
+
assert.equal(exec.status, 200)
|
|
736
|
+
assert.equal(exec.body.result.code, 0)
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
await test('POST /config 打开 allowMutations:镜像变更路由与工具一起解锁', async () => {
|
|
740
|
+
const names = toolNames()
|
|
741
|
+
assert.equal(names.includes('docker_image_remove'), true)
|
|
742
|
+
assert.equal(names.includes('docker_image_prune'), true)
|
|
743
|
+
assert.equal(names.includes('docker_image_pull'), true)
|
|
744
|
+
|
|
745
|
+
const remove = await call('POST', '/images/remove', { target: '本机', ref: 'nginx:1.27' })
|
|
746
|
+
assert.equal(remove.status, 200)
|
|
747
|
+
assert.match(remove.body.result.message, /Untagged/)
|
|
748
|
+
const prune = await call('POST', '/images/prune', { target: '本机' })
|
|
749
|
+
assert.equal(prune.status, 200)
|
|
750
|
+
assert.match(prune.body.result.message, /Total reclaimed space/)
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
await test('开启变更后:网络 / 卷的 remove 与 prune 放行', async () => {
|
|
754
|
+
const netRemove = await call('POST', '/networks/remove', { target: '本机', name: 'shop_default' })
|
|
755
|
+
assert.equal(netRemove.status, 200)
|
|
756
|
+
assert.equal(netRemove.body.result.name, 'shop_default')
|
|
757
|
+
const netPrune = await call('POST', '/networks/prune', { target: '本机' })
|
|
758
|
+
assert.equal(netPrune.status, 200)
|
|
759
|
+
assert.match(netPrune.body.result.message, /shop_default/)
|
|
760
|
+
const volRemove = await call('POST', '/volumes/remove', { target: '本机', name: 'pgdata' })
|
|
761
|
+
assert.equal(volRemove.status, 200)
|
|
762
|
+
assert.equal(volRemove.body.result.name, 'pgdata')
|
|
763
|
+
const volPrune = await call('POST', '/volumes/prune', { target: '本机' })
|
|
764
|
+
assert.equal(volPrune.status, 200)
|
|
765
|
+
assert.match(volPrune.body.result.message, /pgdata/)
|
|
766
|
+
|
|
767
|
+
// 开门之后才轮到 name 校验(门控在最前面,与镜像 /action 同序)
|
|
768
|
+
for (const sub of ['/networks/remove', '/volumes/remove']) {
|
|
769
|
+
const missing = await call('POST', sub, { target: '本机' })
|
|
770
|
+
assert.equal(missing.status, 400, sub)
|
|
771
|
+
assert.match(missing.body.error, /name 必填/)
|
|
772
|
+
}
|
|
773
|
+
})
|
|
774
|
+
|
|
775
|
+
await test('agent 工具:docker_networks / docker_volumes 列清单(变更类不注册工具)', async () => {
|
|
776
|
+
const netTool = state.tools.find((item) => item.name === 'docker_networks')
|
|
777
|
+
assert.ok(netTool !== undefined)
|
|
778
|
+
const nets = await netTool.execute({ target: '本机' })
|
|
779
|
+
assert.equal(nets.target, '本机')
|
|
780
|
+
assert.deepEqual(nets.networks[0], { name: 'shop_default', driver: 'bridge', scope: 'local', id: '8d5028a7e8b1' })
|
|
781
|
+
|
|
782
|
+
const volTool = state.tools.find((item) => item.name === 'docker_volumes')
|
|
783
|
+
assert.ok(volTool !== undefined)
|
|
784
|
+
const vols = await volTool.execute({ target: '本机' })
|
|
785
|
+
assert.deepEqual(vols.volumes[0], { name: 'pgdata', driver: 'local', scope: 'local', mountpoint: '/var/lib/docker/volumes/pgdata/_data' })
|
|
786
|
+
|
|
787
|
+
// 刻意不注册变更类工具:网络 / 卷的 rm / prune 只有 HTTP 端点(agent 侧不扩大变更面)
|
|
788
|
+
for (const absent of ['docker_network_remove', 'docker_network_prune', 'docker_volume_remove', 'docker_volume_prune']) {
|
|
789
|
+
assert.equal(toolNames().includes(absent), false, absent + ' 不该注册')
|
|
790
|
+
}
|
|
791
|
+
})
|
|
792
|
+
|
|
793
|
+
await test('GET /images/pull/stream:SSE 拉取进度(line → end pull-exit)', async () => {
|
|
794
|
+
const res = await call('GET', '/images/pull/stream?target=本机&ref=nginx:1.27')
|
|
795
|
+
assert.equal(res.status, 200)
|
|
796
|
+
assert.equal(res.headers['content-type'], 'text/event-stream; charset=utf-8')
|
|
797
|
+
assert.equal(res.flushed, true)
|
|
798
|
+
const text = res.frames.join('')
|
|
799
|
+
assert.match(text, /event: line\ndata: \{"d":"Pulling from library\/nginx/)
|
|
800
|
+
assert.match(text, /Status: Downloaded newer image for nginx:1.27/)
|
|
801
|
+
assert.match(text, /event: end\ndata: \{"reason":"pull-exit","code":0,"ref":"nginx:1.27"\}\n\n/)
|
|
802
|
+
assert.equal(res.ended, true)
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
await test('GET /images/pull/stream:非法 ref 400(不建流)/ 缺 ref 400 / 非 GET 405', async () => {
|
|
806
|
+
const bad = await call('GET', '/images/pull/stream?target=本机&ref=' + encodeURIComponent('-f'))
|
|
807
|
+
assert.equal(bad.status, 400)
|
|
808
|
+
assert.match(bad.body.error, /image 含非法字符/)
|
|
809
|
+
assert.equal(bad.flushed, false)
|
|
810
|
+
const missing = await call('GET', '/images/pull/stream?target=本机')
|
|
811
|
+
assert.equal(missing.status, 400)
|
|
812
|
+
assert.match(missing.body.error, /ref 必填/)
|
|
813
|
+
const wrongMethod = await call('POST', '/images/pull/stream?target=本机&ref=nginx')
|
|
814
|
+
assert.equal(wrongMethod.status, 405)
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
await test('agent 工具:每个工具的 render 都能跑,且输出里不出现 undefined', async () => {
|
|
818
|
+
/*
|
|
819
|
+
* 这一条是补课。以前所有用例只调 execute()、从不调 render(),于是渲染器与工具返回值
|
|
820
|
+
* 的形状失配一路溜到线上:docker_ps 直接 `row.ports.map is not a function` 崩掉、
|
|
821
|
+
* docker_networks / docker_images 打出 `id=undefined`——因为渲染器按**领域类型**写
|
|
822
|
+
* (ports 是数组、字段叫 shortId、可空字段是 null),而工具按 **schema 扁平形状**返回
|
|
823
|
+
* (ports 已是字符串、字段叫 id、可省字段是 undefined),中间只有一个不受检查的
|
|
824
|
+
* `as` 断言挡着,tsc 也看不见。
|
|
825
|
+
*
|
|
826
|
+
* 所以这里逐个渲染,并断言文本里不出现 `undefined`——它是这类形状失配最直接的探针。
|
|
827
|
+
*/
|
|
828
|
+
const cases = [
|
|
829
|
+
['docker_targets', {}],
|
|
830
|
+
['docker_ps', { target: '本机' }],
|
|
831
|
+
['docker_ps', { target: '*' }],
|
|
832
|
+
['docker_attention', { target: '本机' }],
|
|
833
|
+
['docker_inspect', { target: '本机', id: 'shop-web-1' }],
|
|
834
|
+
['docker_logs', { target: '本机', id: 'shop-web-1', tail: 5 }],
|
|
835
|
+
['docker_stats', { target: '本机' }],
|
|
836
|
+
['docker_images', { target: '本机' }],
|
|
837
|
+
['docker_image_inspect', { target: '本机', ref: 'nginx:1.27' }],
|
|
838
|
+
['docker_events', { target: '本机' }],
|
|
839
|
+
['docker_networks', { target: '本机' }],
|
|
840
|
+
['docker_volumes', { target: '本机' }],
|
|
841
|
+
// 开关门禁的那几个:本用例跑在 allowMutations / allowExec 打开之后,所以它们此刻也注册着。
|
|
842
|
+
// 它们正是排障时最常用的(exec / 重启),渲染器同样要有覆盖。
|
|
843
|
+
['docker_action', { target: '本机', action: 'restart', id: 'shop-web-1' }],
|
|
844
|
+
['docker_exec', { target: '本机', id: 'shop-web-1', command: 'ls -la' }],
|
|
845
|
+
['docker_image_pull', { target: '本机', ref: 'alpine:3.19' }],
|
|
846
|
+
['docker_image_remove', { target: '本机', ref: 'alpine:3.19' }],
|
|
847
|
+
['docker_image_prune', { target: '本机' }],
|
|
848
|
+
]
|
|
849
|
+
for (const [name, args] of cases) {
|
|
850
|
+
const tool = state.tools.find((item) => item.name === name)
|
|
851
|
+
assert.ok(tool !== undefined, name + ' 未注册')
|
|
852
|
+
assert.equal(typeof tool.output.render, 'function', name + ' 缺少 render')
|
|
853
|
+
const value = await tool.execute(args)
|
|
854
|
+
const out = tool.output.render(args, value)
|
|
855
|
+
assert.ok(Array.isArray(out) && out.length > 0, name + ' 的 render 应返回非空数组')
|
|
856
|
+
const text = out.map((part) => part.text ?? '').join('\n')
|
|
857
|
+
assert.ok(text.trim() !== '', name + ' 渲染出空文本')
|
|
858
|
+
assert.ok(!text.includes('undefined'), name + ' 的渲染里出现 undefined:\n' + text.slice(0, 400))
|
|
859
|
+
}
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
await test('agent 工具:docker_ps 经假 CLI 返回容器', async () => {
|
|
863
|
+
const tool = state.tools.find((item) => item.name === 'docker_ps')
|
|
864
|
+
assert.ok(tool !== undefined)
|
|
865
|
+
const value = await tool.execute({ target: '本机' })
|
|
866
|
+
assert.equal(value.target, '本机')
|
|
867
|
+
assert.equal(value.containers[0].name, 'shop-web-1')
|
|
868
|
+
assert.equal(value.containers[0].health, 'healthy')
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
await test('agent 工具:docker_logs 返回日志文本', async () => {
|
|
872
|
+
const tool = state.tools.find((item) => item.name === 'docker_logs')
|
|
873
|
+
const value = await tool.execute({ target: '本机', id: 'shop-web-1', tail: 10 })
|
|
874
|
+
assert.match(value.text, /stdout line 1/)
|
|
875
|
+
assert.match(value.text, /stderr line 1/)
|
|
876
|
+
})
|
|
877
|
+
|
|
878
|
+
await test('agent 工具:docker_image_inspect 返回层数与构建历史', async () => {
|
|
879
|
+
const tool = state.tools.find((item) => item.name === 'docker_image_inspect')
|
|
880
|
+
assert.ok(tool !== undefined)
|
|
881
|
+
const value = await tool.execute({ target: '本机', ref: 'nginx:1.27' })
|
|
882
|
+
assert.equal(value.ref, 'nginx:1.27')
|
|
883
|
+
assert.match(value.detail, /层:2 层/)
|
|
884
|
+
assert.match(value.detail, /构建历史(1 步/)
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
await test('agent 工具:docker_targets 探测目标可达性', async () => {
|
|
888
|
+
const tool = state.tools.find((item) => item.name === 'docker_targets')
|
|
889
|
+
const value = await tool.execute({ probe: true })
|
|
890
|
+
const local = value.targets.find((item) => item.name === '本机')
|
|
891
|
+
assert.equal(local.ok, true)
|
|
892
|
+
// 远程目标走真 SSH(127.0.0.1:1 立即 ECONNREFUSED,D25)→ 应报不可用而不是抛异常
|
|
893
|
+
const remote = value.targets.find((item) => item.name === '远程')
|
|
894
|
+
assert.equal(remote.ok, false)
|
|
895
|
+
assert.ok(typeof remote.error === 'string' && remote.error !== '')
|
|
896
|
+
})
|
|
897
|
+
|
|
898
|
+
/* ------------------------------------------------------------------ *
|
|
899
|
+
* 5.5 跨目标聚合 + 需关注(0.15.0)
|
|
900
|
+
* ------------------------------------------------------------------ */
|
|
901
|
+
|
|
902
|
+
await test('POST /containers:target=* 返回按目标分组的部分成功结果', async () => {
|
|
903
|
+
const res = await call('POST', '/containers', { target: '*', all: true })
|
|
904
|
+
assert.equal(res.status, 200)
|
|
905
|
+
const groups = res.body.groups
|
|
906
|
+
assert.equal(Array.isArray(groups), true)
|
|
907
|
+
assert.equal(groups.length, 3)
|
|
908
|
+
const local = groups.find((group) => group.target === '本机')
|
|
909
|
+
assert.equal(local.ok, true)
|
|
910
|
+
assert.equal(local.data.length, 2) // 正常容器 + OOM 容器
|
|
911
|
+
// 远程目标走真 SSH(不可达)→ 该组 ok:false,但不影响其他组
|
|
912
|
+
const remote = groups.find((group) => group.target === '远程')
|
|
913
|
+
assert.equal(remote.ok, false)
|
|
914
|
+
assert.ok(typeof remote.error === 'string' && remote.error !== '')
|
|
915
|
+
assert.equal(groups.find((group) => group.target === '直连').ok, false)
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
await test('POST /attention:单目标返回需关注列表(OOM 原因来自 inspect)', async () => {
|
|
919
|
+
const res = await call('POST', '/attention', { target: '本机' })
|
|
920
|
+
assert.equal(res.status, 200)
|
|
921
|
+
const items = res.body.items
|
|
922
|
+
assert.equal(items.length, 1)
|
|
923
|
+
assert.equal(items[0].name, 'broken-worker')
|
|
924
|
+
assert.deepEqual(items[0].reasons, ['oom'])
|
|
925
|
+
assert.equal(items[0].oomKilled, true)
|
|
926
|
+
assert.equal(items[0].exitCode, 137)
|
|
927
|
+
assert.equal(items[0].restartCount, 7)
|
|
928
|
+
// 截断信号(D12)
|
|
929
|
+
assert.equal(res.body.total, 1)
|
|
930
|
+
assert.equal(res.body.truncated, false)
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
await test('POST /attention:target=* 跨目标聚合,失败目标带 error', async () => {
|
|
934
|
+
const res = await call('POST', '/attention', { target: '*' })
|
|
935
|
+
assert.equal(res.status, 200)
|
|
936
|
+
const groups = res.body.groups
|
|
937
|
+
assert.equal(groups.length, 3)
|
|
938
|
+
const local = groups.find((group) => group.target === '本机')
|
|
939
|
+
assert.equal(local.ok, true)
|
|
940
|
+
// attention() 现在返回 {items, total, truncated}(D12 截断信号)
|
|
941
|
+
assert.equal(local.data.items.length, 1)
|
|
942
|
+
assert.equal(local.data.total, 1)
|
|
943
|
+
assert.equal(groups.find((group) => group.target === '远程').ok, false)
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
await test('agent 工具:docker_ps 传 * 返回分组', async () => {
|
|
947
|
+
const tool = state.tools.find((item) => item.name === 'docker_ps')
|
|
948
|
+
const value = await tool.execute({ target: '*', all: true })
|
|
949
|
+
assert.equal(Array.isArray(value.groups), true)
|
|
950
|
+
const local = value.groups.find((group) => group.target === '本机')
|
|
951
|
+
assert.equal(local.ok, true)
|
|
952
|
+
assert.equal(local.containers.length, 2)
|
|
953
|
+
assert.equal(value.groups.find((group) => group.target === '远程').ok, false)
|
|
954
|
+
})
|
|
955
|
+
|
|
956
|
+
await test('agent 工具:docker_attention 单目标与跨目标', async () => {
|
|
957
|
+
const tool = state.tools.find((item) => item.name === 'docker_attention')
|
|
958
|
+
assert.ok(tool !== undefined, 'docker_attention 未注册')
|
|
959
|
+
const single = await tool.execute({ target: '本机' })
|
|
960
|
+
assert.equal(single.items.length, 1)
|
|
961
|
+
assert.deepEqual(single.items[0].reasons, ['oom'])
|
|
962
|
+
const all = await tool.execute({ target: '*' })
|
|
963
|
+
assert.equal(Array.isArray(all.groups), true)
|
|
964
|
+
assert.equal(all.groups.find((group) => group.target === '本机').items.length, 1)
|
|
965
|
+
})
|
|
966
|
+
|
|
967
|
+
/* ------------------------------------------------------------------ *
|
|
968
|
+
* 6. 数据保护:空 targets 不得静默清空(启动竞态曾导致目标被抹掉)
|
|
969
|
+
* ------------------------------------------------------------------ */
|
|
970
|
+
|
|
971
|
+
await test('POST /config 空 targets(未带 clearTargets)不会清空已有目标', async () => {
|
|
972
|
+
const before = (await call('GET', '/config')).body.config.targets.length
|
|
973
|
+
assert.ok(before > 0, '前置条件:应已有目标')
|
|
974
|
+
const res = await call('POST', '/config', { targets: [] })
|
|
975
|
+
assert.equal(res.status, 200)
|
|
976
|
+
assert.match(res.body.warning, /已忽略空的目标列表/)
|
|
977
|
+
const after = (await call('GET', '/config')).body.config.targets.length
|
|
978
|
+
assert.equal(after, before, '目标不应被空数组清空')
|
|
979
|
+
})
|
|
980
|
+
|
|
981
|
+
await test('POST /config 带 clearTargets: true 才允许清空目标', async () => {
|
|
982
|
+
const res = await call('POST', '/config', { targets: [], clearTargets: true })
|
|
983
|
+
assert.equal(res.status, 200)
|
|
984
|
+
assert.equal(res.body.config.targets.length, 0)
|
|
985
|
+
assert.equal(res.body.warning, undefined)
|
|
986
|
+
})
|
|
987
|
+
|
|
988
|
+
await test('GET /config 以 settings 解析值为准(挂载竞态不再返回空目标)', async () => {
|
|
989
|
+
// 重新写回一条目标后,快照必须立刻反映(而不是等 settings/updated)
|
|
990
|
+
const res = await call('POST', '/config', { targets: [{ name: '本机', kind: 'local' }] })
|
|
991
|
+
assert.equal(res.status, 200)
|
|
992
|
+
assert.equal(res.body.config.targets.length, 1)
|
|
993
|
+
const snapshot = await call('GET', '/config')
|
|
994
|
+
assert.equal(snapshot.body.config.targets[0].name, '本机')
|
|
995
|
+
})
|
|
996
|
+
|
|
997
|
+
/* ------------------------------------------------------------------ *
|
|
998
|
+
* 7. 禁用与恢复:enabled 热生效(设置卡片路径 + 重启路径)
|
|
999
|
+
* ------------------------------------------------------------------ */
|
|
1000
|
+
|
|
1001
|
+
await test('禁用:POST /config enabled=false → 工具清空、公告撤下、数据路由 403', async () => {
|
|
1002
|
+
const res = await call('POST', '/config', { enabled: false })
|
|
1003
|
+
assert.equal(res.status, 200)
|
|
1004
|
+
assert.equal(res.body.config.enabled, false)
|
|
1005
|
+
assert.deepEqual(toolNames(), [], '禁用后不应残留任何 agent 工具')
|
|
1006
|
+
assert.equal(state.prompts.length, 0, '禁用后能力公告应被撤下')
|
|
1007
|
+
const blockedGet = await call('GET', '/targets')
|
|
1008
|
+
assert.equal(blockedGet.status, 403)
|
|
1009
|
+
assert.match(blockedGet.body.error, /插件已禁用/)
|
|
1010
|
+
// 日志流也一起关(SSE 分支在禁用检查之后,拿不到 200 长连接)
|
|
1011
|
+
const blockedStream = await call('GET', '/logs/stream?target=本机&id=shop-web-1')
|
|
1012
|
+
assert.equal(blockedStream.status, 403)
|
|
1013
|
+
// 统计 / 事件 / 拉取流一并被禁用检查拦在 200 之前
|
|
1014
|
+
assert.equal((await call('GET', '/stats/stream?target=本机&ids=web')).status, 403)
|
|
1015
|
+
assert.equal((await call('GET', '/events/stream?target=本机')).status, 403)
|
|
1016
|
+
assert.equal((await call('GET', '/images/pull/stream?target=本机&ref=nginx:1.27')).status, 403)
|
|
1017
|
+
const blockedPost = await call('POST', '/containers', { target: '本机' })
|
|
1018
|
+
assert.equal(blockedPost.status, 403)
|
|
1019
|
+
// 禁用状态下 /action 与 /exec 的每调用守卫也必须仍然兜底
|
|
1020
|
+
const action = await call('POST', '/action', { target: '本机', action: 'stop', id: 'shop-web-1' })
|
|
1021
|
+
assert.equal(action.status, 403)
|
|
1022
|
+
assert.match(action.body.error, /插件已禁用/)
|
|
1023
|
+
})
|
|
1024
|
+
|
|
1025
|
+
await test('禁用期间:GET/POST /config 保持可用(卡片渲染与重新启用的唯一入口)', async () => {
|
|
1026
|
+
const read = await call('GET', '/config')
|
|
1027
|
+
assert.equal(read.status, 200)
|
|
1028
|
+
assert.equal(read.body.config.enabled, false)
|
|
1029
|
+
const write = await call('POST', '/config', { pollIntervalSec: 6 })
|
|
1030
|
+
assert.equal(write.status, 200)
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
await test('重新启用:工具(含能力开关项)、公告、数据路由全部恢复', async () => {
|
|
1034
|
+
const res = await call('POST', '/config', { enabled: true })
|
|
1035
|
+
assert.equal(res.status, 200)
|
|
1036
|
+
assert.equal(res.body.config.enabled, true)
|
|
1037
|
+
const names = toolNames()
|
|
1038
|
+
for (const readonly of ['docker_targets', 'docker_ps', 'docker_inspect', 'docker_logs', 'docker_stats', 'docker_images', 'docker_image_inspect', 'docker_events', 'docker_networks', 'docker_volumes']) {
|
|
1039
|
+
assert.equal(names.includes(readonly), true, `缺少只读工具 ${readonly}`)
|
|
1040
|
+
}
|
|
1041
|
+
// allowMutations / allowExec 在此前的用例里已打开:重启用后对应工具一并回来
|
|
1042
|
+
assert.equal(names.includes('docker_action'), true)
|
|
1043
|
+
assert.equal(names.includes('docker_exec'), true)
|
|
1044
|
+
assert.equal(names.includes('docker_image_remove'), true)
|
|
1045
|
+
assert.equal(names.includes('docker_image_prune'), true)
|
|
1046
|
+
assert.equal(names.includes('docker_image_pull'), true)
|
|
1047
|
+
assert.equal(state.prompts.length, 1)
|
|
1048
|
+
assert.equal(state.prompts[0].name, 'plugin:dsh-docker')
|
|
1049
|
+
assert.equal((await call('GET', '/targets')).status, 200)
|
|
1050
|
+
})
|
|
1051
|
+
|
|
1052
|
+
await test('重启路径:settings 命名空间已禁用时挂载 → 挂载即禁用(卡片仍可读)', async () => {
|
|
1053
|
+
// 模拟上一次会话在设置卡片里关掉过「启用插件」:settings 命名空间持久化了 enabled:false
|
|
1054
|
+
const second = makeCtx({ dockerBin: fakeBin, targets: MOUNT_TARGETS }, { ttyConfig: TTY_CONFIG })
|
|
1055
|
+
second.state.settingsStored = { enabled: false }
|
|
1056
|
+
host.apply({ ...second.ctx }, { dockerBin: fakeBin, targets: MOUNT_TARGETS })
|
|
1057
|
+
const secondRoute = second.state.routes.find((item) => item.path === '/api/dsh-docker')
|
|
1058
|
+
assert.ok(secondRoute !== undefined, '挂载时仍应注册路由(禁用是运行态而非不挂载)')
|
|
1059
|
+
assert.deepEqual(second.state.tools, [], '挂载即禁用:不应注册任何 agent 工具')
|
|
1060
|
+
assert.equal(second.state.prompts.length, 0, '挂载即禁用:不应注册能力公告')
|
|
1061
|
+
const req = async (method, sub) => {
|
|
1062
|
+
const res = makeRes()
|
|
1063
|
+
await secondRoute.handler(makeReq(method, '/api/dsh-docker' + sub, method === 'GET' ? undefined : {}), res)
|
|
1064
|
+
return res
|
|
1065
|
+
}
|
|
1066
|
+
assert.equal((await req('GET', '/targets')).status, 403)
|
|
1067
|
+
const config = await req('GET', '/config')
|
|
1068
|
+
assert.equal(config.status, 200, '禁用态下 /config 必须保持可读(卡片依赖)')
|
|
1069
|
+
assert.equal(config.body.config.enabled, false)
|
|
1070
|
+
})
|
|
1071
|
+
|
|
1072
|
+
/* ------------------------------------------------------------------ *
|
|
1073
|
+
* 8. 结果
|
|
1074
|
+
* ------------------------------------------------------------------ */
|
|
1075
|
+
|
|
1076
|
+
let failed = 0
|
|
1077
|
+
for (const result of results) {
|
|
1078
|
+
if (result.ok) {
|
|
1079
|
+
console.log(` ✓ ${result.name}`)
|
|
1080
|
+
} else {
|
|
1081
|
+
failed += 1
|
|
1082
|
+
console.error(` ✗ ${result.name}\n ${result.message}`)
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
console.log(`\n[dsh-docker] route-smoke: ${String(results.length - failed)}/${String(results.length)} 通过`)
|
|
1086
|
+
// 清理假 CLI 临时目录(D25 顺带):此前每次运行在 os.tmpdir() 留一份
|
|
1087
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch { /* 忽略 */ }
|
|
1088
|
+
clearTimeout(watchdog)
|
|
1089
|
+
// 成功路径也要显式退出(D121):主体结束后仍有周期句柄漏着的话,
|
|
1090
|
+
// 关掉看门狗就再也没人兜底了 —— 直接 exit 既保证退出也省掉排空等待
|
|
1091
|
+
process.exit(failed > 0 ? 1 : 0)
|