@hyzyn/dsh-docker 0.13.7 → 0.15.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 +665 -783
- package/README.md +568 -788
- package/client-src/docker.css +477 -0
- package/client-src/index.js +1562 -12
- package/client.js +505 -25
- package/lib/diagnose.d.ts +148 -0
- package/lib/diagnose.js +571 -0
- package/lib/diagnose.js.map +1 -0
- package/lib/docker.d.ts +257 -2
- package/lib/docker.js +464 -4
- package/lib/docker.js.map +1 -1
- package/lib/history-store.d.ts +48 -0
- package/lib/history-store.js +187 -0
- package/lib/history-store.js.map +1 -0
- package/lib/history.d.ts +110 -0
- package/lib/history.js +271 -0
- package/lib/history.js.map +1 -0
- package/lib/index.d.ts +13 -0
- package/lib/index.js +796 -6
- package/lib/index.js.map +1 -1
- package/lib/recreate.d.ts +49 -0
- package/lib/recreate.js +305 -0
- package/lib/recreate.js.map +1 -0
- package/package.json +4 -4
- package/scripts/client-smoke.mjs +916 -0
- package/scripts/route-smoke.mjs +289 -8
package/lib/index.js
CHANGED
|
@@ -5,6 +5,8 @@ import { definePlugin, hasSameOriginProof, isLoopbackRequestStrict, originProofH
|
|
|
5
5
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
6
6
|
import { auditCapabilityUse, audited } from './audit.js';
|
|
7
7
|
import { DockerApi, MAX_RESULT_LINES, assertBin, assertImageRef, assertName, assertRef, assertSince, createRunner, parseImageHistoryJson, parseImageHistoryText, parseContainerEvent, parseEventsJson, parseImageInspectJson, parseInspectJson, parsePsJson, parseStatsJson, suggestContainerNames, } from './docker.js';
|
|
8
|
+
import { historyTrend, sampleFromDiagnosis } from './history.js';
|
|
9
|
+
import { DiagnosisHistoryStore, HISTORY_SCHEMA_VERSION, diagnosisHistoryPaths } from './history-store.js';
|
|
8
10
|
import { RemoteExec, sanitizeJumpSpec, sanitizeProxyCommand, setCredentialResolver, sshTarget } from './ssh-exec.js';
|
|
9
11
|
import { auditLoadedGrants, attachTierGate, attachTierRestrict, bindCapabilitySources, capabilityDeniedMessage, capabilityGrantAt, capabilityGranted, capabilityGrantVia, capabilityPaths, createElevationManager, dshHome, sharedGrantStore, } from '@hyzyn/dsh-kit';
|
|
10
12
|
/*
|
|
@@ -26,6 +28,13 @@ export const DOCKER_TIER_CLASS = {
|
|
|
26
28
|
docker_logs_grep: 'read',
|
|
27
29
|
docker_stats: 'read',
|
|
28
30
|
docker_events: 'read',
|
|
31
|
+
// 批一的两条诊断工具:四路采集全是只读(inspect / logs / events / stats + top),
|
|
32
|
+
// 对账由 test/tier-gate.test.ts 从 add() 调用处现算——漏分类会让档位闸静默放行
|
|
33
|
+
docker_top: 'read',
|
|
34
|
+
docker_diagnose: 'read',
|
|
35
|
+
// 批二:磁盘汇总(system df)与等价 docker run 推导——都是只读采集/纯文本生成
|
|
36
|
+
docker_disk_usage: 'read',
|
|
37
|
+
docker_run_command: 'read',
|
|
29
38
|
docker_images: 'read',
|
|
30
39
|
docker_image_inspect: 'read',
|
|
31
40
|
docker_networks: 'read',
|
|
@@ -100,6 +109,60 @@ const KNOWN_CONFIG_KEYS = new Set([
|
|
|
100
109
|
/** 显式删除 SSH 主机密钥记录:[{host, port}](hostKeys 是并集合并,删除必须显式)。 */
|
|
101
110
|
'hostKeysRemove',
|
|
102
111
|
]);
|
|
112
|
+
/** 磁盘工具每类明细回多少行(契约 §2.2):模型要的是「有没有可回收的」,不是迁移清单。 */
|
|
113
|
+
const DISK_TOOL_ROWS = 20;
|
|
114
|
+
/** HTTP 契约(null 到齐)→ 工具 schema 形状(null 变「省略」)。 */
|
|
115
|
+
function toToolEvidence(evidence) {
|
|
116
|
+
return {
|
|
117
|
+
state: evidence.state,
|
|
118
|
+
status: evidence.status,
|
|
119
|
+
oomKilled: evidence.oomKilled,
|
|
120
|
+
...(evidence.health === null ? {} : { health: evidence.health }),
|
|
121
|
+
...(evidence.healthLastOutput === null ? {} : { healthLastOutput: evidence.healthLastOutput }),
|
|
122
|
+
...(evidence.exitCode === null ? {} : { exitCode: evidence.exitCode }),
|
|
123
|
+
...(evidence.restartCount === null ? {} : { restartCount: evidence.restartCount }),
|
|
124
|
+
...(evidence.startedAt === null ? {} : { startedAt: evidence.startedAt }),
|
|
125
|
+
...(evidence.finishedAt === null ? {} : { finishedAt: evidence.finishedAt }),
|
|
126
|
+
...(evidence.memoryLimitBytes === null ? {} : { memoryLimitBytes: evidence.memoryLimitBytes }),
|
|
127
|
+
...(evidence.cpuQuota === null ? {} : { cpuQuota: evidence.cpuQuota }),
|
|
128
|
+
...(evidence.logLines === null ? {} : { logLines: evidence.logLines }),
|
|
129
|
+
...(evidence.errorLines === null ? {} : { errorLines: evidence.errorLines }),
|
|
130
|
+
...(evidence.eventCount === null ? {} : { eventCount: evidence.eventCount }),
|
|
131
|
+
...(evidence.cpuPercent === null ? {} : { cpuPercent: evidence.cpuPercent }),
|
|
132
|
+
...(evidence.memBytes === null ? {} : { memBytes: evidence.memBytes }),
|
|
133
|
+
...(evidence.memPercent === null ? {} : { memPercent: evidence.memPercent }),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* 历史视图 → 工具输出形状。
|
|
138
|
+
*
|
|
139
|
+
* 两处压缩都是**有意的**:
|
|
140
|
+
* - 样本最多 10 条(再多模型也读不动,而 `trend` 已经把结论给出来了);
|
|
141
|
+
* - `null` 字段省略(工具 schema 没有 null 类型,见 `toToolEvidence` 的同款说明)。
|
|
142
|
+
*/
|
|
143
|
+
function toToolHistory(history) {
|
|
144
|
+
return {
|
|
145
|
+
samples: history.samples.slice(-10).map((sample) => ({
|
|
146
|
+
at: sample.at,
|
|
147
|
+
severity: sample.severity,
|
|
148
|
+
code: sample.code,
|
|
149
|
+
findings: sample.findings,
|
|
150
|
+
...(sample.restartCount === null ? {} : { restartCount: sample.restartCount }),
|
|
151
|
+
...(sample.memPercent === null ? {} : { memPercent: sample.memPercent }),
|
|
152
|
+
...(sample.errorLines === null ? {} : { errorLines: sample.errorLines }),
|
|
153
|
+
})),
|
|
154
|
+
trend: {
|
|
155
|
+
count: history.trend.count,
|
|
156
|
+
...(history.trend.window === null ? {} : { window: history.trend.window }),
|
|
157
|
+
verdict: history.trend.verdict,
|
|
158
|
+
numbers: history.trend.numbers,
|
|
159
|
+
newFindings: history.trend.newFindings,
|
|
160
|
+
resolvedFindings: history.trend.resolvedFindings,
|
|
161
|
+
aggravating: history.trend.aggravating,
|
|
162
|
+
},
|
|
163
|
+
recorded: history.recorded,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
103
166
|
/* ------------------------------------------------------------------ *
|
|
104
167
|
* 列表分页(docker_ps / docker_images / docker_networks / docker_volumes 共用口径)
|
|
105
168
|
* ------------------------------------------------------------------ */
|
|
@@ -968,6 +1031,70 @@ const plugin = definePlugin({
|
|
|
968
1031
|
info: (msg) => ctx.logger.info(msg),
|
|
969
1032
|
warn: (msg) => ctx.logger.warn(msg),
|
|
970
1033
|
};
|
|
1034
|
+
/*
|
|
1035
|
+
* 诊断历史(批三):把「一次诊断」变成时间序列,回答「它在慢慢变坏吗」。
|
|
1036
|
+
*
|
|
1037
|
+
* **为什么历史不是新工具 / 新路由**:它不是新的采集面,只是同一份 diagnosis 的第二种视图。
|
|
1038
|
+
* `docker_logs` 与 `docker_logs_grep` 之所以拆成两条,是因为预算模型不同(前者「返回 = 收到」、
|
|
1039
|
+
* 后者「扫得多、返回少」);这里预算完全相同,多拆一条只会让模型多试一次。
|
|
1040
|
+
*
|
|
1041
|
+
* **写盘失败绝不能让诊断失败**:历史是辅助信息,读不到只是没有趋势,而诊断本身仍然有价值。
|
|
1042
|
+
* 所以整段 catch 住、记一行日志、回空历史(`recorded: false`)——「加了个新功能,结果老功能
|
|
1043
|
+
* 因为磁盘满了不能用了」是本仓最不能接受的那类回归。
|
|
1044
|
+
*
|
|
1045
|
+
* 落点只在 `diagnosisHistoryPaths()` 里拼一次(见 history-store.ts 文件头);store 构造**不碰磁盘**。
|
|
1046
|
+
*/
|
|
1047
|
+
const historyStore = new DiagnosisHistoryStore(diagnosisHistoryPaths(dshHome()).dir);
|
|
1048
|
+
const HISTORY_DEFAULT = 10;
|
|
1049
|
+
const HISTORY_LIMIT_MAX = 50;
|
|
1050
|
+
/** `history` 参数:非有限数回落默认、越界夹紧——与 `tail` / `logTail` 的既有风格一致(不报错)。 */
|
|
1051
|
+
const clampHistoryCount = (value) => {
|
|
1052
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
1053
|
+
return HISTORY_DEFAULT;
|
|
1054
|
+
return Math.min(Math.max(Math.trunc(value), 0), HISTORY_LIMIT_MAX);
|
|
1055
|
+
};
|
|
1056
|
+
/**
|
|
1057
|
+
* 采集 → 压缩成样本 → 落盘(受去重窗口约束)→ 读回最近 N 条 → 趋势。
|
|
1058
|
+
*
|
|
1059
|
+
* 索引键用**容器名**(`target + container`):改名会开一段新历史。这是可见的行为变化,
|
|
1060
|
+
* 但比「改名后把两个容器的历史混在一起」好——混起来会让趋势说出根本没发生过的变化。
|
|
1061
|
+
*/
|
|
1062
|
+
const diagnoseWithHistory = async (api, target, id, options) => {
|
|
1063
|
+
const since = typeof options.since === 'string' && options.since.trim() !== '' ? assertSince(options.since) : undefined;
|
|
1064
|
+
const result = await api.diagnose(id, {
|
|
1065
|
+
...(typeof options.logTail === 'number' ? { logTail: options.logTail } : {}),
|
|
1066
|
+
...(since === undefined ? {} : { since }),
|
|
1067
|
+
});
|
|
1068
|
+
const diagnosis = result.diagnosis;
|
|
1069
|
+
const limit = clampHistoryCount(options.history);
|
|
1070
|
+
const container = diagnosis.name === '' ? diagnosis.id : diagnosis.name;
|
|
1071
|
+
let recorded = false;
|
|
1072
|
+
try {
|
|
1073
|
+
recorded = historyStore.append({
|
|
1074
|
+
v: HISTORY_SCHEMA_VERSION,
|
|
1075
|
+
target,
|
|
1076
|
+
container,
|
|
1077
|
+
id: diagnosis.id,
|
|
1078
|
+
// 时钟在**这里**读(history.ts 是纯逻辑,时钟由调用方注入,同 diagnose.ts)
|
|
1079
|
+
sample: sampleFromDiagnosis(diagnosis, Date.now()),
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
catch (error) {
|
|
1083
|
+
logger.warn(`[dsh-docker] 诊断历史写入失败(本次只回空历史):${error instanceof Error ? error.message : String(error)}`);
|
|
1084
|
+
return { diagnosis, history: { samples: [], trend: historyTrend([]), recorded: false } };
|
|
1085
|
+
}
|
|
1086
|
+
let samples = [];
|
|
1087
|
+
// `history: 0` = 明确不要历史(但**仍然记录**:记录是默认行为,读不读是调用方的选择)
|
|
1088
|
+
if (limit > 0) {
|
|
1089
|
+
try {
|
|
1090
|
+
samples = historyStore.read(target, container).slice(-limit);
|
|
1091
|
+
}
|
|
1092
|
+
catch (error) {
|
|
1093
|
+
logger.warn(`[dsh-docker] 诊断历史读取失败(趋势本次不可用):${error instanceof Error ? error.message : String(error)}`);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
return { diagnosis, history: { samples, trend: historyTrend(samples), recorded } };
|
|
1097
|
+
};
|
|
971
1098
|
/* ---------- 主机指纹(TOFU,本插件自持;tty 记录作种子) ---------- */
|
|
972
1099
|
let settingsScope;
|
|
973
1100
|
let settingsForms;
|
|
@@ -1812,7 +1939,9 @@ const plugin = definePlugin({
|
|
|
1812
1939
|
return 'Docker 目标:' + rows.map((row) => {
|
|
1813
1940
|
const state = row.ok === undefined ? '' : row.ok ? ' [可达]' : ` [不可用:${row.error ?? '未知'}]`;
|
|
1814
1941
|
const version = row.serverVersion === undefined ? '' : ` docker ${row.serverVersion}`;
|
|
1815
|
-
|
|
1942
|
+
// 连接簿条目名紧跟在 `user@host` 之后:它就是「同一台主机在 tty 那边叫什么」(D170)
|
|
1943
|
+
const book = row.book === undefined ? '' : `(连接簿 ${row.book})`;
|
|
1944
|
+
return `\n- ${row.name} (${row.kind}) ${row.label}${book}${version}${state}`;
|
|
1816
1945
|
}).join('');
|
|
1817
1946
|
};
|
|
1818
1947
|
/**
|
|
@@ -1916,6 +2045,113 @@ const plugin = definePlugin({
|
|
|
1916
2045
|
return `\n- ${at} ${row.name} ${row.action}${exit}${compose} image=${row.image}`;
|
|
1917
2046
|
}).join('');
|
|
1918
2047
|
};
|
|
2048
|
+
/*
|
|
2049
|
+
* 进程快照的渲染:表头 + 行。0 行时给一句「容器内没有进程」——注意 top 在容器没在
|
|
2050
|
+
* 运行时是**抛错**的(docker 就是这么答的),所以走到这里 0 行只能是 docker 没打印
|
|
2051
|
+
* 任何进程行(极少数运行时);空列表本身不是错误。
|
|
2052
|
+
*/
|
|
2053
|
+
const renderTop = (target, row) => {
|
|
2054
|
+
const head = `容器 ${row.name === '' ? row.id : row.name}(id=${row.id},目标 ${target})的进程`;
|
|
2055
|
+
const rows = row.processes ?? [];
|
|
2056
|
+
if (rows.length === 0)
|
|
2057
|
+
return `${head}:容器内没有进程(容器可能未运行)。`;
|
|
2058
|
+
const body = rows.map((proc) => {
|
|
2059
|
+
const cell = (value) => String(value ?? '');
|
|
2060
|
+
return `\n${cell(proc.pid).padStart(7)} ${cell(proc.ppid).padStart(7)} ${cell(proc.user).padEnd(8)} ${cell(proc.stat).padEnd(4)} ${cell(proc.elapsed).padStart(10)} ${cell(proc.cpu).padStart(5)} ${cell(proc.mem).padStart(5)} ${cell(proc.command)}`;
|
|
2061
|
+
}).join('');
|
|
2062
|
+
const cut = row.truncated ? '\n(进程数超过上限已截断:要全量请在容器内跑 ps)' : '';
|
|
2063
|
+
return `${head}(${String(rows.length)} 个${row.truncated ? ',已截断' : ''}):\n PID PPID USER STAT ELAPSED CPU% MEM% COMMAND${body}${cut}`;
|
|
2064
|
+
};
|
|
2065
|
+
/*
|
|
2066
|
+
* 诊断的渲染:**只给结论**,不把 80 行日志灌进模型上下文(契约 §2)。
|
|
2067
|
+
* 返回值里仍然带着 `logTail` 全量、`evidence` 全字段与 `timeline`,模型要细看可以
|
|
2068
|
+
* 自己读;这里只显示 verdict + findings + 建议 + 日志行数(外加三路降级的提示,
|
|
2069
|
+
* 那是「结论可不可信」的一部分,不能藏)。
|
|
2070
|
+
*/
|
|
2071
|
+
/**
|
|
2072
|
+
* 历史压成**一行**(不逐条铺样本):模型要的是「它在变坏吗」的结论,不是 10 行时间戳。
|
|
2073
|
+
*
|
|
2074
|
+
* 时间只保留到分钟(`2026-11-03 07:12`):秒对趋势判断没有意义,却会把这一行撑长两倍。
|
|
2075
|
+
*/
|
|
2076
|
+
const historyLine = (history) => {
|
|
2077
|
+
if (history === undefined || history.samples.length === 0)
|
|
2078
|
+
return '';
|
|
2079
|
+
const trend = history.trend;
|
|
2080
|
+
const from = history.samples[0]?.at ?? '';
|
|
2081
|
+
const to = history.samples[history.samples.length - 1]?.at ?? '';
|
|
2082
|
+
const short = (iso) => iso.replace('T', ' ').slice(0, 16);
|
|
2083
|
+
const window = from === '' || to === '' ? '' : `(最早 ${short(from)} → 最近 ${short(to)})`;
|
|
2084
|
+
const note = history.recorded ? '' : ';本次与上一条重复,未追加';
|
|
2085
|
+
return `\n历史 ${String(trend.count)} 次${window}:${trend.verdict.summary}${note}`;
|
|
2086
|
+
};
|
|
2087
|
+
const renderDiagnosis = (target, row) => {
|
|
2088
|
+
const verdict = row.verdict ?? { severity: '?', code: '?', summary: '' };
|
|
2089
|
+
// 标题里用短 id(与 docker_inspect 的 shortId 口径一致):全量 64 位在返回值里,不占正文
|
|
2090
|
+
const shortId = row.id.length > 12 ? row.id.slice(0, 12) : row.id;
|
|
2091
|
+
const head = `容器 ${row.name === '' ? shortId : row.name}(id=${shortId},目标 ${target})诊断结论:**[${verdict.severity}] ${verdict.summary}**(${verdict.code})`;
|
|
2092
|
+
const findings = (row.findings ?? []).length === 0
|
|
2093
|
+
? '\n判据:没有命中任何问题判据。'
|
|
2094
|
+
: `\n判据(${String(row.findings.length)} 条):` + row.findings.map((finding) => `\n- [${finding.severity}] ${finding.title}:${finding.detail}`).join('');
|
|
2095
|
+
const suggestions = (row.suggestions ?? []).length === 0
|
|
2096
|
+
? ''
|
|
2097
|
+
: '\n建议:' + row.suggestions.map((item) => `\n- ${item}`).join('');
|
|
2098
|
+
const evidence = row.evidence ?? {};
|
|
2099
|
+
const logLines = evidence.logLines === undefined ? '未取到' : `${String(evidence.logLines)} 行`;
|
|
2100
|
+
const errorLines = evidence.errorLines === undefined ? '未取到' : `${String(evidence.errorLines)} 行`;
|
|
2101
|
+
const foot = `\n日志:抓取 ${logLines}(疑似报错 ${errorLines}),时间线 ${String((row.timeline ?? []).length)} 条;证据全字段与日志原文在返回值里(要更多日志 / 找关键词用 docker_logs_grep)。`;
|
|
2102
|
+
return head + findings + suggestions + historyLine(row.history) + foot;
|
|
2103
|
+
};
|
|
2104
|
+
/*
|
|
2105
|
+
* 磁盘占用:四行汇总 + 可回收最多的镜像(前 10)+ 一句判据。
|
|
2106
|
+
*
|
|
2107
|
+
* 「可回收」只算 `containers === 0` 的镜像——镜像的「独占大小」只有在没有容器使用时
|
|
2108
|
+
* 才真的能回收;还在用的镜像删了会牵连容器。这句话必须写出来,否则模型看到一张
|
|
2109
|
+
* 「大镜像」表就建议用户 `image prune`。
|
|
2110
|
+
*/
|
|
2111
|
+
const renderDiskUsage = (target, row) => {
|
|
2112
|
+
const lines = [`目标 ${target} 的磁盘占用(docker system df):`];
|
|
2113
|
+
for (const item of row.summary ?? []) {
|
|
2114
|
+
lines.push(`- ${item.type}:${String(item.totalCount)} 个(活跃 ${String(item.active)})占用 ${item.size},可回收 ${item.reclaimable}`);
|
|
2115
|
+
}
|
|
2116
|
+
if (row.degraded) {
|
|
2117
|
+
lines.push(`\n明细未取到:${row.degradedReason ?? '未知原因'}(汇总仍可用)`);
|
|
2118
|
+
return lines.join('\n');
|
|
2119
|
+
}
|
|
2120
|
+
const unused = (row.images ?? []).filter((image) => image.containers === 0).slice(0, 10);
|
|
2121
|
+
const used = (row.images ?? []).filter((image) => image.containers > 0).length;
|
|
2122
|
+
lines.push('\n可回收最多的镜像(独占大小降序,只列没有被任何容器使用的):');
|
|
2123
|
+
if (unused.length === 0) {
|
|
2124
|
+
lines.push(`- (没有未被使用的镜像;还有 ${String(used)} 个镜像在用)`);
|
|
2125
|
+
}
|
|
2126
|
+
else {
|
|
2127
|
+
lines.push(unused.map((image) => `- ${image.repository === '' ? image.id : `${image.repository}:${image.tag}`} 独占 ${image.uniqueSize}(大小 ${image.size})`).join('\n'));
|
|
2128
|
+
}
|
|
2129
|
+
lines.push('\n删之前先看 `containers` 是不是 0:0 才是没人用,>0 的镜像删了会牵连容器。');
|
|
2130
|
+
if (row.truncated.images || row.truncated.volumes || row.truncated.buildCache) {
|
|
2131
|
+
lines.push(`明细已截断(镜像 ${String(row.images.length)}/${String(row.totals.images)}、卷 ${String(row.volumes.length)}/${String(row.totals.volumes)}、构建缓存 ${String(row.buildCache.length)}/${String(row.totals.buildCache)});面板的磁盘页可看全量。`);
|
|
2132
|
+
}
|
|
2133
|
+
return lines.join('\n');
|
|
2134
|
+
};
|
|
2135
|
+
/*
|
|
2136
|
+
* 等价 docker run:命令用**围栏块**原样给出(可直接复制),后面才列提醒。
|
|
2137
|
+
*
|
|
2138
|
+
* 为什么不折行或加省略:这条命令的全部价值就是「能复制粘贴后跑起来」,
|
|
2139
|
+
* 任何为了排版做的改动都会让它变成一条错的命令。
|
|
2140
|
+
*/
|
|
2141
|
+
const renderRunCommand = (target, row) => {
|
|
2142
|
+
const head = `容器 ${row.name}(id=${row.id.slice(0, 12)},目标 ${target})的等价 docker run——**只生成命令,没有执行任何操作**:`;
|
|
2143
|
+
const body = '```sh\n' + (row.command === '' ? '(命令生成失败)' : row.command) + '\n```';
|
|
2144
|
+
const warnings = (row.warnings ?? []).length === 0
|
|
2145
|
+
? ''
|
|
2146
|
+
: '\n提醒:' + row.warnings.map((item) => `\n- ${item}`).join('');
|
|
2147
|
+
const secrets = (row.secretKeys ?? []).length === 0
|
|
2148
|
+
? ''
|
|
2149
|
+
: `\n可能含凭证的环境变量键:${row.secretKeys.join('、')}(命令里是原值,贴出去前先确认)`;
|
|
2150
|
+
const omitted = (row.omitted ?? []).length === 0
|
|
2151
|
+
? ''
|
|
2152
|
+
: '\n故意没还原(需要你手动补):' + row.omitted.map((item) => `\n- ${item}`).join('');
|
|
2153
|
+
return `${head}\n${body}${warnings}${secrets}${omitted}`;
|
|
2154
|
+
};
|
|
1919
2155
|
const renderDetail = (detail) => {
|
|
1920
2156
|
const lines = [
|
|
1921
2157
|
`容器 ${detail.name}(${detail.shortId})`,
|
|
@@ -2033,7 +2269,7 @@ const plugin = definePlugin({
|
|
|
2033
2269
|
name: 'docker_targets',
|
|
2034
2270
|
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
2035
2271
|
isConcurrencySafe: () => true,
|
|
2036
|
-
description: '列出已配置的 Docker 目标(本机 / SSH 主机),可选探测每个目标的 docker daemon
|
|
2272
|
+
description: '列出已配置的 Docker 目标(本机 / SSH 主机),可选探测每个目标的 docker daemon 是否可达;SSH 目标会带出它引用的 tty 连接簿条目名(book,可直接用于 sftp_* 的 book)。其他 docker_* 工具的 target 参数取自这里。',
|
|
2037
2273
|
parameters: { probe: { type: 'boolean', description: 'true 时逐个探测 docker 版本与 daemon 可达性(SSH 目标会建连接,较慢)' } },
|
|
2038
2274
|
output: {
|
|
2039
2275
|
schema: {
|
|
@@ -2050,6 +2286,8 @@ const plugin = definePlugin({
|
|
|
2050
2286
|
name: { type: 'string', required: true },
|
|
2051
2287
|
kind: { type: 'string', required: true },
|
|
2052
2288
|
label: { type: 'string', required: true },
|
|
2289
|
+
// D170:目标引用的 tty 连接簿条目名(内联 host/username 的目标没有这个字段)
|
|
2290
|
+
book: { type: 'string' },
|
|
2053
2291
|
ok: { type: 'boolean' },
|
|
2054
2292
|
error: { type: 'string' },
|
|
2055
2293
|
serverVersion: { type: 'string' },
|
|
@@ -2068,19 +2306,27 @@ const plugin = definePlugin({
|
|
|
2068
2306
|
const books = readTtyBooks(settingsApi);
|
|
2069
2307
|
const rows = [];
|
|
2070
2308
|
for (const target of targetsNow()) {
|
|
2309
|
+
/*
|
|
2310
|
+
* `book` 只在目标**真的引用了连接簿条目**时带出(D170):`resolveTarget` 内部靠它
|
|
2311
|
+
* 查表,但工具输出里从来没有它——于是模型手里只剩 `user@host`,而 tty / sftp_* 认的
|
|
2312
|
+
* 是**条目名**。两边不同名时(目标名 `host-a`、条目名 `KB_JUMP_01` 这种)猜不出来,
|
|
2313
|
+
* 只能赌一次,赌错就是「连接簿中不存在」。本机目标与内联字段目标一律不带。
|
|
2314
|
+
*/
|
|
2315
|
+
const book = target.book ?? '';
|
|
2316
|
+
const bookField = target.kind === 'ssh' && book !== '' ? { book } : {};
|
|
2071
2317
|
const { resolved, error } = resolveTarget(target, books);
|
|
2072
2318
|
if (resolved === undefined) {
|
|
2073
|
-
rows.push({ name: target.name, kind: target.kind, label: '解析失败', ok: false, error: error ?? '未知错误' });
|
|
2319
|
+
rows.push({ name: target.name, kind: target.kind, label: '解析失败', ...bookField, ok: false, error: error ?? '未知错误' });
|
|
2074
2320
|
continue;
|
|
2075
2321
|
}
|
|
2076
2322
|
const label = resolved.kind === 'local' ? '本机' : sshTarget(resolved.spec);
|
|
2077
2323
|
if (input.probe !== true) {
|
|
2078
|
-
rows.push({ name: target.name, kind: target.kind, label });
|
|
2324
|
+
rows.push({ name: target.name, kind: target.kind, label, ...bookField });
|
|
2079
2325
|
continue;
|
|
2080
2326
|
}
|
|
2081
2327
|
const { api } = apiFor(target.name);
|
|
2082
2328
|
if (api === undefined) {
|
|
2083
|
-
rows.push({ name: target.name, kind: target.kind, label, ok: false, error: '无法构造执行通道' });
|
|
2329
|
+
rows.push({ name: target.name, kind: target.kind, label, ...bookField, ok: false, error: '无法构造执行通道' });
|
|
2084
2330
|
continue;
|
|
2085
2331
|
}
|
|
2086
2332
|
const probe = await api.probe();
|
|
@@ -2090,6 +2336,7 @@ const plugin = definePlugin({
|
|
|
2090
2336
|
name: target.name,
|
|
2091
2337
|
kind: target.kind,
|
|
2092
2338
|
label,
|
|
2339
|
+
...bookField,
|
|
2093
2340
|
ok: probe.ok,
|
|
2094
2341
|
...(probe.ok ? { ...(probe.serverVersion === null ? {} : { serverVersion: probe.serverVersion }) } : { error: probe.error ?? '未知错误' }),
|
|
2095
2342
|
});
|
|
@@ -2779,6 +3026,346 @@ const plugin = definePlugin({
|
|
|
2779
3026
|
};
|
|
2780
3027
|
},
|
|
2781
3028
|
}));
|
|
3029
|
+
add('docker_top', defineTool({
|
|
3030
|
+
name: 'docker_top',
|
|
3031
|
+
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
3032
|
+
isConcurrencySafe: () => true,
|
|
3033
|
+
description: '列出容器内的进程快照(docker top):PID / PPID / USER / STAT / 运行时长 / CPU% / 内存% / 命令。**只读**;看「谁在吃 CPU / 内存」先看这里,整体「为什么不行」用 docker_diagnose。容器没在运行会直接报错(不是空列表)。',
|
|
3034
|
+
parameters: {
|
|
3035
|
+
target: targetParam,
|
|
3036
|
+
id: { type: 'string', required: true, description: '容器名或 ID(来自 docker_ps)' },
|
|
3037
|
+
},
|
|
3038
|
+
output: {
|
|
3039
|
+
schema: {
|
|
3040
|
+
type: 'object',
|
|
3041
|
+
additionalProperties: false,
|
|
3042
|
+
properties: {
|
|
3043
|
+
target: { type: 'string', required: true },
|
|
3044
|
+
id: { type: 'string', required: true },
|
|
3045
|
+
name: { type: 'string', required: true },
|
|
3046
|
+
truncated: { type: 'boolean', required: true },
|
|
3047
|
+
processes: {
|
|
3048
|
+
type: 'array',
|
|
3049
|
+
required: true,
|
|
3050
|
+
items: {
|
|
3051
|
+
type: 'object',
|
|
3052
|
+
additionalProperties: false,
|
|
3053
|
+
properties: {
|
|
3054
|
+
pid: { type: 'string', required: true },
|
|
3055
|
+
ppid: { type: 'string', required: true },
|
|
3056
|
+
user: { type: 'string', required: true },
|
|
3057
|
+
stat: { type: 'string', required: true },
|
|
3058
|
+
elapsed: { type: 'string', required: true },
|
|
3059
|
+
cpu: { type: 'string', required: true },
|
|
3060
|
+
mem: { type: 'string', required: true },
|
|
3061
|
+
command: { type: 'string', required: true },
|
|
3062
|
+
},
|
|
3063
|
+
},
|
|
3064
|
+
},
|
|
3065
|
+
},
|
|
3066
|
+
},
|
|
3067
|
+
render: (_args, value) => {
|
|
3068
|
+
const v = value;
|
|
3069
|
+
return [{ type: 'text', text: renderTop(v.target ?? '?', { target: v.target ?? '?', id: v.id ?? '?', name: v.name ?? '', processes: v.processes ?? [], truncated: v.truncated === true }) }];
|
|
3070
|
+
},
|
|
3071
|
+
},
|
|
3072
|
+
async execute(args) {
|
|
3073
|
+
const input = (args ?? {});
|
|
3074
|
+
const picked = pickTarget(input.target);
|
|
3075
|
+
if (picked.name === undefined)
|
|
3076
|
+
throw new Error(picked.error ?? '无效的 target');
|
|
3077
|
+
if (typeof input.id !== 'string')
|
|
3078
|
+
throw new Error('id 必填');
|
|
3079
|
+
const { api } = apiFor(picked.name);
|
|
3080
|
+
if (api === undefined)
|
|
3081
|
+
throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
|
|
3082
|
+
const top = await api.top(input.id);
|
|
3083
|
+
return {
|
|
3084
|
+
target: picked.name,
|
|
3085
|
+
id: top.id,
|
|
3086
|
+
name: top.name,
|
|
3087
|
+
processes: top.rows.map((row) => ({
|
|
3088
|
+
pid: row.pid,
|
|
3089
|
+
ppid: row.ppid,
|
|
3090
|
+
user: row.user,
|
|
3091
|
+
stat: row.stat,
|
|
3092
|
+
elapsed: row.elapsed,
|
|
3093
|
+
cpu: row.cpu,
|
|
3094
|
+
mem: row.mem,
|
|
3095
|
+
command: row.command,
|
|
3096
|
+
})),
|
|
3097
|
+
truncated: top.truncated,
|
|
3098
|
+
};
|
|
3099
|
+
},
|
|
3100
|
+
}));
|
|
3101
|
+
add('docker_diagnose', defineTool({
|
|
3102
|
+
name: 'docker_diagnose',
|
|
3103
|
+
// 只读工具:四路全是只读采集(inspect / logs / events / stats),与同轮其它工具并发执行
|
|
3104
|
+
isConcurrencySafe: () => true,
|
|
3105
|
+
description: '诊断容器「为什么不行」(**只读**):一次采集 inspect + logs + events + stats 四路,按固定判据给出结论——OOM / 非零退出 / 反复重启 / 健康检查失败 / 内存或 CPU 逼近上限 / 日志里有报错。结论是**判据**不是猜测:每条 finding 都带证据(detail),evidence 给结构化字段,**没采到的字段是缺席而不是 0**(findings 里另有 *-unavailable 说明原因)。日志默认只抓尾部 80 行:要更多日志 / 找关键词用 docker_logs_grep。',
|
|
3106
|
+
parameters: {
|
|
3107
|
+
target: targetParam,
|
|
3108
|
+
id: { type: 'string', required: true, description: '容器名或 ID(来自 docker_ps)' },
|
|
3109
|
+
logTail: { type: 'number', description: '日志尾部行数(1~500,默认 80;越界值会被夹紧到边界)' },
|
|
3110
|
+
since: { type: 'string', description: '事件与日志的时间窗(docker --since 语法,默认 1h,如 30m、2h、2026-09-09T10:00:00)' },
|
|
3111
|
+
history: { type: 'number', description: '返回多少条历史样本(0~50,默认 10;越界值会被夹紧)。每次调用都会记录本次诊断(30 秒内完全相同的重复不重复记),所以多调几次就能看出「它在慢慢变坏吗」' },
|
|
3112
|
+
},
|
|
3113
|
+
output: {
|
|
3114
|
+
schema: {
|
|
3115
|
+
type: 'object',
|
|
3116
|
+
additionalProperties: false,
|
|
3117
|
+
properties: {
|
|
3118
|
+
target: { type: 'string', required: true },
|
|
3119
|
+
id: { type: 'string', required: true },
|
|
3120
|
+
name: { type: 'string', required: true },
|
|
3121
|
+
verdict: {
|
|
3122
|
+
type: 'object',
|
|
3123
|
+
required: true,
|
|
3124
|
+
additionalProperties: false,
|
|
3125
|
+
properties: {
|
|
3126
|
+
severity: { type: 'string', required: true },
|
|
3127
|
+
code: { type: 'string', required: true },
|
|
3128
|
+
summary: { type: 'string', required: true },
|
|
3129
|
+
},
|
|
3130
|
+
},
|
|
3131
|
+
findings: {
|
|
3132
|
+
type: 'array',
|
|
3133
|
+
required: true,
|
|
3134
|
+
items: {
|
|
3135
|
+
type: 'object',
|
|
3136
|
+
additionalProperties: false,
|
|
3137
|
+
properties: {
|
|
3138
|
+
code: { type: 'string', required: true },
|
|
3139
|
+
severity: { type: 'string', required: true },
|
|
3140
|
+
title: { type: 'string', required: true },
|
|
3141
|
+
detail: { type: 'string', required: true },
|
|
3142
|
+
},
|
|
3143
|
+
},
|
|
3144
|
+
},
|
|
3145
|
+
evidence: {
|
|
3146
|
+
type: 'object',
|
|
3147
|
+
required: true,
|
|
3148
|
+
additionalProperties: false,
|
|
3149
|
+
properties: {
|
|
3150
|
+
state: { type: 'string' },
|
|
3151
|
+
status: { type: 'string' },
|
|
3152
|
+
health: { type: 'string' },
|
|
3153
|
+
healthLastOutput: { type: 'string' },
|
|
3154
|
+
exitCode: { type: 'number' },
|
|
3155
|
+
oomKilled: { type: 'boolean' },
|
|
3156
|
+
restartCount: { type: 'number' },
|
|
3157
|
+
startedAt: { type: 'string' },
|
|
3158
|
+
finishedAt: { type: 'string' },
|
|
3159
|
+
memoryLimitBytes: { type: 'number' },
|
|
3160
|
+
cpuQuota: { type: 'number' },
|
|
3161
|
+
logLines: { type: 'number' },
|
|
3162
|
+
errorLines: { type: 'number' },
|
|
3163
|
+
eventCount: { type: 'number' },
|
|
3164
|
+
cpuPercent: { type: 'number' },
|
|
3165
|
+
memBytes: { type: 'number' },
|
|
3166
|
+
memPercent: { type: 'number' },
|
|
3167
|
+
},
|
|
3168
|
+
},
|
|
3169
|
+
timeline: {
|
|
3170
|
+
type: 'array',
|
|
3171
|
+
required: true,
|
|
3172
|
+
items: {
|
|
3173
|
+
type: 'object',
|
|
3174
|
+
additionalProperties: false,
|
|
3175
|
+
properties: {
|
|
3176
|
+
at: { type: 'string' },
|
|
3177
|
+
kind: { type: 'string', required: true },
|
|
3178
|
+
text: { type: 'string', required: true },
|
|
3179
|
+
},
|
|
3180
|
+
},
|
|
3181
|
+
},
|
|
3182
|
+
suggestions: { type: 'array', required: true, items: { type: 'string' } },
|
|
3183
|
+
// 批三:历史与趋势(不是新工具,理由见 diagnoseWithHistory 的注释)。
|
|
3184
|
+
// `window` 在样本 <2 时**不出现**(schema 没有 null 类型,见 toToolEvidence 的同款说明)
|
|
3185
|
+
history: {
|
|
3186
|
+
type: 'object',
|
|
3187
|
+
required: true,
|
|
3188
|
+
additionalProperties: false,
|
|
3189
|
+
properties: {
|
|
3190
|
+
samples: {
|
|
3191
|
+
type: 'array',
|
|
3192
|
+
required: true,
|
|
3193
|
+
items: {
|
|
3194
|
+
type: 'object',
|
|
3195
|
+
additionalProperties: false,
|
|
3196
|
+
properties: {
|
|
3197
|
+
at: { type: 'string', required: true },
|
|
3198
|
+
severity: { type: 'string', required: true },
|
|
3199
|
+
code: { type: 'string', required: true },
|
|
3200
|
+
findings: { type: 'array', required: true, items: { type: 'string' } },
|
|
3201
|
+
restartCount: { type: 'number' },
|
|
3202
|
+
memPercent: { type: 'number' },
|
|
3203
|
+
errorLines: { type: 'number' },
|
|
3204
|
+
},
|
|
3205
|
+
},
|
|
3206
|
+
},
|
|
3207
|
+
trend: {
|
|
3208
|
+
type: 'object',
|
|
3209
|
+
required: true,
|
|
3210
|
+
additionalProperties: false,
|
|
3211
|
+
properties: {
|
|
3212
|
+
count: { type: 'number', required: true },
|
|
3213
|
+
window: {
|
|
3214
|
+
type: 'object',
|
|
3215
|
+
additionalProperties: false,
|
|
3216
|
+
properties: { from: { type: 'string', required: true }, to: { type: 'string', required: true } },
|
|
3217
|
+
},
|
|
3218
|
+
verdict: {
|
|
3219
|
+
type: 'object',
|
|
3220
|
+
required: true,
|
|
3221
|
+
additionalProperties: false,
|
|
3222
|
+
properties: {
|
|
3223
|
+
severity: { type: 'string', required: true },
|
|
3224
|
+
code: { type: 'string', required: true },
|
|
3225
|
+
summary: { type: 'string', required: true },
|
|
3226
|
+
},
|
|
3227
|
+
},
|
|
3228
|
+
numbers: {
|
|
3229
|
+
type: 'array',
|
|
3230
|
+
required: true,
|
|
3231
|
+
items: {
|
|
3232
|
+
type: 'object',
|
|
3233
|
+
additionalProperties: false,
|
|
3234
|
+
properties: {
|
|
3235
|
+
key: { type: 'string', required: true },
|
|
3236
|
+
from: { type: 'number', required: true },
|
|
3237
|
+
to: { type: 'number', required: true },
|
|
3238
|
+
delta: { type: 'number', required: true },
|
|
3239
|
+
},
|
|
3240
|
+
},
|
|
3241
|
+
},
|
|
3242
|
+
newFindings: { type: 'array', required: true, items: { type: 'string' } },
|
|
3243
|
+
resolvedFindings: { type: 'array', required: true, items: { type: 'string' } },
|
|
3244
|
+
aggravating: { type: 'boolean', required: true },
|
|
3245
|
+
},
|
|
3246
|
+
},
|
|
3247
|
+
recorded: { type: 'boolean', required: true },
|
|
3248
|
+
},
|
|
3249
|
+
},
|
|
3250
|
+
logTail: { type: 'string', required: true },
|
|
3251
|
+
},
|
|
3252
|
+
},
|
|
3253
|
+
render: (_args, value) => {
|
|
3254
|
+
const v = value;
|
|
3255
|
+
return [{ type: 'text', text: renderDiagnosis(v.target ?? '?', {
|
|
3256
|
+
target: v.target ?? '?',
|
|
3257
|
+
id: v.id ?? '?',
|
|
3258
|
+
name: v.name ?? '',
|
|
3259
|
+
verdict: v.verdict ?? { severity: '?', code: '?', summary: '' },
|
|
3260
|
+
findings: v.findings ?? [],
|
|
3261
|
+
evidence: v.evidence ?? {},
|
|
3262
|
+
timeline: v.timeline ?? [],
|
|
3263
|
+
suggestions: v.suggestions ?? [],
|
|
3264
|
+
logTail: v.logTail ?? '',
|
|
3265
|
+
// render 只在「schema 校验前」跑;正常路径一定有 history,兜底给空样本(形状走同一份转换)
|
|
3266
|
+
history: v.history ?? toToolHistory({ samples: [], trend: historyTrend([]), recorded: false }),
|
|
3267
|
+
}) }];
|
|
3268
|
+
},
|
|
3269
|
+
},
|
|
3270
|
+
async execute(args) {
|
|
3271
|
+
const input = (args ?? {});
|
|
3272
|
+
const picked = pickTarget(input.target);
|
|
3273
|
+
if (picked.name === undefined)
|
|
3274
|
+
throw new Error(picked.error ?? '无效的 target');
|
|
3275
|
+
if (typeof input.id !== 'string')
|
|
3276
|
+
throw new Error('id 必填');
|
|
3277
|
+
const { api } = apiFor(picked.name);
|
|
3278
|
+
if (api === undefined)
|
|
3279
|
+
throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
|
|
3280
|
+
// logTail / since / history 的夹紧与默认值统一在 diagnoseWithHistory 里做(HTTP 路由与工具
|
|
3281
|
+
// 同一份语义);非法 since 在这里抛成可读错误(与 docker_logs 同一个 assertSince)
|
|
3282
|
+
const { diagnosis, history } = await diagnoseWithHistory(api, picked.name, input.id, {
|
|
3283
|
+
logTail: input.logTail,
|
|
3284
|
+
since: input.since,
|
|
3285
|
+
history: input.history,
|
|
3286
|
+
});
|
|
3287
|
+
return {
|
|
3288
|
+
target: picked.name,
|
|
3289
|
+
id: diagnosis.id,
|
|
3290
|
+
name: diagnosis.name,
|
|
3291
|
+
verdict: diagnosis.verdict,
|
|
3292
|
+
findings: diagnosis.findings.map((finding) => ({
|
|
3293
|
+
code: finding.code,
|
|
3294
|
+
severity: finding.severity,
|
|
3295
|
+
title: finding.title,
|
|
3296
|
+
detail: finding.detail,
|
|
3297
|
+
})),
|
|
3298
|
+
evidence: toToolEvidence(diagnosis.evidence),
|
|
3299
|
+
timeline: diagnosis.timeline.map((entry) => ({
|
|
3300
|
+
...(entry.at === null ? {} : { at: entry.at }),
|
|
3301
|
+
kind: entry.kind,
|
|
3302
|
+
text: entry.text,
|
|
3303
|
+
})),
|
|
3304
|
+
suggestions: diagnosis.suggestions,
|
|
3305
|
+
logTail: diagnosis.logTail,
|
|
3306
|
+
// 给模型的样本最多 10 条(再多也读不动);`window` 为 null 时省略(schema 没有 null)
|
|
3307
|
+
history: toToolHistory(history),
|
|
3308
|
+
};
|
|
3309
|
+
},
|
|
3310
|
+
}));
|
|
3311
|
+
add('docker_run_command', defineTool({
|
|
3312
|
+
name: 'docker_run_command',
|
|
3313
|
+
// 只读工具:只读 inspect + 纯文本推导,没有任何副作用
|
|
3314
|
+
isConcurrencySafe: () => true,
|
|
3315
|
+
description: '从容器配置反推一条**等价的 docker run 命令**(只生成命令文本,**不执行任何操作**):端口 / 挂载 / 网络 / 环境变量 / 资源限制 / 设备都会还原。**故意没还原的**东西(--privileged、匿名卷、自定义 ENTRYPOINT、健康检查等)在 omitted 里点名,不许静默丢;命中敏感键名的环境变量在 secretKeys 里(**值不改写**,命令里是原值——贴出去前先确认)。适合手动复现 / 迁移。Compose 管理的容器只是近似。',
|
|
3316
|
+
parameters: {
|
|
3317
|
+
target: targetParam,
|
|
3318
|
+
id: { type: 'string', required: true, description: '容器名或 ID(来自 docker_ps)' },
|
|
3319
|
+
},
|
|
3320
|
+
output: {
|
|
3321
|
+
schema: {
|
|
3322
|
+
type: 'object',
|
|
3323
|
+
additionalProperties: false,
|
|
3324
|
+
properties: {
|
|
3325
|
+
target: { type: 'string', required: true },
|
|
3326
|
+
id: { type: 'string', required: true },
|
|
3327
|
+
name: { type: 'string', required: true },
|
|
3328
|
+
command: { type: 'string', required: true },
|
|
3329
|
+
warnings: { type: 'array', required: true, items: { type: 'string' } },
|
|
3330
|
+
secretKeys: { type: 'array', required: true, items: { type: 'string' } },
|
|
3331
|
+
omitted: { type: 'array', required: true, items: { type: 'string' } },
|
|
3332
|
+
},
|
|
3333
|
+
},
|
|
3334
|
+
render: (_args, value) => {
|
|
3335
|
+
const v = value;
|
|
3336
|
+
return [{ type: 'text', text: renderRunCommand(v.target ?? '?', {
|
|
3337
|
+
target: v.target ?? '?',
|
|
3338
|
+
id: v.id ?? '?',
|
|
3339
|
+
name: v.name ?? '',
|
|
3340
|
+
command: v.command ?? '',
|
|
3341
|
+
warnings: v.warnings ?? [],
|
|
3342
|
+
secretKeys: v.secretKeys ?? [],
|
|
3343
|
+
omitted: v.omitted ?? [],
|
|
3344
|
+
}) }];
|
|
3345
|
+
},
|
|
3346
|
+
},
|
|
3347
|
+
async execute(args) {
|
|
3348
|
+
const input = (args ?? {});
|
|
3349
|
+
const picked = pickTarget(input.target);
|
|
3350
|
+
if (picked.name === undefined)
|
|
3351
|
+
throw new Error(picked.error ?? '无效的 target');
|
|
3352
|
+
if (typeof input.id !== 'string')
|
|
3353
|
+
throw new Error('id 必填');
|
|
3354
|
+
const { api } = apiFor(picked.name);
|
|
3355
|
+
if (api === undefined)
|
|
3356
|
+
throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
|
|
3357
|
+
const result = await api.recreate(input.id);
|
|
3358
|
+
return {
|
|
3359
|
+
target: picked.name,
|
|
3360
|
+
id: result.id,
|
|
3361
|
+
name: result.name,
|
|
3362
|
+
command: result.command,
|
|
3363
|
+
warnings: result.warnings,
|
|
3364
|
+
secretKeys: result.secretKeys,
|
|
3365
|
+
omitted: result.omitted,
|
|
3366
|
+
};
|
|
3367
|
+
},
|
|
3368
|
+
}));
|
|
2782
3369
|
add('docker_images', defineTool({
|
|
2783
3370
|
name: 'docker_images',
|
|
2784
3371
|
// 只读工具:与同轮其它工具并发执行(宿主默认把未声明的工具当独占,见项目级 ROADMAP 第 4 项)
|
|
@@ -3010,6 +3597,146 @@ const plugin = definePlugin({
|
|
|
3010
3597
|
};
|
|
3011
3598
|
},
|
|
3012
3599
|
}));
|
|
3600
|
+
add('docker_disk_usage', defineTool({
|
|
3601
|
+
name: 'docker_disk_usage',
|
|
3602
|
+
// 只读工具:`docker system df` 是纯查询(-v 会去 stat 卷,但没有任何写操作)
|
|
3603
|
+
isConcurrencySafe: () => true,
|
|
3604
|
+
description: '读取目标上的磁盘占用(docker system df):四类汇总 + 镜像 / 卷 / 构建缓存明细,并给出**谁在用**(镜像的容器数、卷的链接数)——这两个数字来自 docker 自己的 `Images[].Containers` / `Volumes[].Links`,不是按 ps 反推的。只读;明细每类最多 20 行(超了给 truncated 与 total)。**删之前先看 containers 是不是 0**:0 才是没人用。老 docker 取不到明细时 degraded=true,汇总仍可用。',
|
|
3605
|
+
parameters: { target: targetParam },
|
|
3606
|
+
output: {
|
|
3607
|
+
schema: {
|
|
3608
|
+
type: 'object',
|
|
3609
|
+
additionalProperties: false,
|
|
3610
|
+
properties: {
|
|
3611
|
+
target: { type: 'string', required: true },
|
|
3612
|
+
summary: {
|
|
3613
|
+
type: 'array',
|
|
3614
|
+
required: true,
|
|
3615
|
+
items: {
|
|
3616
|
+
type: 'object',
|
|
3617
|
+
additionalProperties: false,
|
|
3618
|
+
properties: {
|
|
3619
|
+
type: { type: 'string', required: true },
|
|
3620
|
+
totalCount: { type: 'number', required: true },
|
|
3621
|
+
active: { type: 'number', required: true },
|
|
3622
|
+
size: { type: 'string', required: true },
|
|
3623
|
+
reclaimable: { type: 'string', required: true },
|
|
3624
|
+
},
|
|
3625
|
+
},
|
|
3626
|
+
},
|
|
3627
|
+
images: {
|
|
3628
|
+
type: 'array',
|
|
3629
|
+
required: true,
|
|
3630
|
+
items: {
|
|
3631
|
+
type: 'object',
|
|
3632
|
+
additionalProperties: false,
|
|
3633
|
+
properties: {
|
|
3634
|
+
id: { type: 'string', required: true },
|
|
3635
|
+
repository: { type: 'string', required: true },
|
|
3636
|
+
tag: { type: 'string', required: true },
|
|
3637
|
+
size: { type: 'string', required: true },
|
|
3638
|
+
sharedSize: { type: 'string', required: true },
|
|
3639
|
+
uniqueSize: { type: 'string', required: true },
|
|
3640
|
+
containers: { type: 'number', required: true },
|
|
3641
|
+
},
|
|
3642
|
+
},
|
|
3643
|
+
},
|
|
3644
|
+
volumes: {
|
|
3645
|
+
type: 'array',
|
|
3646
|
+
required: true,
|
|
3647
|
+
items: {
|
|
3648
|
+
type: 'object',
|
|
3649
|
+
additionalProperties: false,
|
|
3650
|
+
properties: {
|
|
3651
|
+
name: { type: 'string', required: true },
|
|
3652
|
+
driver: { type: 'string', required: true },
|
|
3653
|
+
size: { type: 'string', required: true },
|
|
3654
|
+
links: { type: 'number', required: true },
|
|
3655
|
+
},
|
|
3656
|
+
},
|
|
3657
|
+
},
|
|
3658
|
+
buildCache: {
|
|
3659
|
+
type: 'array',
|
|
3660
|
+
required: true,
|
|
3661
|
+
items: {
|
|
3662
|
+
type: 'object',
|
|
3663
|
+
additionalProperties: false,
|
|
3664
|
+
properties: {
|
|
3665
|
+
id: { type: 'string', required: true },
|
|
3666
|
+
type: { type: 'string', required: true },
|
|
3667
|
+
size: { type: 'string', required: true },
|
|
3668
|
+
inUse: { type: 'boolean', required: true },
|
|
3669
|
+
},
|
|
3670
|
+
},
|
|
3671
|
+
},
|
|
3672
|
+
// 截断前的真实条数(每类):工具只回 20 行,模型靠它知道「还有多少」
|
|
3673
|
+
totals: {
|
|
3674
|
+
type: 'object',
|
|
3675
|
+
required: true,
|
|
3676
|
+
additionalProperties: false,
|
|
3677
|
+
properties: {
|
|
3678
|
+
images: { type: 'number', required: true },
|
|
3679
|
+
volumes: { type: 'number', required: true },
|
|
3680
|
+
buildCache: { type: 'number', required: true },
|
|
3681
|
+
},
|
|
3682
|
+
},
|
|
3683
|
+
truncated: {
|
|
3684
|
+
type: 'object',
|
|
3685
|
+
required: true,
|
|
3686
|
+
additionalProperties: false,
|
|
3687
|
+
properties: {
|
|
3688
|
+
images: { type: 'boolean', required: true },
|
|
3689
|
+
volumes: { type: 'boolean', required: true },
|
|
3690
|
+
buildCache: { type: 'boolean', required: true },
|
|
3691
|
+
},
|
|
3692
|
+
},
|
|
3693
|
+
degraded: { type: 'boolean', required: true },
|
|
3694
|
+
degradedReason: { type: 'string' },
|
|
3695
|
+
},
|
|
3696
|
+
},
|
|
3697
|
+
render: (_args, value) => {
|
|
3698
|
+
const v = value;
|
|
3699
|
+
return [{ type: 'text', text: renderDiskUsage(v.target ?? '?', {
|
|
3700
|
+
target: v.target ?? '?',
|
|
3701
|
+
summary: v.summary ?? [],
|
|
3702
|
+
images: v.images ?? [],
|
|
3703
|
+
volumes: v.volumes ?? [],
|
|
3704
|
+
buildCache: v.buildCache ?? [],
|
|
3705
|
+
totals: v.totals ?? { images: 0, volumes: 0, buildCache: 0 },
|
|
3706
|
+
truncated: v.truncated ?? { images: false, volumes: false, buildCache: false },
|
|
3707
|
+
degraded: v.degraded === true,
|
|
3708
|
+
...(v.degradedReason === undefined ? {} : { degradedReason: v.degradedReason }),
|
|
3709
|
+
}) }];
|
|
3710
|
+
},
|
|
3711
|
+
},
|
|
3712
|
+
async execute(args) {
|
|
3713
|
+
const input = (args ?? {});
|
|
3714
|
+
const picked = pickTarget(input.target);
|
|
3715
|
+
if (picked.name === undefined)
|
|
3716
|
+
throw new Error(picked.error ?? '无效的 target');
|
|
3717
|
+
const { api } = apiFor(picked.name);
|
|
3718
|
+
if (api === undefined)
|
|
3719
|
+
throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
|
|
3720
|
+
const usage = await api.systemDf();
|
|
3721
|
+
// 工具是给模型看的:每类只回 20 行(宿主侧上限 100,客户端看全量)
|
|
3722
|
+
const take = (list) => list.slice(0, DISK_TOOL_ROWS);
|
|
3723
|
+
return {
|
|
3724
|
+
target: picked.name,
|
|
3725
|
+
summary: usage.summary,
|
|
3726
|
+
images: take(usage.images),
|
|
3727
|
+
volumes: take(usage.volumes),
|
|
3728
|
+
buildCache: take(usage.buildCache),
|
|
3729
|
+
totals: usage.totals,
|
|
3730
|
+
truncated: {
|
|
3731
|
+
images: usage.truncated.images || usage.images.length > DISK_TOOL_ROWS,
|
|
3732
|
+
volumes: usage.truncated.volumes || usage.volumes.length > DISK_TOOL_ROWS,
|
|
3733
|
+
buildCache: usage.truncated.buildCache || usage.buildCache.length > DISK_TOOL_ROWS,
|
|
3734
|
+
},
|
|
3735
|
+
degraded: usage.degraded,
|
|
3736
|
+
...(usage.degradedReason === null ? {} : { degradedReason: usage.degradedReason }),
|
|
3737
|
+
};
|
|
3738
|
+
},
|
|
3739
|
+
}));
|
|
3013
3740
|
if (live.allowMutations) {
|
|
3014
3741
|
add('docker_action', defineTool({
|
|
3015
3742
|
name: 'docker_action',
|
|
@@ -4138,7 +4865,7 @@ const plugin = definePlugin({
|
|
|
4138
4865
|
// D97:`/action` 的 id、`/stats` 的 ids[] 与 `/exec` 的空 command 原先漏在外,
|
|
4139
4866
|
// 于是同一类错误在有的路由是 400、有的是 500。
|
|
4140
4867
|
try {
|
|
4141
|
-
if ((sub === '/inspect' || sub === '/logs' || sub === '/exec' || sub === '/action') && typeof body.id === 'string')
|
|
4868
|
+
if ((sub === '/inspect' || sub === '/logs' || sub === '/exec' || sub === '/action' || sub === '/top' || sub === '/diagnose' || sub === '/run-command') && typeof body.id === 'string')
|
|
4142
4869
|
assertRef(body.id, 'container');
|
|
4143
4870
|
if (sub === '/stats' && Array.isArray(body.ids)) {
|
|
4144
4871
|
for (const id of body.ids)
|
|
@@ -4151,6 +4878,10 @@ const plugin = definePlugin({
|
|
|
4151
4878
|
assertName(body.name, 'network');
|
|
4152
4879
|
if ((sub === '/volumes/inspect' || sub === '/volumes/remove') && typeof body.name === 'string')
|
|
4153
4880
|
assertName(body.name, 'volume');
|
|
4881
|
+
// /diagnose 的 since 也在这里先校验(与 /logs 的 since 同一口径,D45):放进
|
|
4882
|
+
// switch 里抛的话会被外层 catch 写成 500,而非法 since 是**客户端**错误。
|
|
4883
|
+
if (sub === '/diagnose' && typeof body.since === 'string' && body.since.trim() !== '')
|
|
4884
|
+
assertSince(body.since);
|
|
4154
4885
|
if (sub === '/exec' && typeof body.command === 'string' && body.command.trim() === '') {
|
|
4155
4886
|
writeJson(res, 400, { error: 'command 不能为空' });
|
|
4156
4887
|
return;
|
|
@@ -4211,6 +4942,47 @@ const plugin = definePlugin({
|
|
|
4211
4942
|
});
|
|
4212
4943
|
return;
|
|
4213
4944
|
}
|
|
4945
|
+
// 批一的两条只读路由:位置紧挨 /logs /stats(同一段「单容器采集」语义)。
|
|
4946
|
+
// 都不是 MUTATION_SUBROUTES 成员——只读、不走审计、不要求同源证明。
|
|
4947
|
+
case '/top': {
|
|
4948
|
+
if (typeof body.id !== 'string') {
|
|
4949
|
+
writeJson(res, 400, { error: 'id 必填' });
|
|
4950
|
+
return;
|
|
4951
|
+
}
|
|
4952
|
+
writeJson(res, 200, { ok: true, top: await api.top(body.id) });
|
|
4953
|
+
return;
|
|
4954
|
+
}
|
|
4955
|
+
case '/diagnose': {
|
|
4956
|
+
if (typeof body.id !== 'string') {
|
|
4957
|
+
writeJson(res, 400, { error: 'id 必填' });
|
|
4958
|
+
return;
|
|
4959
|
+
}
|
|
4960
|
+
/*
|
|
4961
|
+
* 批三:多一个 `history` 字段(最近 N 条样本 + 趋势)。**没有新路由**——历史不是新的
|
|
4962
|
+
* 采集面,只是同一份诊断的第二种视图(理由见 `diagnoseWithHistory` 的注释)。
|
|
4963
|
+
*
|
|
4964
|
+
* logTail / since 的默认值与夹紧仍在 api.diagnose 里做(agent 工具同一份语义);
|
|
4965
|
+
* 非法 since 已经在上面的引用白名单段拦成 400。
|
|
4966
|
+
*/
|
|
4967
|
+
const { diagnosis, history } = await diagnoseWithHistory(api, picked.name, body.id, {
|
|
4968
|
+
logTail: body.logTail,
|
|
4969
|
+
since: body.since,
|
|
4970
|
+
history: body.history,
|
|
4971
|
+
});
|
|
4972
|
+
// 三路降级的**原因**不进 HTTP 契约(客户端按 evidence 的 null 渲染 `—`,
|
|
4973
|
+
// findings 里已有 *-unavailable 说明);这里只回契约里的 diagnosis + history。
|
|
4974
|
+
writeJson(res, 200, { ok: true, diagnosis: { ...diagnosis, history } });
|
|
4975
|
+
return;
|
|
4976
|
+
}
|
|
4977
|
+
// 批二:现场还原。只读(inspect + 纯文本推导),不执行任何命令、不走审计。
|
|
4978
|
+
case '/run-command': {
|
|
4979
|
+
if (typeof body.id !== 'string') {
|
|
4980
|
+
writeJson(res, 400, { error: 'id 必填' });
|
|
4981
|
+
return;
|
|
4982
|
+
}
|
|
4983
|
+
writeJson(res, 200, { ok: true, recreate: await api.recreate(body.id) });
|
|
4984
|
+
return;
|
|
4985
|
+
}
|
|
4214
4986
|
case '/images': {
|
|
4215
4987
|
writeJson(res, 200, { ok: true, images: await api.images() });
|
|
4216
4988
|
return;
|
|
@@ -4283,6 +5055,24 @@ const plugin = definePlugin({
|
|
|
4283
5055
|
writeJson(res, 200, { ok: true, volumes: await api.volumes() });
|
|
4284
5056
|
return;
|
|
4285
5057
|
}
|
|
5058
|
+
// 批二:磁盘占用(只读)。`totals` 是宿主内部字段(工具用它报「还有多少」),
|
|
5059
|
+
// 契约 §2.1 里没有它,所以这里显式挑字段,不做整对象透传。
|
|
5060
|
+
case '/system-df': {
|
|
5061
|
+
const usage = await api.systemDf();
|
|
5062
|
+
writeJson(res, 200, {
|
|
5063
|
+
ok: true,
|
|
5064
|
+
disk: {
|
|
5065
|
+
summary: usage.summary,
|
|
5066
|
+
images: usage.images,
|
|
5067
|
+
volumes: usage.volumes,
|
|
5068
|
+
buildCache: usage.buildCache,
|
|
5069
|
+
truncated: usage.truncated,
|
|
5070
|
+
degraded: usage.degraded,
|
|
5071
|
+
degradedReason: usage.degradedReason,
|
|
5072
|
+
},
|
|
5073
|
+
});
|
|
5074
|
+
return;
|
|
5075
|
+
}
|
|
4286
5076
|
case '/volumes/inspect': {
|
|
4287
5077
|
if (typeof body.name !== 'string' || body.name.trim() === '') {
|
|
4288
5078
|
writeJson(res, 400, { error: 'name 必填' });
|