@hyzyn/dsh-docker 0.1.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/lib/index.js ADDED
@@ -0,0 +1,1212 @@
1
+ import z from '@deepseek-ai/schemastery';
2
+ import { definePlugin } from '@hyzyn/dsh-kit';
3
+ import { defineTool } from '@deepseek-ai/dsh-tools';
4
+ import { DockerApi, assertBin, createRunner, parseInspectJson, parsePsJson, parseStatsJson, } from './docker.js';
5
+ import { RemoteExec, sshTarget } from './ssh-exec.js';
6
+ const TARGET_SCHEMA = z.object({
7
+ name: z.string().required(),
8
+ kind: z.union([z.const('local'), z.const('ssh')]).default('local'),
9
+ /** kind=ssh:引用 tty 连接簿条目名(可留空,改用下方内联字段)。 */
10
+ book: z.string().default(''),
11
+ host: z.string().default(''),
12
+ port: z.natural().max(65535).default(22),
13
+ username: z.string().default(''),
14
+ auth: z.union([z.const('agent'), z.const('key'), z.const('password')]).default('agent'),
15
+ keyPath: z.string().default(''),
16
+ /** 建议写 `env:VAR` 引用(env 插件托管),避免明文落盘。 */
17
+ password: z.string().default(''),
18
+ passphrase: z.string().default(''),
19
+ agentForward: z.boolean().default(false),
20
+ });
21
+ const HOST_KEY_SCHEMA = z.object({
22
+ host: z.string().required(),
23
+ port: z.natural().max(65535).default(22),
24
+ fingerprint: z.string().required(),
25
+ });
26
+ const DOCKER_SETTINGS_SCHEMA = z.object({
27
+ enabled: z.boolean().default(true),
28
+ announceToAgent: z.boolean().default(true),
29
+ dockerBin: z.string().default('docker'),
30
+ allowMutations: z.boolean().default(false),
31
+ allowExec: z.boolean().default(false),
32
+ execTimeoutSec: z.natural().max(120).default(30),
33
+ pollIntervalSec: z.natural().max(60).default(5),
34
+ logTailDefault: z.natural().max(5000).default(200),
35
+ maxOutputKb: z.natural().max(8192).default(512),
36
+ targets: z.array(TARGET_SCHEMA).default([]),
37
+ hostKeys: z.array(HOST_KEY_SCHEMA).default([]),
38
+ });
39
+ /** 可经 HTTP POST 写入的配置键(白名单)。 */
40
+ const KNOWN_CONFIG_KEYS = new Set([
41
+ 'enabled',
42
+ 'announceToAgent',
43
+ 'dockerBin',
44
+ 'allowMutations',
45
+ 'allowExec',
46
+ 'execTimeoutSec',
47
+ 'pollIntervalSec',
48
+ 'logTailDefault',
49
+ 'maxOutputKb',
50
+ 'targets',
51
+ 'hostKeys',
52
+ /** 显式清空全部目标的确认位(见 POST /config 的空数组防丢保护)。 */
53
+ 'clearTargets',
54
+ ]);
55
+ /* ------------------------------------------------------------------ *
56
+ * 常量
57
+ * ------------------------------------------------------------------ */
58
+ const ROUTE_PREFIX = '/api/dsh-docker';
59
+ const BODY_LIMIT = 1024 * 1024;
60
+ const DOCKER_GUIDANCE = '本机已安装 dsh-docker 插件(Docker 容器面板):Web GUI 侧边栏「容器」入口可查看各目标(本机 / SSH 主机)上的容器列表、状态、端口、日志与资源占用,以及镜像列表;目标在 设置 → 插件 → Docker 容器面板 里维护(SSH 目标可直接引用 tty 终端面板的连接簿条目)。**默认只读**:启动/停止/重启/删除与 docker exec 需要用户在设置里显式打开「允许变更操作」「允许 exec」后才有对应工具与按钮。agent 侧配套只读工具 docker_targets(列目标)、docker_ps(列容器)、docker_inspect(容器详情)、docker_logs(日志)、docker_stats(CPU/内存/IO)、docker_images(镜像);排障推荐顺序 docker_ps → docker_logs → docker_inspect → docker_stats。docker_action / docker_exec 仅在用户打开对应开关后可用,执行前须确认目标容器,破坏性操作(remove)要向用户复述后果。docker socket 等价于目标主机的 root 权限,不要在用户未明确要求时执行变更操作。';
61
+ /** HTTP 路由的 loopback 信任围栏(与 tty / dsh-mcp 同思路)。 */
62
+ function isLoopbackHttp(req) {
63
+ const address = req.socket.remoteAddress;
64
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1')
65
+ return false;
66
+ const host = req.headers.host;
67
+ if (typeof host !== 'string')
68
+ return false;
69
+ let hostUrl;
70
+ try {
71
+ hostUrl = new URL('http://' + host);
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]')
77
+ return false;
78
+ if (req.headers['sec-fetch-site'] === 'cross-site')
79
+ return false;
80
+ const origin = req.headers.origin;
81
+ if (origin === undefined)
82
+ return true;
83
+ try {
84
+ return new URL(origin).host === hostUrl.host;
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
90
+ function writeJson(res, status, body) {
91
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' });
92
+ res.end(JSON.stringify(body));
93
+ }
94
+ /** 读取并解析 JSON 请求体;超限/非对象/非法 JSON 返回 undefined。 */
95
+ async function readJsonBody(req) {
96
+ const chunks = [];
97
+ let size = 0;
98
+ for await (const chunk of req) {
99
+ size += chunk.byteLength;
100
+ if (size > BODY_LIMIT)
101
+ return undefined;
102
+ chunks.push(Buffer.from(chunk));
103
+ }
104
+ if (size === 0)
105
+ return {};
106
+ try {
107
+ const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
108
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
109
+ return undefined;
110
+ return parsed;
111
+ }
112
+ catch {
113
+ return undefined;
114
+ }
115
+ }
116
+ /** 清洗一份 targets 输入(settings 存储 / 热更新路径共用)。 */
117
+ export function sanitizeTargets(input) {
118
+ if (!Array.isArray(input))
119
+ return undefined;
120
+ const out = [];
121
+ const seen = new Set();
122
+ for (const raw of input) {
123
+ if (typeof raw !== 'object' || raw === null)
124
+ continue;
125
+ const item = raw;
126
+ const name = typeof item.name === 'string' ? item.name.trim() : '';
127
+ if (name === '' || name.length > 64 || seen.has(name))
128
+ continue;
129
+ const kind = item.kind === 'ssh' ? 'ssh' : 'local';
130
+ const port = typeof item.port === 'number' && Number.isInteger(item.port) && item.port >= 1 && item.port <= 65535 ? item.port : 22;
131
+ const auth = item.auth === 'key' || item.auth === 'password' ? item.auth : 'agent';
132
+ seen.add(name);
133
+ out.push({
134
+ name,
135
+ kind,
136
+ book: typeof item.book === 'string' ? item.book.trim() : '',
137
+ host: typeof item.host === 'string' ? item.host.trim() : '',
138
+ port,
139
+ username: typeof item.username === 'string' ? item.username.trim() : '',
140
+ auth,
141
+ keyPath: typeof item.keyPath === 'string' ? item.keyPath.trim() : '',
142
+ password: typeof item.password === 'string' ? item.password : '',
143
+ passphrase: typeof item.passphrase === 'string' ? item.passphrase : '',
144
+ agentForward: item.agentForward === true,
145
+ });
146
+ }
147
+ return out;
148
+ }
149
+ /** 清洗一份 hostKeys 输入。 */
150
+ export function sanitizeHostKeys(input) {
151
+ if (!Array.isArray(input))
152
+ return undefined;
153
+ const out = [];
154
+ for (const raw of input) {
155
+ if (typeof raw !== 'object' || raw === null)
156
+ continue;
157
+ const item = raw;
158
+ const host = typeof item.host === 'string' ? item.host.trim() : '';
159
+ const fingerprint = typeof item.fingerprint === 'string' ? item.fingerprint.trim() : '';
160
+ if (host === '' || fingerprint === '')
161
+ continue;
162
+ const port = typeof item.port === 'number' && Number.isInteger(item.port) && item.port >= 1 && item.port <= 65535 ? item.port : 22;
163
+ out.push({ host, port, fingerprint });
164
+ }
165
+ return out;
166
+ }
167
+ /**
168
+ * 合并凭证:配置卡片从不回显密码 / 口令(只回 passwordSet),因此浏览器提交的
169
+ * targets 里往往**没有** password/passphrase 字段。按目标名把已有值补回来,
170
+ * 避免「改个名字就把密码清了」。(显式传空字符串仍然按清空处理。)
171
+ */
172
+ export function mergeTargetSecrets(prev, incoming) {
173
+ if (!Array.isArray(incoming))
174
+ return incoming;
175
+ return incoming.map((raw) => {
176
+ if (typeof raw !== 'object' || raw === null)
177
+ return raw;
178
+ const item = raw;
179
+ const name = typeof item.name === 'string' ? item.name.trim() : '';
180
+ const before = prev.find((target) => target.name === name);
181
+ if (before === undefined)
182
+ return item;
183
+ const next = { ...item };
184
+ if (typeof next.password !== 'string')
185
+ next.password = before.password ?? '';
186
+ if (typeof next.passphrase !== 'string')
187
+ next.passphrase = before.passphrase ?? '';
188
+ return next;
189
+ });
190
+ }
191
+ /** 把一份任意来源的配置归一成 LiveConfig。 */
192
+ export function normalizeConfig(section) {
193
+ const targets = sanitizeTargets(section.targets);
194
+ const hostKeys = sanitizeHostKeys(section.hostKeys);
195
+ return {
196
+ enabled: section.enabled !== false,
197
+ announceToAgent: section.announceToAgent !== false,
198
+ dockerBin: assertBin(section.dockerBin),
199
+ allowMutations: section.allowMutations === true,
200
+ allowExec: section.allowExec === true,
201
+ execTimeoutSec: clampInt(section.execTimeoutSec, 1, 120, 30),
202
+ pollIntervalSec: clampInt(section.pollIntervalSec, 1, 60, 5),
203
+ logTailDefault: clampInt(section.logTailDefault, 1, 5000, 200),
204
+ maxOutputKb: clampInt(section.maxOutputKb, 1, 8192, 512),
205
+ targets: targets ?? [],
206
+ hostKeys: hostKeys ?? [],
207
+ };
208
+ }
209
+ function clampInt(value, min, max, fallback) {
210
+ if (typeof value !== 'number' || !Number.isInteger(value))
211
+ return fallback;
212
+ return Math.min(Math.max(value, min), max);
213
+ }
214
+ /** 从 tty 的 settings 命名空间读取连接簿(只读;tty 未安装时为空表)。 */
215
+ function readTtyBooks(settings) {
216
+ const out = new Map();
217
+ if (settings === undefined)
218
+ return out;
219
+ let raw;
220
+ try {
221
+ raw = settings.get('tty');
222
+ }
223
+ catch {
224
+ return out;
225
+ }
226
+ if (typeof raw !== 'object' || raw === null)
227
+ return out;
228
+ const hosts = raw.sshHosts;
229
+ if (!Array.isArray(hosts))
230
+ return out;
231
+ for (const entry of hosts) {
232
+ if (typeof entry !== 'object' || entry === null)
233
+ continue;
234
+ const item = entry;
235
+ const name = typeof item.name === 'string' ? item.name.trim() : '';
236
+ const host = typeof item.host === 'string' ? item.host.trim() : '';
237
+ const username = typeof item.username === 'string' ? item.username.trim() : '';
238
+ if (name === '' || host === '' || username === '')
239
+ continue;
240
+ out.set(name, {
241
+ host,
242
+ port: typeof item.port === 'number' && Number.isInteger(item.port) ? item.port : 22,
243
+ username,
244
+ auth: item.auth === 'key' || item.auth === 'password' ? item.auth : 'agent',
245
+ keyPath: typeof item.keyPath === 'string' ? item.keyPath : '',
246
+ password: typeof item.password === 'string' ? item.password : '',
247
+ passphrase: typeof item.passphrase === 'string' ? item.passphrase : '',
248
+ agentForward: item.agentForward === true,
249
+ });
250
+ }
251
+ return out;
252
+ }
253
+ /** 从 tty 的 hostKeys 读取已钉扎指纹(作为本插件 TOFU 的种子)。 */
254
+ function readTtyHostKeys(settings) {
255
+ if (settings === undefined)
256
+ return [];
257
+ let raw;
258
+ try {
259
+ raw = settings.get('tty');
260
+ }
261
+ catch {
262
+ return [];
263
+ }
264
+ if (typeof raw !== 'object' || raw === null)
265
+ return [];
266
+ const keys = raw.hostKeys;
267
+ return sanitizeHostKeys(keys) ?? [];
268
+ }
269
+ /** 把一个配置目标解析成可连接的规格(连接簿查找在此完成)。 */
270
+ export function resolveTarget(target, books) {
271
+ if (target.kind === 'local')
272
+ return { resolved: { name: target.name, kind: 'local' } };
273
+ if (target.book !== undefined && target.book !== '') {
274
+ const spec = books.get(target.book);
275
+ if (spec === undefined) {
276
+ return { error: `目标「${target.name}」引用的连接簿条目不存在:${target.book}(请在 tty 终端面板的设置卡片里添加,或改为内联 host/username)` };
277
+ }
278
+ return { resolved: { name: target.name, kind: 'ssh', spec } };
279
+ }
280
+ const host = target.host ?? '';
281
+ const username = target.username ?? '';
282
+ if (host === '' || username === '') {
283
+ return { error: `目标「${target.name}」缺少 SSH 信息:需要 book(连接簿条目名)或 host + username` };
284
+ }
285
+ const spec = {
286
+ host,
287
+ port: target.port ?? 22,
288
+ username,
289
+ auth: target.auth ?? 'agent',
290
+ keyPath: target.keyPath ?? '',
291
+ password: target.password ?? '',
292
+ passphrase: target.passphrase ?? '',
293
+ agentForward: target.agentForward === true,
294
+ };
295
+ return { resolved: { name: target.name, kind: 'ssh', spec } };
296
+ }
297
+ /* ------------------------------------------------------------------ *
298
+ * 插件
299
+ * ------------------------------------------------------------------ */
300
+ const plugin = definePlugin({
301
+ name: 'docker',
302
+ apply(ctx, config) {
303
+ let live = normalizeConfig((config ?? {}));
304
+ if (!live.enabled)
305
+ return;
306
+ const logger = {
307
+ info: (msg) => ctx.logger.info(msg),
308
+ warn: (msg) => ctx.logger.warn(msg),
309
+ };
310
+ /* ---------- 主机指纹(TOFU,本插件自持;tty 记录作种子) ---------- */
311
+ let settingsScope;
312
+ let settingsApi;
313
+ const ttySeed = () => {
314
+ const map = new Map();
315
+ for (const record of readTtyHostKeys(settingsApi))
316
+ map.set(`${record.host}:${String(record.port)}`, record.fingerprint);
317
+ return map;
318
+ };
319
+ const persistHostKeys = (records) => {
320
+ const scope = settingsScope;
321
+ if (scope === undefined)
322
+ return;
323
+ void Promise.resolve(scope.update({ hostKeys: records })).catch((error) => {
324
+ console.warn('[dsh-docker] 主机密钥记录持久化失败: ' + (error instanceof Error ? error.message : String(error)));
325
+ });
326
+ };
327
+ const hostKeyStore = {
328
+ get(host, port) {
329
+ const own = live.hostKeys.find((record) => record.host === host && record.port === port);
330
+ if (own !== undefined)
331
+ return own.fingerprint;
332
+ // tty 已确认过的主机不再重复确认:种子命中即视为可信,并复制进本插件的记录
333
+ const seeded = ttySeed().get(`${host}:${String(port)}`);
334
+ if (seeded !== undefined) {
335
+ live.hostKeys = [...live.hostKeys, { host, port, fingerprint: seeded }];
336
+ persistHostKeys(live.hostKeys);
337
+ return seeded;
338
+ }
339
+ return undefined;
340
+ },
341
+ record(host, port, fingerprint) {
342
+ const next = live.hostKeys.filter((record) => !(record.host === host && record.port === port));
343
+ next.push({ host, port, fingerprint });
344
+ live.hostKeys = next;
345
+ persistHostKeys(next);
346
+ },
347
+ };
348
+ const remote = new RemoteExec(logger, hostKeyStore);
349
+ /**
350
+ * 当前生效的 targets:**以 settings 解析值为准**。
351
+ * 为什么不能只读内存里的 live:settings 命名空间注册是异步的,挂载后存在一个
352
+ * 窗口期内 live 仍是 composition 配置(targets 为空)。若此时 GET /config 被
353
+ * 调用,卡片会拿到空目标列表,用户随后保存就把空数组写回 → 目标被清空。
354
+ */
355
+ const targetsNow = () => {
356
+ const scope = settingsScope;
357
+ if (scope !== undefined) {
358
+ try {
359
+ const resolved = scope.get();
360
+ if (typeof resolved === 'object' && resolved !== null) {
361
+ const fromScope = sanitizeTargets(resolved.targets);
362
+ if (fromScope !== undefined)
363
+ return fromScope;
364
+ }
365
+ }
366
+ catch {
367
+ /* 回落到内存值 */
368
+ }
369
+ }
370
+ return live.targets;
371
+ };
372
+ /* ---------- 目标解析与 DockerApi ---------- */
373
+ /** 目标名 → 解析结果(每次现算,连接簿热改立即生效)。 */
374
+ const resolveByName = (name) => {
375
+ const target = targetsNow().find((item) => item.name === name);
376
+ if (target === undefined) {
377
+ const names = targetsNow().map((item) => item.name).join('、');
378
+ return { error: `未知目标:${name}${names === '' ? '(尚未配置任何目标)' : `(已配置:${names})`}` };
379
+ }
380
+ return resolveTarget(target, readTtyBooks(settingsApi));
381
+ };
382
+ /** 取某个目标上的 DockerApi(Runner 每次新建,连接由 RemoteExec 池化)。 */
383
+ const apiFor = (name) => {
384
+ const { resolved, error } = resolveByName(name);
385
+ if (resolved === undefined)
386
+ return { error };
387
+ try {
388
+ const runner = createRunner({ target: resolved, remote, logger });
389
+ return { api: new DockerApi(runner, live.dockerBin, { timeoutMs: 30_000, maxBytes: live.maxOutputKb * 1024 }), resolved };
390
+ }
391
+ catch (error_) {
392
+ return { error: error_ instanceof Error ? error_.message : String(error_) };
393
+ }
394
+ };
395
+ /** 单目标省略 target 参数时的默认目标。 */
396
+ const defaultTargetName = () => {
397
+ const list = targetsNow();
398
+ return list.length === 1 ? list[0]?.name : undefined;
399
+ };
400
+ /** 解析工具/路由里的 target 参数。 */
401
+ const pickTarget = (input) => {
402
+ if (typeof input === 'string' && input.trim() !== '')
403
+ return { name: input.trim() };
404
+ const fallback = defaultTargetName();
405
+ if (fallback !== undefined)
406
+ return { name: fallback };
407
+ const list = targetsNow();
408
+ if (list.length === 0)
409
+ return { error: '尚未配置任何 Docker 目标(设置 → 插件 → Docker 容器面板)' };
410
+ return { error: 'target 必填(已配置多个目标:' + list.map((item) => item.name).join('、') + ')' };
411
+ };
412
+ /* ---------- 配置快照(凭证不外泄) ---------- */
413
+ const snapshotTarget = (target) => ({
414
+ name: target.name,
415
+ kind: target.kind,
416
+ book: target.book ?? '',
417
+ host: target.host ?? '',
418
+ port: target.port ?? 22,
419
+ username: target.username ?? '',
420
+ auth: target.auth ?? 'agent',
421
+ keyPath: target.keyPath ?? '',
422
+ agentForward: target.agentForward === true,
423
+ // 密码 / 口令只回「是否已设置」,值永不回传浏览器
424
+ passwordSet: typeof target.password === 'string' && target.password !== '',
425
+ passphraseSet: typeof target.passphrase === 'string' && target.passphrase !== '',
426
+ });
427
+ const snapshot = () => {
428
+ const books = readTtyBooks(settingsApi);
429
+ return {
430
+ enabled: live.enabled,
431
+ announceToAgent: live.announceToAgent,
432
+ dockerBin: live.dockerBin,
433
+ allowMutations: live.allowMutations,
434
+ allowExec: live.allowExec,
435
+ execTimeoutSec: live.execTimeoutSec,
436
+ pollIntervalSec: live.pollIntervalSec,
437
+ logTailDefault: live.logTailDefault,
438
+ maxOutputKb: live.maxOutputKb,
439
+ targets: targetsNow().map(snapshotTarget),
440
+ hostKeys: live.hostKeys,
441
+ // 只读复用 tty 连接簿:卡片用它渲染「从连接簿选择」下拉
442
+ ttyBooks: [...books.keys()],
443
+ ttyAvailable: books.size > 0,
444
+ toolsRegistered: registeredNames,
445
+ };
446
+ };
447
+ /* ---------- 配置热应用 ---------- */
448
+ const applySection = (section) => {
449
+ // hostKeys 只在显式传入时覆盖(避免把 TOFU 运行期新增的记录冲掉)
450
+ const merged = { ...live, ...section };
451
+ if (section.hostKeys === undefined)
452
+ merged.hostKeys = live.hostKeys;
453
+ live = normalizeConfig(merged);
454
+ refreshTools();
455
+ console.log(`[dsh-docker] config applied (bin=${live.dockerBin}, targets=${String(live.targets.length)}, allowMutations=${String(live.allowMutations)}, allowExec=${String(live.allowExec)})`);
456
+ };
457
+ /* ---------- agent 工具 ---------- */
458
+ let toolsApi;
459
+ let toolDisposers = [];
460
+ let registeredNames = [];
461
+ const renderTargets = (rows) => {
462
+ if (rows.length === 0)
463
+ return '尚未配置任何 Docker 目标(设置 → 插件 → Docker 容器面板 → 目标)。';
464
+ return 'Docker 目标:' + rows.map((row) => {
465
+ const state = row.ok === undefined ? '' : row.ok ? ' [可达]' : ` [不可用:${row.error ?? '未知'}]`;
466
+ return `\n- ${row.name} (${row.kind}) ${row.label}${state}`;
467
+ }).join('');
468
+ };
469
+ const renderContainers = (target, rows) => {
470
+ if (rows.length === 0)
471
+ return `目标 ${target}:没有容器。`;
472
+ return `目标 ${target} 的容器(${String(rows.length)} 个):` + rows.map((row) => {
473
+ const ports = row.ports.length === 0
474
+ ? ''
475
+ : ' ports=' + row.ports.map((p) => (p.hostPort === undefined ? `${String(p.containerPort)}/${p.protocol}` : `${String(p.hostPort)}→${String(p.containerPort)}/${p.protocol}`)).join(',');
476
+ const health = row.health === null ? '' : ` health=${row.health}`;
477
+ const compose = row.composeProject === null ? '' : ` compose=${row.composeProject}/${row.composeService ?? '-'}`;
478
+ return `\n- ${row.name} [${row.state}]${health} image=${row.image}${ports}${compose} id=${row.shortId}`;
479
+ }).join('');
480
+ };
481
+ const renderStats = (target, rows) => {
482
+ if (rows.length === 0)
483
+ return `目标 ${target}:没有运行中的容器。`;
484
+ return `目标 ${target} 的资源占用:` + rows.map((row) => `\n- ${row.name} cpu=${row.cpuPercent === null ? '?' : String(row.cpuPercent) + '%'} mem=${row.memUsage} (${row.memPercent === null ? '?' : String(row.memPercent) + '%'}) net=${row.netIO} block=${row.blockIO} pids=${row.pids === null ? '?' : String(row.pids)}`).join('');
485
+ };
486
+ const renderDetail = (detail) => {
487
+ const lines = [
488
+ `容器 ${detail.name}(${detail.shortId})`,
489
+ `- 状态:${detail.state}${detail.health === null ? '' : ' / ' + detail.health}${detail.status === '' ? '' : '(' + detail.status + ')'}`,
490
+ `- 镜像:${detail.image}`,
491
+ `- 启动:${detail.startedAt ?? '—'} 结束:${detail.finishedAt ?? '—'}`,
492
+ `- 退出码:${detail.exitCode === null ? '—' : String(detail.exitCode)} 重启次数:${detail.restartCount === null ? '—' : String(detail.restartCount)} 重启策略:${detail.restartPolicy ?? '—'}`,
493
+ `- 端口:${detail.ports.length === 0 ? '—' : detail.ports.map((p) => (p.hostPort === undefined ? `${String(p.containerPort)}/${p.protocol}` : `${p.hostIp ?? ''}:${String(p.hostPort)}→${String(p.containerPort)}/${p.protocol}`)).join(', ')}`,
494
+ `- 挂载:${detail.mounts.length === 0 ? '—' : detail.mounts.map((m) => `${m.source}→${m.destination}${m.readWrite ? '' : '(ro)'}`).join(', ')}`,
495
+ `- 网络:${detail.networks.length === 0 ? '—' : detail.networks.map((n) => `${n.name}${n.ip === null ? '' : '(' + n.ip + ')'}`).join(', ')}`,
496
+ `- 命令:${detail.entrypoint}${detail.command === '' ? '' : ' ' + detail.command}`,
497
+ ];
498
+ if (detail.healthLogTail !== null)
499
+ lines.push(`- 最近健康检查:${detail.healthLogTail}`);
500
+ return lines.join('\n');
501
+ };
502
+ const renderImages = (target, rows) => {
503
+ if (rows.length === 0)
504
+ return `目标 ${target}:没有镜像。`;
505
+ return `目标 ${target} 的镜像(${String(rows.length)} 个):` + rows.map((row) => `\n- ${row.reference} ${row.sizeText}${row.createdSince === '' ? '' : ' (' + row.createdSince + ')'} id=${row.shortId}`).join('');
506
+ };
507
+ /** 重新注册 agent 工具(能力开关变化时调用;幂等)。 */
508
+ const refreshTools = () => {
509
+ for (const dispose of toolDisposers) {
510
+ try {
511
+ dispose();
512
+ }
513
+ catch {
514
+ /* 工具已注销 */
515
+ }
516
+ }
517
+ toolDisposers = [];
518
+ registeredNames = [];
519
+ const tools = toolsApi;
520
+ if (tools === undefined)
521
+ return;
522
+ const targetParam = { type: 'string', description: '目标名(docker_targets 列出;只有一个目标时可省略)' };
523
+ const add = (toolName, definition) => {
524
+ toolDisposers.push(tools.register(definition));
525
+ registeredNames.push(toolName);
526
+ };
527
+ add('docker_targets', defineTool({
528
+ name: 'docker_targets',
529
+ description: '列出已配置的 Docker 目标(本机 / SSH 主机),可选探测每个目标的 docker daemon 是否可达。其他 docker_* 工具的 target 参数取自这里。',
530
+ parameters: { probe: { type: 'boolean', description: 'true 时逐个探测 docker 版本与 daemon 可达性(SSH 目标会建连接,较慢)' } },
531
+ output: {
532
+ schema: {
533
+ type: 'object',
534
+ additionalProperties: false,
535
+ properties: {
536
+ targets: {
537
+ type: 'array',
538
+ required: true,
539
+ items: {
540
+ type: 'object',
541
+ additionalProperties: false,
542
+ properties: {
543
+ name: { type: 'string', required: true },
544
+ kind: { type: 'string', required: true },
545
+ label: { type: 'string', required: true },
546
+ ok: { type: 'boolean' },
547
+ error: { type: 'string' },
548
+ },
549
+ },
550
+ },
551
+ },
552
+ },
553
+ render: (_args, value) => {
554
+ const rows = value.targets ?? [];
555
+ return [{ type: 'text', text: renderTargets(rows) }];
556
+ },
557
+ },
558
+ async execute(args) {
559
+ const input = (args ?? {});
560
+ const books = readTtyBooks(settingsApi);
561
+ const rows = [];
562
+ for (const target of targetsNow()) {
563
+ const { resolved, error } = resolveTarget(target, books);
564
+ if (resolved === undefined) {
565
+ rows.push({ name: target.name, kind: target.kind, label: '解析失败', ok: false, error: error ?? '未知错误' });
566
+ continue;
567
+ }
568
+ const label = resolved.kind === 'local' ? '本机' : sshTarget(resolved.spec);
569
+ if (input.probe !== true) {
570
+ rows.push({ name: target.name, kind: target.kind, label });
571
+ continue;
572
+ }
573
+ const { api } = apiFor(target.name);
574
+ if (api === undefined) {
575
+ rows.push({ name: target.name, kind: target.kind, label, ok: false, error: '无法构造执行通道' });
576
+ continue;
577
+ }
578
+ const probe = await api.probe();
579
+ rows.push({ name: target.name, kind: target.kind, label, ok: probe.ok, ...(probe.ok ? {} : { error: probe.error ?? '未知错误' }) });
580
+ }
581
+ return { targets: rows };
582
+ },
583
+ }));
584
+ add('docker_ps', defineTool({
585
+ name: 'docker_ps',
586
+ description: '列出某个目标上的容器(默认只列运行中的;all:true 含已停止)。返回名称/状态/健康/镜像/端口/compose 项目/短 ID。排障第一步。',
587
+ parameters: { target: targetParam, all: { type: 'boolean', description: 'true 时包含已停止容器(默认 false)' } },
588
+ output: {
589
+ schema: {
590
+ type: 'object',
591
+ additionalProperties: false,
592
+ properties: {
593
+ target: { type: 'string', required: true },
594
+ containers: {
595
+ type: 'array',
596
+ required: true,
597
+ items: {
598
+ type: 'object',
599
+ additionalProperties: false,
600
+ properties: {
601
+ id: { type: 'string', required: true },
602
+ name: { type: 'string', required: true },
603
+ image: { type: 'string', required: true },
604
+ state: { type: 'string', required: true },
605
+ status: { type: 'string', required: true },
606
+ health: { type: 'string' },
607
+ ports: { type: 'string' },
608
+ composeProject: { type: 'string' },
609
+ composeService: { type: 'string' },
610
+ },
611
+ },
612
+ },
613
+ },
614
+ },
615
+ render: (_args, value) => {
616
+ const v = value;
617
+ return [{ type: 'text', text: renderContainers(v.target ?? '?', v.containers ?? []) }];
618
+ },
619
+ },
620
+ async execute(args) {
621
+ const input = (args ?? {});
622
+ const picked = pickTarget(input.target);
623
+ if (picked.name === undefined)
624
+ throw new Error(picked.error ?? '无效的 target');
625
+ const { api } = apiFor(picked.name);
626
+ if (api === undefined)
627
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
628
+ const containers = await api.listContainers(input.all === true);
629
+ return {
630
+ target: picked.name,
631
+ containers: containers.map((row) => ({
632
+ id: row.id,
633
+ name: row.name,
634
+ image: row.image,
635
+ state: row.state,
636
+ status: row.status,
637
+ ...(row.health === null ? {} : { health: row.health }),
638
+ ports: row.ports.map((p) => (p.hostPort === undefined ? `${String(p.containerPort)}/${p.protocol}` : `${String(p.hostPort)}→${String(p.containerPort)}/${p.protocol}`)).join(','),
639
+ ...(row.composeProject === null ? {} : { composeProject: row.composeProject }),
640
+ ...(row.composeService === null ? {} : { composeService: row.composeService }),
641
+ })),
642
+ };
643
+ },
644
+ }));
645
+ add('docker_inspect', defineTool({
646
+ name: 'docker_inspect',
647
+ description: '读取某个容器的权威详情(docker inspect):状态/健康检查/退出码/重启次数/端口映射/挂载/网络/启动命令。',
648
+ parameters: { target: targetParam, id: { type: 'string', required: true, description: '容器名或 ID(来自 docker_ps)' } },
649
+ output: {
650
+ schema: {
651
+ type: 'object',
652
+ additionalProperties: false,
653
+ properties: {
654
+ target: { type: 'string', required: true },
655
+ detail: { type: 'string', required: true },
656
+ },
657
+ },
658
+ render: (_args, value) => {
659
+ const v = value;
660
+ return [{ type: 'text', text: v.detail ?? '' }];
661
+ },
662
+ },
663
+ async execute(args) {
664
+ const input = (args ?? {});
665
+ const picked = pickTarget(input.target);
666
+ if (picked.name === undefined)
667
+ throw new Error(picked.error ?? '无效的 target');
668
+ if (typeof input.id !== 'string')
669
+ throw new Error('id 必填');
670
+ const { api } = apiFor(picked.name);
671
+ if (api === undefined)
672
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
673
+ const details = await api.inspect([input.id]);
674
+ const detail = details[0];
675
+ if (detail === undefined)
676
+ throw new Error(`容器不存在:${input.id}`);
677
+ return { target: picked.name, detail: renderDetail(detail) };
678
+ },
679
+ }));
680
+ add('docker_logs', defineTool({
681
+ name: 'docker_logs',
682
+ description: '读取某个容器的日志尾部(docker logs --tail)。默认 200 行、不带时间戳;可加 timestamps / since。日志可能很大,优先用 tail 而不是全量。',
683
+ parameters: {
684
+ target: targetParam,
685
+ id: { type: 'string', required: true, description: '容器名或 ID' },
686
+ tail: { type: 'number', description: '尾部行数(1~5000,默认 200)' },
687
+ timestamps: { type: 'boolean', description: 'true 时每行带时间戳' },
688
+ since: { type: 'string', description: '起始时间(docker --since 语法,如 10m、2026-09-09T10:00:00)' },
689
+ },
690
+ output: {
691
+ schema: {
692
+ type: 'object',
693
+ additionalProperties: false,
694
+ properties: {
695
+ target: { type: 'string', required: true },
696
+ id: { type: 'string', required: true },
697
+ text: { type: 'string', required: true },
698
+ truncated: { type: 'boolean' },
699
+ },
700
+ },
701
+ render: (_args, value) => {
702
+ const v = value;
703
+ const head = `容器 ${v.id ?? '?'}(目标 ${v.target ?? '?'})日志${v.truncated === true ? '(输出已截断)' : ''}:\n\n`;
704
+ return [{ type: 'text', text: head + (v.text === undefined || v.text === '' ? '(无日志)' : v.text) }];
705
+ },
706
+ },
707
+ async execute(args) {
708
+ const input = (args ?? {});
709
+ const picked = pickTarget(input.target);
710
+ if (picked.name === undefined)
711
+ throw new Error(picked.error ?? '无效的 target');
712
+ if (typeof input.id !== 'string')
713
+ throw new Error('id 必填');
714
+ const { api } = apiFor(picked.name);
715
+ if (api === undefined)
716
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
717
+ const result = await api.logs(input.id, {
718
+ tail: typeof input.tail === 'number' && Number.isInteger(input.tail) ? input.tail : live.logTailDefault,
719
+ timestamps: input.timestamps === true,
720
+ ...(typeof input.since === 'string' ? { since: input.since } : {}),
721
+ });
722
+ return { target: picked.name, id: result.id, text: result.text, truncated: result.truncated };
723
+ },
724
+ }));
725
+ add('docker_stats', defineTool({
726
+ name: 'docker_stats',
727
+ description: '读取容器实时资源占用(docker stats --no-stream):CPU%、内存用量/上限、网络与磁盘 IO、PIDs。不传 ids 时返回该目标上全部运行中容器。',
728
+ parameters: {
729
+ target: targetParam,
730
+ ids: { type: 'string', description: '容器名/ID,逗号分隔(省略 = 全部运行中)' },
731
+ },
732
+ output: {
733
+ schema: {
734
+ type: 'object',
735
+ additionalProperties: false,
736
+ properties: {
737
+ target: { type: 'string', required: true },
738
+ stats: {
739
+ type: 'array',
740
+ required: true,
741
+ items: {
742
+ type: 'object',
743
+ additionalProperties: false,
744
+ properties: {
745
+ id: { type: 'string', required: true },
746
+ name: { type: 'string', required: true },
747
+ cpuPercent: { type: 'number' },
748
+ memUsage: { type: 'string', required: true },
749
+ memPercent: { type: 'number' },
750
+ netIO: { type: 'string', required: true },
751
+ blockIO: { type: 'string', required: true },
752
+ pids: { type: 'number' },
753
+ },
754
+ },
755
+ },
756
+ },
757
+ },
758
+ render: (_args, value) => {
759
+ const v = value;
760
+ return [{ type: 'text', text: renderStats(v.target ?? '?', v.stats ?? []) }];
761
+ },
762
+ },
763
+ async execute(args) {
764
+ const input = (args ?? {});
765
+ const picked = pickTarget(input.target);
766
+ if (picked.name === undefined)
767
+ throw new Error(picked.error ?? '无效的 target');
768
+ const ids = typeof input.ids === 'string'
769
+ ? input.ids.split(',').map((id) => id.trim()).filter((id) => id !== '')
770
+ : [];
771
+ const { api } = apiFor(picked.name);
772
+ if (api === undefined)
773
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
774
+ const stats = await api.stats(ids);
775
+ return {
776
+ target: picked.name,
777
+ stats: stats.map((row) => ({
778
+ id: row.id,
779
+ name: row.name,
780
+ ...(row.cpuPercent === null ? {} : { cpuPercent: row.cpuPercent }),
781
+ memUsage: row.memUsage,
782
+ ...(row.memPercent === null ? {} : { memPercent: row.memPercent }),
783
+ netIO: row.netIO,
784
+ blockIO: row.blockIO,
785
+ ...(row.pids === null ? {} : { pids: row.pids }),
786
+ })),
787
+ };
788
+ },
789
+ }));
790
+ add('docker_images', defineTool({
791
+ name: 'docker_images',
792
+ description: '列出某个目标上的镜像(仓库:标签、大小、创建时间、短 ID)。',
793
+ parameters: { target: targetParam },
794
+ output: {
795
+ schema: {
796
+ type: 'object',
797
+ additionalProperties: false,
798
+ properties: {
799
+ target: { type: 'string', required: true },
800
+ images: {
801
+ type: 'array',
802
+ required: true,
803
+ items: {
804
+ type: 'object',
805
+ additionalProperties: false,
806
+ properties: {
807
+ reference: { type: 'string', required: true },
808
+ sizeText: { type: 'string', required: true },
809
+ createdSince: { type: 'string' },
810
+ id: { type: 'string', required: true },
811
+ },
812
+ },
813
+ },
814
+ },
815
+ },
816
+ render: (_args, value) => {
817
+ const v = value;
818
+ return [{ type: 'text', text: renderImages(v.target ?? '?', v.images ?? []) }];
819
+ },
820
+ },
821
+ async execute(args) {
822
+ const input = (args ?? {});
823
+ const picked = pickTarget(input.target);
824
+ if (picked.name === undefined)
825
+ throw new Error(picked.error ?? '无效的 target');
826
+ const { api } = apiFor(picked.name);
827
+ if (api === undefined)
828
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
829
+ const images = await api.images();
830
+ return {
831
+ target: picked.name,
832
+ images: images.map((row) => ({
833
+ reference: row.reference,
834
+ sizeText: row.sizeText,
835
+ ...(row.createdSince === '' ? {} : { createdSince: row.createdSince }),
836
+ id: row.shortId,
837
+ })),
838
+ };
839
+ },
840
+ }));
841
+ if (live.allowMutations) {
842
+ add('docker_action', defineTool({
843
+ name: 'docker_action',
844
+ description: '对容器执行生命周期操作:start / stop / restart / remove。**破坏性**:remove 会删除容器(数据卷不在其中,但容器配置与可写层丢失),执行前必须向用户确认目标容器。仅当用户在设置里打开「允许变更操作」时可用。',
845
+ parameters: {
846
+ target: targetParam,
847
+ action: { type: 'string', required: true, description: 'start | stop | restart | remove' },
848
+ id: { type: 'string', required: true, description: '容器名或 ID' },
849
+ },
850
+ output: {
851
+ schema: {
852
+ type: 'object',
853
+ additionalProperties: false,
854
+ properties: {
855
+ target: { type: 'string', required: true },
856
+ id: { type: 'string', required: true },
857
+ action: { type: 'string', required: true },
858
+ message: { type: 'string', required: true },
859
+ },
860
+ },
861
+ render: (_args, value) => {
862
+ const v = value;
863
+ return [{ type: 'text', text: `${v.action ?? '?'} ${v.id ?? '?'}(目标 ${v.target ?? '?'}):${v.message ?? 'ok'}` }];
864
+ },
865
+ },
866
+ async execute(args) {
867
+ if (!live.allowMutations)
868
+ throw new Error('变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)');
869
+ const input = (args ?? {});
870
+ const picked = pickTarget(input.target);
871
+ if (picked.name === undefined)
872
+ throw new Error(picked.error ?? '无效的 target');
873
+ if (typeof input.id !== 'string')
874
+ throw new Error('id 必填');
875
+ const action = input.action;
876
+ if (action !== 'start' && action !== 'stop' && action !== 'restart' && action !== 'remove') {
877
+ throw new Error('action 必须是 start / stop / restart / remove');
878
+ }
879
+ const { api } = apiFor(picked.name);
880
+ if (api === undefined)
881
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
882
+ const result = await api.action({ action, id: input.id });
883
+ return { target: picked.name, id: result.id, action: result.action, message: result.message };
884
+ },
885
+ }));
886
+ }
887
+ if (live.allowExec) {
888
+ add('docker_exec', defineTool({
889
+ name: 'docker_exec',
890
+ description: '在容器内执行一条一次性命令(docker exec,无 TTY):如 `ls -la /app`、`cat /etc/nginx/nginx.conf`、`env`。仅当用户在设置里打开「允许 exec」时可用;不要在容器里做破坏性操作,交互式排障请让用户到终端面板执行 `docker exec -it <容器> sh`。',
891
+ parameters: {
892
+ target: targetParam,
893
+ id: { type: 'string', required: true, description: '容器名或 ID' },
894
+ command: { type: 'string', required: true, description: '要执行的命令(经容器内 sh -c 执行)' },
895
+ timeoutSec: { type: 'number', description: '超时秒数(1~120,默认取插件配置)' },
896
+ },
897
+ output: {
898
+ schema: {
899
+ type: 'object',
900
+ additionalProperties: false,
901
+ properties: {
902
+ target: { type: 'string', required: true },
903
+ id: { type: 'string', required: true },
904
+ code: { type: 'number' },
905
+ stdout: { type: 'string', required: true },
906
+ stderr: { type: 'string', required: true },
907
+ truncated: { type: 'boolean' },
908
+ },
909
+ },
910
+ render: (_args, value) => {
911
+ const v = value;
912
+ const parts = [`容器 ${v.id ?? '?'} 退出码 ${v.code === undefined ? '?' : String(v.code)}`];
913
+ if ((v.stdout ?? '') !== '')
914
+ parts.push('stdout:\n' + (v.stdout ?? ''));
915
+ if ((v.stderr ?? '') !== '')
916
+ parts.push('stderr:\n' + (v.stderr ?? ''));
917
+ if (v.truncated === true)
918
+ parts.push('(输出已截断)');
919
+ return [{ type: 'text', text: parts.join('\n\n') }];
920
+ },
921
+ },
922
+ async execute(args) {
923
+ if (!live.allowExec)
924
+ throw new Error('exec 未启用(设置 → 插件 → Docker 容器面板 → 允许 exec)');
925
+ const input = (args ?? {});
926
+ const picked = pickTarget(input.target);
927
+ if (picked.name === undefined)
928
+ throw new Error(picked.error ?? '无效的 target');
929
+ if (typeof input.id !== 'string')
930
+ throw new Error('id 必填');
931
+ if (typeof input.command !== 'string' || input.command.trim() === '')
932
+ throw new Error('command 必填');
933
+ const timeoutSec = typeof input.timeoutSec === 'number' && Number.isInteger(input.timeoutSec) ? input.timeoutSec : live.execTimeoutSec;
934
+ const { api } = apiFor(picked.name);
935
+ if (api === undefined)
936
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
937
+ const result = await api.exec(input.id, input.command, timeoutSec * 1000);
938
+ return {
939
+ target: picked.name,
940
+ id: result.id,
941
+ ...(result.code === null ? {} : { code: result.code }),
942
+ stdout: result.stdout,
943
+ stderr: result.stderr,
944
+ truncated: result.truncated,
945
+ };
946
+ },
947
+ }));
948
+ }
949
+ };
950
+ // tools 服务(可选):拿到后注册一次,能力开关变化时 refreshTools 重注册
951
+ ctx.inject(['tools'], (toolsCtx) => {
952
+ toolsCtx.effect(() => {
953
+ toolsApi = toolsCtx.tools;
954
+ refreshTools();
955
+ console.log('[dsh-docker] agent tools registered (' + registeredNames.join(', ') + ')');
956
+ return () => {
957
+ toolsApi = undefined;
958
+ for (const dispose of toolDisposers) {
959
+ try {
960
+ dispose();
961
+ }
962
+ catch {
963
+ /* 工具已注销 */
964
+ }
965
+ }
966
+ toolDisposers = [];
967
+ registeredNames = [];
968
+ };
969
+ }, 'dsh-docker: agent tools');
970
+ });
971
+ /* ---------- HTTP 路由(loopback 围栏) ---------- */
972
+ ctx.inject(['webServer'], (webCtx) => {
973
+ webCtx.effect(() => {
974
+ const webServer = webCtx.webServer;
975
+ const dispose = webServer.register({
976
+ kind: 'prefix',
977
+ path: ROUTE_PREFIX,
978
+ handler: async (req, res) => {
979
+ if (!isLoopbackHttp(req)) {
980
+ writeJson(res, 403, { error: 'forbidden: loopback-only' });
981
+ return;
982
+ }
983
+ const sub = new URL(req.url ?? '/', 'http://loopback').pathname.slice(ROUTE_PREFIX.length);
984
+ if (sub === '/config') {
985
+ if (req.method === 'GET') {
986
+ writeJson(res, 200, { ok: true, config: snapshot() });
987
+ return;
988
+ }
989
+ if (req.method !== 'POST') {
990
+ writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
991
+ return;
992
+ }
993
+ const body = await readJsonBody(req);
994
+ if (body === undefined) {
995
+ writeJson(res, 400, { error: 'invalid JSON body' });
996
+ return;
997
+ }
998
+ const patch = {};
999
+ let warning;
1000
+ for (const key of Object.keys(body)) {
1001
+ if (!KNOWN_CONFIG_KEYS.has(key)) {
1002
+ writeJson(res, 400, { error: '未知配置项: ' + key });
1003
+ return;
1004
+ }
1005
+ if (key === 'clearTargets')
1006
+ continue;
1007
+ patch[key] = body[key];
1008
+ }
1009
+ // 空 targets 只在显式 clearTargets 时才允许清空:卡片若因启动竞态拿到
1010
+ // 空列表,保存不会再把已配置的目标抹掉(防数据丢失)
1011
+ if (Array.isArray(patch.targets) && patch.targets.length === 0 && body.clearTargets !== true) {
1012
+ const existing = targetsNow();
1013
+ if (existing.length > 0) {
1014
+ delete patch.targets;
1015
+ warning = `已忽略空的目标列表:当前仍有 ${String(existing.length)} 个目标。请刷新设置卡片后重试(若确实要清空全部目标,请逐个删除后保存)。`;
1016
+ }
1017
+ }
1018
+ // 凭证补全必须在写盘之前:否则提交的 targets 会把密码清空
1019
+ if (patch.targets !== undefined)
1020
+ patch.targets = mergeTargetSecrets(targetsNow(), patch.targets);
1021
+ const scope = settingsScope;
1022
+ if (scope !== undefined) {
1023
+ try {
1024
+ await scope.update(patch);
1025
+ }
1026
+ catch (error) {
1027
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1028
+ return;
1029
+ }
1030
+ // settings/updated 会触发 applySection;无事件时也应用一次(幂等)
1031
+ }
1032
+ applySection(patch);
1033
+ writeJson(res, 200, { ok: true, config: snapshot(), ...(warning === undefined ? {} : { warning }) });
1034
+ return;
1035
+ }
1036
+ if (sub === '/targets') {
1037
+ if (req.method !== 'GET' && req.method !== 'POST') {
1038
+ writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
1039
+ return;
1040
+ }
1041
+ const books = readTtyBooks(settingsApi);
1042
+ writeJson(res, 200, {
1043
+ ok: true,
1044
+ targets: targetsNow().map((target) => {
1045
+ const { resolved, error } = resolveTarget(target, books);
1046
+ return {
1047
+ name: target.name,
1048
+ kind: target.kind,
1049
+ ...(resolved === undefined ? { error } : { label: resolved.kind === 'local' ? '本机' : sshTarget(resolved.spec) }),
1050
+ };
1051
+ }),
1052
+ });
1053
+ return;
1054
+ }
1055
+ if (req.method !== 'POST') {
1056
+ writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
1057
+ return;
1058
+ }
1059
+ const body = await readJsonBody(req);
1060
+ if (body === undefined) {
1061
+ writeJson(res, 400, { error: 'invalid JSON body' });
1062
+ return;
1063
+ }
1064
+ const picked = pickTarget(body.target);
1065
+ if (picked.name === undefined) {
1066
+ writeJson(res, 400, { error: picked.error ?? '无效的 target' });
1067
+ return;
1068
+ }
1069
+ const built = apiFor(picked.name);
1070
+ if (built.api === undefined) {
1071
+ writeJson(res, 400, { error: built.error ?? '无法构造执行通道' });
1072
+ return;
1073
+ }
1074
+ const api = built.api;
1075
+ try {
1076
+ switch (sub) {
1077
+ case '/probe': {
1078
+ writeJson(res, 200, { ok: true, probe: await api.probe() });
1079
+ return;
1080
+ }
1081
+ case '/containers': {
1082
+ writeJson(res, 200, { ok: true, containers: await api.listContainers(body.all === true) });
1083
+ return;
1084
+ }
1085
+ case '/inspect': {
1086
+ if (typeof body.id !== 'string') {
1087
+ writeJson(res, 400, { error: 'id 必填' });
1088
+ return;
1089
+ }
1090
+ writeJson(res, 200, { ok: true, details: await api.inspect([body.id]) });
1091
+ return;
1092
+ }
1093
+ case '/stats': {
1094
+ const ids = Array.isArray(body.ids) ? body.ids.filter((id) => typeof id === 'string') : [];
1095
+ writeJson(res, 200, { ok: true, stats: await api.stats(ids) });
1096
+ return;
1097
+ }
1098
+ case '/logs': {
1099
+ if (typeof body.id !== 'string') {
1100
+ writeJson(res, 400, { error: 'id 必填' });
1101
+ return;
1102
+ }
1103
+ const tail = typeof body.tail === 'number' && Number.isInteger(body.tail) ? body.tail : live.logTailDefault;
1104
+ writeJson(res, 200, {
1105
+ ok: true,
1106
+ logs: await api.logs(body.id, {
1107
+ tail,
1108
+ timestamps: body.timestamps === true,
1109
+ ...(typeof body.since === 'string' ? { since: body.since } : {}),
1110
+ }),
1111
+ });
1112
+ return;
1113
+ }
1114
+ case '/images': {
1115
+ writeJson(res, 200, { ok: true, images: await api.images() });
1116
+ return;
1117
+ }
1118
+ case '/action': {
1119
+ if (!live.allowMutations) {
1120
+ writeJson(res, 403, { error: '变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)' });
1121
+ return;
1122
+ }
1123
+ if (typeof body.id !== 'string') {
1124
+ writeJson(res, 400, { error: 'id 必填' });
1125
+ return;
1126
+ }
1127
+ const action = body.action;
1128
+ if (action !== 'start' && action !== 'stop' && action !== 'restart' && action !== 'remove') {
1129
+ writeJson(res, 400, { error: 'action 必须是 start / stop / restart / remove' });
1130
+ return;
1131
+ }
1132
+ writeJson(res, 200, { ok: true, result: await api.action({ action, id: body.id }) });
1133
+ return;
1134
+ }
1135
+ case '/exec': {
1136
+ if (!live.allowExec) {
1137
+ writeJson(res, 403, { error: 'exec 未启用(设置 → 插件 → Docker 容器面板 → 允许 exec)' });
1138
+ return;
1139
+ }
1140
+ if (typeof body.id !== 'string' || typeof body.command !== 'string') {
1141
+ writeJson(res, 400, { error: 'id 与 command 必填' });
1142
+ return;
1143
+ }
1144
+ const timeoutSec = typeof body.timeoutSec === 'number' && Number.isInteger(body.timeoutSec) ? body.timeoutSec : live.execTimeoutSec;
1145
+ writeJson(res, 200, { ok: true, result: await api.exec(body.id, body.command, timeoutSec * 1000) });
1146
+ return;
1147
+ }
1148
+ default: {
1149
+ writeJson(res, 404, { error: 'unknown route: ' + sub });
1150
+ }
1151
+ }
1152
+ }
1153
+ catch (error) {
1154
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
1155
+ }
1156
+ },
1157
+ });
1158
+ return () => dispose();
1159
+ }, 'dsh-docker: web routes');
1160
+ });
1161
+ /* ---------- settings 命名空间 ---------- */
1162
+ ctx.inject(['settings'], (settingsCtx) => {
1163
+ settingsCtx.effect(() => {
1164
+ const settings = settingsCtx.settings;
1165
+ const scope = settings.register('docker', DOCKER_SETTINGS_SCHEMA, { base: live });
1166
+ settingsApi = settings;
1167
+ settingsScope = scope;
1168
+ // 注册后立即读一次解析值(schema 默认值 ← composition base ← 用户层)
1169
+ const resolved = scope.get();
1170
+ if (typeof resolved === 'object' && resolved !== null)
1171
+ live = normalizeConfig(resolved);
1172
+ const diag = (resolved ?? {});
1173
+ console.log(`[dsh-docker] settings resolved (keys=${Object.keys(diag).join('|')}, targets=${Array.isArray(diag.targets) ? String(diag.targets.length) : 'not-array'}, hostKeys=${Array.isArray(diag.hostKeys) ? String(diag.hostKeys.length) : 'not-array'})`);
1174
+ refreshTools();
1175
+ const events = settingsCtx;
1176
+ const off = events.events.on('settings/updated', (ns, next) => {
1177
+ if (ns !== 'docker' || typeof next !== 'object' || next === null)
1178
+ return;
1179
+ applySection(next);
1180
+ });
1181
+ return () => {
1182
+ off();
1183
+ settingsScope = undefined;
1184
+ settingsApi = undefined;
1185
+ };
1186
+ }, 'dsh-docker: settings');
1187
+ });
1188
+ /* ---------- 能力公告 ---------- */
1189
+ if (live.announceToAgent) {
1190
+ ctx.inject(['systemPrompt'], (promptCtx) => {
1191
+ promptCtx.effect(() => {
1192
+ const systemPrompt = promptCtx.systemPrompt;
1193
+ const dispose = systemPrompt.section({ name: 'plugin:dsh-docker', order: 152, text: DOCKER_GUIDANCE });
1194
+ return () => dispose();
1195
+ }, 'dsh-docker: announcement');
1196
+ });
1197
+ }
1198
+ /* ---------- 卸载清理 ---------- */
1199
+ ctx.effect(() => {
1200
+ return () => {
1201
+ remote.disposeAll();
1202
+ };
1203
+ }, 'dsh-docker: cleanup');
1204
+ console.log(`[dsh-docker] mounted (bin=${live.dockerBin}, targets=${String(live.targets.length)}, allowMutations=${String(live.allowMutations)}, allowExec=${String(live.allowExec)})`);
1205
+ },
1206
+ });
1207
+ export const { name, inject, apply } = plugin;
1208
+ /* ------------------------------------------------------------------ *
1209
+ * 供 scripts/smoke.mjs 直接复用的纯函数(解析器回归)
1210
+ * ------------------------------------------------------------------ */
1211
+ export { parsePsJson, parseStatsJson, parseInspectJson, assertBin, DockerApi };
1212
+ //# sourceMappingURL=index.js.map