oh-my-knowledge 0.35.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/README.zh.md +5 -3
- package/dist/assets/agent-skills/omk/SKILL.md +4 -2
- package/dist/assets/agent-skills/omk/references/commands.md +121 -2
- package/dist/authoring/evolver.js +1 -1
- package/dist/cli/commands/doctor.js +21 -5
- package/dist/cli/commands/eval/index.js +7 -6
- package/dist/cli/commands/evolve.d.ts +11 -0
- package/dist/cli/commands/evolve.js +105 -5
- package/dist/cli/commands/list.d.ts +25 -0
- package/dist/cli/commands/list.js +118 -0
- package/dist/cli/commands/promote.d.ts +24 -0
- package/dist/cli/commands/promote.js +158 -0
- package/dist/cli/commands/rollback.d.ts +20 -0
- package/dist/cli/commands/rollback.js +95 -0
- package/dist/cli/commands/sample.d.ts +1 -0
- package/dist/cli/commands/sample.js +10 -4
- package/dist/cli/lib/cell-format.d.ts +17 -0
- package/dist/cli/lib/cell-format.js +19 -0
- package/dist/cli/lib/cmd-flags.d.ts +1 -0
- package/dist/cli/lib/i18n-dict/common.d.ts +1 -1
- package/dist/cli/lib/i18n-dict/common.js +8 -0
- package/dist/cli/lib/i18n-dict/list.d.ts +3 -0
- package/dist/cli/lib/i18n-dict/list.js +36 -0
- package/dist/cli/lib/i18n-dict/promote.d.ts +3 -0
- package/dist/cli/lib/i18n-dict/promote.js +54 -0
- package/dist/cli/lib/i18n-dict/rollback.d.ts +3 -0
- package/dist/cli/lib/i18n-dict/rollback.js +22 -0
- package/dist/cli/lib/i18n-dict.d.ts +4 -1
- package/dist/cli/lib/i18n-dict.js +6 -0
- package/dist/cli/lib/progress.d.ts +3 -0
- package/dist/cli/lib/progress.js +22 -0
- package/dist/cli/lib/run-tally.js +1 -1
- package/dist/cli/lib/shared.js +1 -1
- package/dist/cli/lib/source-probe.d.ts +15 -0
- package/dist/cli/lib/source-probe.js +129 -0
- package/dist/doctor/endpoint-rule.d.ts +60 -0
- package/dist/doctor/endpoint-rule.js +381 -0
- package/dist/doctor/health/load-custom-dimensions.d.ts +6 -0
- package/dist/doctor/health/load-custom-dimensions.js +43 -3
- package/dist/doctor/index.js +9 -2
- package/dist/eval-core/evaluation-reporting.d.ts +1 -0
- package/dist/eval-core/evaluation-reporting.js +6 -4
- package/dist/eval-workflows/batch-evaluation-workflow.js +3 -2
- package/dist/eval-workflows/run-evaluation.js +2 -2
- package/dist/executors/codex-cli.js +5 -1
- package/dist/inputs/skill-loader.js +6 -3
- package/dist/managed/index.d.ts +2 -0
- package/dist/managed/index.js +2 -0
- package/dist/managed/list-view.d.ts +58 -0
- package/dist/managed/list-view.js +70 -0
- package/dist/managed/promote-gate.d.ts +40 -0
- package/dist/managed/promote-gate.js +37 -0
- package/dist/managed/store.d.ts +25 -2
- package/dist/managed/store.js +137 -11
- package/dist/observability/experience.d.ts +2 -0
- package/dist/observability/experience.js +31 -2
- package/dist/observability/inbox.js +31 -5
- package/dist/observability/review-state.js +22 -11
- package/dist/observability/soft-standards/llm-extractor.js +3 -3
- package/dist/observability/soft-standards/skill-standards-store.d.ts +1 -0
- package/dist/observability/soft-standards/skill-standards-store.js +77 -14
- package/dist/observability/soft-standards/types.d.ts +2 -2
- package/dist/renderer/doctor-detail-renderer.d.ts +9 -0
- package/dist/renderer/doctor-detail-renderer.js +114 -0
- package/dist/renderer/html-renderer.d.ts +3 -2
- package/dist/renderer/html-renderer.js +102 -102
- package/dist/renderer/icons.d.ts +29 -0
- package/dist/renderer/icons.js +66 -0
- package/dist/renderer/layout.js +81 -68
- package/dist/renderer/observation-inbox/styles.d.ts +1 -1
- package/dist/renderer/observation-inbox/styles.js +53 -53
- package/dist/renderer/report-shell.d.ts +77 -0
- package/dist/renderer/report-shell.js +223 -0
- package/dist/renderer/skill-detail-renderer.js +147 -352
- package/dist/renderer/skill-health-renderer.js +50 -73
- package/dist/renderer/skill-list-renderer.js +358 -306
- package/dist/renderer/summary.js +272 -162
- package/dist/renderer/test-view.d.ts +4 -3
- package/dist/renderer/test-view.js +386 -135
- package/dist/server/report-server.js +142 -28
- package/dist/server/report-store.d.ts +1 -1
- package/dist/server/report-store.js +16 -15
- package/dist/server/skill-index.js +5 -4
- package/dist/types/doctor.d.ts +24 -2
- package/dist/types/doctor.js +1 -1
- package/dist/types/managed.d.ts +17 -3
- package/dist/types/observability.d.ts +6 -6
- package/dist/types/report.d.ts +9 -4
- package/package.json +1 -1
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* endpoint-rule — 接口驱动的自定义检查维度。
|
|
3
|
+
*
|
|
4
|
+
* 普通自定义维度走 LLM health composer(promptSection 喂 prompt);带 `endpoint`
|
|
5
|
+
* 的维度则注册成独立 DoctorRule:doctor 运行时把 skill 完整快照 POST 给用户的
|
|
6
|
+
* 接口,接口按协议返回判定结果。适合"调外部服务做深度审查"(eg. 安全风险审查)
|
|
7
|
+
* 这类靠 prompt 表达不了、需要真实逻辑/模型的场景。
|
|
8
|
+
*
|
|
9
|
+
* 请求协议(doctor → endpoint):
|
|
10
|
+
* POST <endpoint> Content-Type: application/json
|
|
11
|
+
* {
|
|
12
|
+
* "dimensionId": "deep-security-audit",
|
|
13
|
+
* "params": { ... }, // YAML 里用户透传的自定义参数
|
|
14
|
+
* "skill": {
|
|
15
|
+
* "name": "my-skill",
|
|
16
|
+
* "content": "SKILL.md 全文", // file-skill / directory-skill 主文件
|
|
17
|
+
* "skillRoot": "/abs/path" | null, // git / inline 来源可能为 null
|
|
18
|
+
* "ref": "abc1234" | null, // git 来源的 commit
|
|
19
|
+
* "files": { "references/x.md": "...", "scripts/y.sh": "..." } // 子文件快照
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* 响应协议(endpoint → doctor):
|
|
24
|
+
* { "status": "pass" | "warn" | "fail", "message": "...", "hint"?: "...", "detail"?: {...} }
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, readFileSync, readdirSync, lstatSync, openSync, readSync, closeSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
const DEFAULT_MAX_FILE_BYTES = 200 * 1024;
|
|
29
|
+
const DEFAULT_MAX_TOTAL_BYTES = 2 * 1024 * 1024;
|
|
30
|
+
/** 二进制 / 大文件不可读时跳过;只收文本。简单按扩展名 + 内容嗅探。 */
|
|
31
|
+
const SKIP_EXTENSIONS = new Set([
|
|
32
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.pdf',
|
|
33
|
+
'.zip', '.gz', '.tar', '.tgz', '.exe', '.bin', '.so', '.dylib',
|
|
34
|
+
'.woff', '.woff2', '.ttf', '.mp4', '.mov', '.mp3', '.wav',
|
|
35
|
+
]);
|
|
36
|
+
function extOf(name) {
|
|
37
|
+
const i = name.lastIndexOf('.');
|
|
38
|
+
return i >= 0 ? name.slice(i).toLowerCase() : '';
|
|
39
|
+
}
|
|
40
|
+
/** 收集 skillRoot 下的子文件内容(排除主 SKILL.md / 隐藏文件 / node_modules)。
|
|
41
|
+
* 受 maxFileBytes / maxTotalBytes 双重限制,避免 payload 过大。 */
|
|
42
|
+
function collectFiles(skillRoot, maxFileBytes, maxTotalBytes) {
|
|
43
|
+
const files = {};
|
|
44
|
+
if (!skillRoot || !existsSync(skillRoot))
|
|
45
|
+
return files;
|
|
46
|
+
let total = 0;
|
|
47
|
+
const walk = (dir, rel, depth) => {
|
|
48
|
+
if (depth > 3 || total >= maxTotalBytes)
|
|
49
|
+
return;
|
|
50
|
+
let entries = [];
|
|
51
|
+
try {
|
|
52
|
+
entries = readdirSync(dir);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (total >= maxTotalBytes)
|
|
59
|
+
return;
|
|
60
|
+
if (entry.startsWith('.'))
|
|
61
|
+
continue;
|
|
62
|
+
if (entry === 'node_modules')
|
|
63
|
+
continue;
|
|
64
|
+
if (entry === 'SKILL.md' && rel === '')
|
|
65
|
+
continue;
|
|
66
|
+
const full = join(dir, entry);
|
|
67
|
+
const relPath = rel ? `${rel}/${entry}` : entry;
|
|
68
|
+
let st;
|
|
69
|
+
// lstat(不跟随 symlink):跳过符号链接,防止 skillRoot 下的 link 指向 root 外
|
|
70
|
+
// (如 /etc/passwd)被读取并随 payload 外发。
|
|
71
|
+
try {
|
|
72
|
+
st = lstatSync(full);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (st.isSymbolicLink())
|
|
78
|
+
continue;
|
|
79
|
+
if (st.isDirectory()) {
|
|
80
|
+
walk(full, relPath, depth + 1);
|
|
81
|
+
}
|
|
82
|
+
else if (st.isFile()) {
|
|
83
|
+
if (SKIP_EXTENSIONS.has(extOf(entry)))
|
|
84
|
+
continue;
|
|
85
|
+
let buf;
|
|
86
|
+
if (st.size > maxFileBytes) {
|
|
87
|
+
// 大文件守卫:不整文件读入(超大文件会撑内存,超 Buffer 上限的 throw
|
|
88
|
+
// 还会被 catch 静默跳过),只读前 maxFileBytes + 1 字节,NUL 嗅探与
|
|
89
|
+
// 截断都在这个前缀上做。
|
|
90
|
+
let fd;
|
|
91
|
+
try {
|
|
92
|
+
fd = openSync(full, 'r');
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const head = Buffer.allocUnsafe(maxFileBytes + 1);
|
|
99
|
+
const n = readSync(fd, head, 0, head.length, 0);
|
|
100
|
+
buf = head.subarray(0, n);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
closeSync(fd);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
try {
|
|
111
|
+
buf = readFileSync(full);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// 内容嗅探:含 NUL 字节 → 二进制,跳过(扩展名漏网的 binary 兜底)。
|
|
118
|
+
if (buf.includes(0))
|
|
119
|
+
continue;
|
|
120
|
+
// 按字节截断(中文等多字节内容也不会冲穿上限);单文件受 maxFileBytes 与
|
|
121
|
+
// 剩余总预算双重收口,超限只收前缀,toString 会把尾部可能被切断的半个
|
|
122
|
+
// UTF-8 字符替换成 U+FFFD。total 按实际写入的字节数累计(含截断标记与
|
|
123
|
+
// U+FFFD 展开),所以 maxTotalBytes 至多被末文件冲破十几个字节(常数级)。
|
|
124
|
+
const cap = Math.min(maxFileBytes, maxTotalBytes - total);
|
|
125
|
+
const over = buf.length > cap;
|
|
126
|
+
const slice = over ? buf.subarray(0, cap) : buf;
|
|
127
|
+
const text = slice.toString('utf-8') + (over ? '\n…[truncated]' : '');
|
|
128
|
+
files[relPath] = text;
|
|
129
|
+
total += Buffer.byteLength(text, 'utf-8');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
walk(skillRoot, '', 0);
|
|
134
|
+
return files;
|
|
135
|
+
}
|
|
136
|
+
function isValidStatus(s) {
|
|
137
|
+
return s === 'pass' || s === 'warn' || s === 'fail';
|
|
138
|
+
}
|
|
139
|
+
/** ::ffff:a.b.c.d / 规范化后的 ::ffff:hhhh:hhhh 还原成点分 IPv4;无法解析返回 null。
|
|
140
|
+
* WHATWG URL 会把 ::ffff:127.0.0.1 规范成 hex 形态 ::ffff:7f00:1。 */
|
|
141
|
+
function mappedToIPv4(suffix) {
|
|
142
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(suffix))
|
|
143
|
+
return suffix;
|
|
144
|
+
const hx = /^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(suffix);
|
|
145
|
+
if (!hx)
|
|
146
|
+
return null;
|
|
147
|
+
const hi = parseInt(hx[1], 16);
|
|
148
|
+
const lo = parseInt(hx[2], 16);
|
|
149
|
+
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
|
|
150
|
+
}
|
|
151
|
+
/** 私网/本机 hostname 判定(SSRF 防护用)。只做字面 hostname 检查的
|
|
152
|
+
* defense-in-depth:不做 DNS 解析,公网域名解析到内网(DNS rebinding)不在
|
|
153
|
+
* 防护范围。WHATWG URL 会把 0x7f.0.0.1 / 0 之类写法规整成点分十进制,所以
|
|
154
|
+
* IPv4 直接按规范化后的 hostname 判断即可。 */
|
|
155
|
+
function isPrivateHostname(hostname) {
|
|
156
|
+
// 转小写、去 IPv6 字面量方括号([::1] → ::1)、去 FQDN 尾点(localhost. / foo.local.)。
|
|
157
|
+
let h = hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
|
158
|
+
if (h === 'localhost' || h.endsWith('.local'))
|
|
159
|
+
return true;
|
|
160
|
+
if (h.includes(':')) {
|
|
161
|
+
// IPv6 字面量。
|
|
162
|
+
if (h === '::1' || h === '::')
|
|
163
|
+
return true; // loopback / unspecified
|
|
164
|
+
if (/^f[cd]/.test(h))
|
|
165
|
+
return true; // fc00::/7 ULA
|
|
166
|
+
if (/^fe[89ab]/.test(h))
|
|
167
|
+
return true; // fe80::/10 link-local
|
|
168
|
+
// IPv4-mapped(::ffff:…)归并到点分 IPv4 再判;无法解析的保守拒绝。
|
|
169
|
+
const mapped = /^::ffff:(.+)$/.exec(h);
|
|
170
|
+
if (!mapped)
|
|
171
|
+
return false;
|
|
172
|
+
const dotted = mappedToIPv4(mapped[1]);
|
|
173
|
+
if (dotted == null)
|
|
174
|
+
return true;
|
|
175
|
+
h = dotted;
|
|
176
|
+
}
|
|
177
|
+
const m = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(h);
|
|
178
|
+
if (!m)
|
|
179
|
+
return false;
|
|
180
|
+
const a = Number(m[1]);
|
|
181
|
+
const b = Number(m[2]);
|
|
182
|
+
if (a === 0)
|
|
183
|
+
return true; // 0.0.0.0/8(含 0.0.0.0、http://0/)
|
|
184
|
+
if (a === 127)
|
|
185
|
+
return true; // 127.0.0.0/8 loopback
|
|
186
|
+
if (a === 10)
|
|
187
|
+
return true; // 10.0.0.0/8
|
|
188
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
189
|
+
return true; // 172.16.0.0/12
|
|
190
|
+
if (a === 192 && b === 168)
|
|
191
|
+
return true; // 192.168.0.0/16
|
|
192
|
+
if (a === 169 && b === 254)
|
|
193
|
+
return true; // 169.254.0.0/16(含云 metadata 169.254.169.254)
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
/** outcome 自由文本/JSON 字段的统一上限(字符),与协议违规路径的 received 截断对齐。 */
|
|
197
|
+
const MAX_OUTCOME_CHARS = 2000;
|
|
198
|
+
/** 响应体声明长度(Content-Length)上限(字节):超过即拒读,避免超大 body 撑爆内存。
|
|
199
|
+
* 注意只挡声明了 Content-Length 的情况;分块且不声明长度的响应仍只受 timeoutMs 墙钟约束。 */
|
|
200
|
+
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
201
|
+
function clampText(s) {
|
|
202
|
+
return s.length > MAX_OUTCOME_CHARS ? `${s.slice(0, MAX_OUTCOME_CHARS)}…[truncated]` : s;
|
|
203
|
+
}
|
|
204
|
+
/** detail 先序列化判长:超限替换成 { truncated: true, preview } 结构,
|
|
205
|
+
* 保证仍是合法 JSON 值且不会无界塞进 report。 */
|
|
206
|
+
function clampDetail(detail) {
|
|
207
|
+
const json = JSON.stringify(detail) ?? '';
|
|
208
|
+
if (json.length <= MAX_OUTCOME_CHARS)
|
|
209
|
+
return detail;
|
|
210
|
+
return { truncated: true, preview: json.slice(0, MAX_OUTCOME_CHARS) };
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* 把一个 endpoint spec 编译成 DoctorRule。check() 内组装 skill 快照、POST、
|
|
214
|
+
* 校验响应协议并映射成 DoctorRuleCheckOutcome。
|
|
215
|
+
*
|
|
216
|
+
* 所有失败(网络错误 / 非 2xx / 非法 JSON / 协议字段缺失)都返回 status='fail',
|
|
217
|
+
* 让用户立刻看到接口侧的问题,而不是静默放行。
|
|
218
|
+
*/
|
|
219
|
+
export function makeEndpointRule(spec, fetchFn = fetch) {
|
|
220
|
+
return {
|
|
221
|
+
id: spec.id,
|
|
222
|
+
severity: spec.severity,
|
|
223
|
+
// 自定义 key 不在 DOCTOR_MESSAGES 字典里,renderer 会 fallback 到 ruleId(=spec.id)。
|
|
224
|
+
labelKey: `cli.doctor.endpoint.${spec.id}`,
|
|
225
|
+
// 网络检查,与 health composer 同档:默认跑,--static-only 跳过。
|
|
226
|
+
external: true,
|
|
227
|
+
async check(ctx) {
|
|
228
|
+
// SSRF 防护(defense-in-depth):endpoint 会收到 skill 完整快照,且响应
|
|
229
|
+
// 原样回填进报告(response oracle),所以组装请求前先校验 scheme 与 host,
|
|
230
|
+
// 私网/本机地址默认拒绝(云 metadata 169.254.169.254、内网主机等)。
|
|
231
|
+
// 只做字面校验、不做 DNS 解析:公网域名解析到内网(DNS rebinding)不在
|
|
232
|
+
// 防护范围。
|
|
233
|
+
let endpointUrl;
|
|
234
|
+
try {
|
|
235
|
+
endpointUrl = new URL(spec.endpoint);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return {
|
|
239
|
+
status: 'fail',
|
|
240
|
+
message: failMsg(ctx, spec, `endpoint 不是合法 URL:${spec.endpoint}`, `endpoint is not a valid URL: ${spec.endpoint}`),
|
|
241
|
+
detail: { endpoint: spec.endpoint },
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
if (endpointUrl.protocol !== 'http:' && endpointUrl.protocol !== 'https:') {
|
|
245
|
+
return {
|
|
246
|
+
status: 'fail',
|
|
247
|
+
message: failMsg(ctx, spec, `endpoint 协议必须是 http/https,实际是 ${endpointUrl.protocol}`, `endpoint protocol must be http/https, got ${endpointUrl.protocol}`),
|
|
248
|
+
detail: { endpoint: spec.endpoint, protocol: endpointUrl.protocol },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (!spec.allowPrivateHost && isPrivateHostname(endpointUrl.hostname)) {
|
|
252
|
+
return {
|
|
253
|
+
status: 'fail',
|
|
254
|
+
message: failMsg(ctx, spec, `endpoint 指向私网/本机地址(${endpointUrl.hostname}),默认拒绝以防 SSRF(skill 快照会被外发、响应会回填进报告);确认该内网服务可信后,可在维度配置加 allowPrivateHost: true 放行`, `endpoint points to a private/loopback host (${endpointUrl.hostname}); refused by default to prevent SSRF (the skill snapshot is sent out and the response is echoed into the report). Set allowPrivateHost: true in the dimension config if this internal service is trusted`),
|
|
255
|
+
detail: { endpoint: spec.endpoint, hostname: endpointUrl.hostname },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
const artifact = ctx.artifact;
|
|
259
|
+
const content = artifact.content ?? '';
|
|
260
|
+
const skillName = artifact.name.replace(/\.md$/, '').split('/').pop() ?? artifact.name;
|
|
261
|
+
const skillRoot = artifact.skillRoot ?? null;
|
|
262
|
+
const includeFiles = spec.includeFiles !== false;
|
|
263
|
+
const files = includeFiles
|
|
264
|
+
? collectFiles(skillRoot, spec.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES, spec.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES)
|
|
265
|
+
: {};
|
|
266
|
+
const body = {
|
|
267
|
+
dimensionId: spec.id,
|
|
268
|
+
params: spec.params ?? {},
|
|
269
|
+
skill: {
|
|
270
|
+
name: skillName,
|
|
271
|
+
content,
|
|
272
|
+
skillRoot,
|
|
273
|
+
ref: artifact.ref ?? null,
|
|
274
|
+
files,
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
const controller = new AbortController();
|
|
278
|
+
const timer = setTimeout(() => controller.abort(), ctx.timeoutMs);
|
|
279
|
+
let parsed;
|
|
280
|
+
try {
|
|
281
|
+
let res;
|
|
282
|
+
try {
|
|
283
|
+
res = await fetchFn(spec.endpoint, {
|
|
284
|
+
method: 'POST',
|
|
285
|
+
headers: { 'Content-Type': 'application/json', ...spec.headers },
|
|
286
|
+
body: JSON.stringify(body),
|
|
287
|
+
signal: controller.signal,
|
|
288
|
+
// redirect:'manual' —— 不跟随重定向。否则一个可信公网 endpoint 返回
|
|
289
|
+
// 302 Location: http://169.254.169.254/… 即可让 fetch 透明跳到私网,
|
|
290
|
+
// 绕过上面的 host 校验(请求前只校验一次原始 URL)。3xx 一律拒绝。
|
|
291
|
+
redirect: 'manual',
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
296
|
+
return {
|
|
297
|
+
status: 'fail',
|
|
298
|
+
message: failMsg(ctx, spec, `请求失败:${msg}`, `request failed: ${msg}`),
|
|
299
|
+
hint: hintNet(ctx, spec.endpoint),
|
|
300
|
+
detail: { endpoint: spec.endpoint, error: msg },
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (res.status >= 300 && res.status < 400) {
|
|
304
|
+
const location = res.headers?.get?.('location') ?? null;
|
|
305
|
+
return {
|
|
306
|
+
status: 'fail',
|
|
307
|
+
message: failMsg(ctx, spec, `endpoint 返回重定向(HTTP ${res.status}),默认拒绝以防 SSRF(重定向目标可能指向私网)`, `endpoint returned a redirect (HTTP ${res.status}); refused by default to prevent SSRF (the redirect target may point to a private host)`),
|
|
308
|
+
hint: hintNet(ctx, spec.endpoint),
|
|
309
|
+
detail: { endpoint: spec.endpoint, httpStatus: res.status, location },
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const declaredLen = Number(res.headers?.get?.('content-length') ?? '');
|
|
313
|
+
if (Number.isFinite(declaredLen) && declaredLen > MAX_RESPONSE_BYTES) {
|
|
314
|
+
return {
|
|
315
|
+
status: 'fail',
|
|
316
|
+
message: failMsg(ctx, spec, `响应体过大(Content-Length ${declaredLen} 字节,上限 ${MAX_RESPONSE_BYTES})`, `response body too large (Content-Length ${declaredLen} bytes, limit ${MAX_RESPONSE_BYTES})`),
|
|
317
|
+
hint: hintProto(ctx),
|
|
318
|
+
detail: { endpoint: spec.endpoint, contentLength: declaredLen },
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (!res.ok) {
|
|
322
|
+
return {
|
|
323
|
+
status: 'fail',
|
|
324
|
+
message: failMsg(ctx, spec, `接口返回 HTTP ${res.status}`, `endpoint returned HTTP ${res.status}`),
|
|
325
|
+
hint: hintNet(ctx, spec.endpoint),
|
|
326
|
+
detail: { endpoint: spec.endpoint, httpStatus: res.status },
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
parsed = await res.json();
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
334
|
+
return {
|
|
335
|
+
status: 'fail',
|
|
336
|
+
message: failMsg(ctx, spec, `响应不是合法 JSON:${msg}`, `response is not valid JSON: ${msg}`),
|
|
337
|
+
hint: hintProto(ctx),
|
|
338
|
+
detail: { endpoint: spec.endpoint, error: msg },
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
// timer 覆盖 fetch + body 读取(res.json()):两者都完成或任一出错后才清,
|
|
344
|
+
// 防止慢速/滴流响应体绕过 ctx.timeoutMs 无限挂起。
|
|
345
|
+
clearTimeout(timer);
|
|
346
|
+
}
|
|
347
|
+
const resp = parsed;
|
|
348
|
+
if (!isValidStatus(resp.status) || typeof resp.message !== 'string') {
|
|
349
|
+
return {
|
|
350
|
+
status: 'fail',
|
|
351
|
+
message: failMsg(ctx, spec, '响应缺少必填字段 status(pass/warn/fail) 或 message', 'response missing required field status (pass/warn/fail) or message'),
|
|
352
|
+
hint: hintProto(ctx),
|
|
353
|
+
// received 截断:接口可能返回超大 body,避免无界塞进 report JSON。
|
|
354
|
+
detail: { endpoint: spec.endpoint, received: JSON.stringify(parsed).slice(0, MAX_OUTCOME_CHARS) },
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
// 合法响应同样截断:message / hint / detail 都来自外部接口,不加界会被
|
|
358
|
+
// 无界写入 outcome 落盘(与上面 received 的处理风格对齐)。
|
|
359
|
+
return {
|
|
360
|
+
status: resp.status,
|
|
361
|
+
message: `${spec.displayName}: ${clampText(resp.message)}`,
|
|
362
|
+
hint: typeof resp.hint === 'string' ? clampText(resp.hint) : resp.hint,
|
|
363
|
+
detail: resp.detail == null ? { endpoint: spec.endpoint } : clampDetail(resp.detail),
|
|
364
|
+
};
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function failMsg(ctx, spec, zh, en) {
|
|
369
|
+
const head = `${spec.displayName}`;
|
|
370
|
+
return ctx.lang === 'zh' ? `${head}: ${zh}` : `${head}: ${en}`;
|
|
371
|
+
}
|
|
372
|
+
function hintNet(ctx, endpoint) {
|
|
373
|
+
return ctx.lang === 'zh'
|
|
374
|
+
? `确认接口 ${endpoint} 可达、鉴权 header 正确,或调大 --timeout`
|
|
375
|
+
: `Verify endpoint ${endpoint} is reachable, auth headers are correct, or raise --timeout`;
|
|
376
|
+
}
|
|
377
|
+
function hintProto(ctx) {
|
|
378
|
+
return ctx.lang === 'zh'
|
|
379
|
+
? '接口需返回 JSON: { status: "pass"|"warn"|"fail", message: string, hint?, detail? }'
|
|
380
|
+
: 'Endpoint must return JSON: { status: "pass"|"warn"|"fail", message: string, hint?, detail? }';
|
|
381
|
+
}
|
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import yaml from 'js-yaml';
|
|
3
3
|
import { registerHealthDimension } from './dimension-registry.js';
|
|
4
|
+
import { registerRule } from '../rules.js';
|
|
5
|
+
import { makeEndpointRule } from '../endpoint-rule.js';
|
|
4
6
|
const VALID_SEVERITIES = new Set(['fatal', 'warn', 'info']);
|
|
7
|
+
/** 缺省 → warn;非法值直接 throw(与 id 缺失同等对待),避免用户写了
|
|
8
|
+
* `severity: critical` 想要 fatal 却被静默降成 warn、endpoint fail 不再 gate。 */
|
|
9
|
+
function resolveSeverity(dimId, raw) {
|
|
10
|
+
if (raw == null)
|
|
11
|
+
return 'warn'; // 缺省/空值 → 默认 warn
|
|
12
|
+
if (!VALID_SEVERITIES.has(raw)) {
|
|
13
|
+
throw new Error(`自定义维度 ${dimId} 的 severity 非法:${raw}(合法值:fatal / warn / info)`);
|
|
14
|
+
}
|
|
15
|
+
return raw;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 加载自定义维度 YAML 并注册。两类维度按字段自动分流:
|
|
19
|
+
* - 带 `endpoint` → 接口维度,注册成独立 DoctorRule(运行时 POST skill 快照)。
|
|
20
|
+
* - 带 `promptSection` → LLM 维度,注册进 health composer(原逻辑)。
|
|
21
|
+
* 返回成功注册的维度数量。
|
|
22
|
+
*/
|
|
5
23
|
export function loadAndRegisterCustomDimensions(filePath) {
|
|
6
24
|
if (!existsSync(filePath)) {
|
|
7
25
|
throw new Error(`自定义维度配置文件不存在: ${filePath}`);
|
|
@@ -12,10 +30,32 @@ export function loadAndRegisterCustomDimensions(filePath) {
|
|
|
12
30
|
return 0;
|
|
13
31
|
let count = 0;
|
|
14
32
|
for (const dim of doc.dimensions) {
|
|
15
|
-
if (!dim.id || !dim.displayName
|
|
16
|
-
throw new Error(`自定义维度缺少必填字段(id/displayName
|
|
33
|
+
if (!dim.id || !dim.displayName) {
|
|
34
|
+
throw new Error(`自定义维度缺少必填字段(id/displayName): ${JSON.stringify(dim).slice(0, 100)}`);
|
|
35
|
+
}
|
|
36
|
+
if (dim.endpoint && dim.promptSection) {
|
|
37
|
+
throw new Error(`自定义维度 ${dim.id} 不能同时配置 endpoint 与 promptSection(二选一)`);
|
|
38
|
+
}
|
|
39
|
+
const severity = resolveSeverity(dim.id, dim.severity);
|
|
40
|
+
if (dim.endpoint) {
|
|
41
|
+
registerRule(makeEndpointRule({
|
|
42
|
+
id: dim.id,
|
|
43
|
+
displayName: dim.displayName,
|
|
44
|
+
severity,
|
|
45
|
+
endpoint: dim.endpoint,
|
|
46
|
+
params: dim.params,
|
|
47
|
+
includeFiles: dim.includeFiles,
|
|
48
|
+
headers: dim.headers,
|
|
49
|
+
maxFileBytes: dim.maxFileBytes,
|
|
50
|
+
maxTotalBytes: dim.maxTotalBytes,
|
|
51
|
+
allowPrivateHost: dim.allowPrivateHost,
|
|
52
|
+
}));
|
|
53
|
+
count++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (!dim.promptSection) {
|
|
57
|
+
throw new Error(`自定义维度 ${dim.id} 必须配置 endpoint 或 promptSection 之一`);
|
|
17
58
|
}
|
|
18
|
-
const severity = VALID_SEVERITIES.has(dim.severity ?? '') ? dim.severity : 'warn';
|
|
19
59
|
const spec = {
|
|
20
60
|
id: dim.id,
|
|
21
61
|
displayName: dim.displayName,
|
package/dist/doctor/index.js
CHANGED
|
@@ -206,11 +206,17 @@ export async function runDoctor(opts) {
|
|
|
206
206
|
const skillReports = [];
|
|
207
207
|
const totals = { pass: 0, warn: 0, fail: 0 };
|
|
208
208
|
const ruleStats = { pass: 0, warn: 0, fail: 0, skipped: 0, total: 0 };
|
|
209
|
+
const total = artifacts.length;
|
|
210
|
+
let index = 0;
|
|
209
211
|
for (const artifact of artifacts) {
|
|
212
|
+
index += 1;
|
|
213
|
+
const skillName = basename(artifact.name).replace(/\.md$/, '');
|
|
214
|
+
opts.onProgress?.({ phase: 'skill_start', index, total, skillName });
|
|
215
|
+
const startedAt = Date.now();
|
|
210
216
|
const results = await runRulesOnArtifact(artifact, effectiveRules, ctxBase);
|
|
211
217
|
const status = classifySkillStatus(results);
|
|
212
218
|
skillReports.push({
|
|
213
|
-
skillName
|
|
219
|
+
skillName,
|
|
214
220
|
skillPath: inferSkillPath(artifact, opts.cwd),
|
|
215
221
|
results,
|
|
216
222
|
status,
|
|
@@ -220,6 +226,7 @@ export async function runDoctor(opts) {
|
|
|
220
226
|
ruleStats[r.status] += 1;
|
|
221
227
|
ruleStats.total += 1;
|
|
222
228
|
}
|
|
229
|
+
opts.onProgress?.({ phase: 'skill_done', index, total, skillName, status, durationMs: Date.now() - startedAt });
|
|
223
230
|
}
|
|
224
231
|
// Single-enum verdict for CI / agent code. fatal-fail dominates;
|
|
225
232
|
// warnings_only when no fatal-fail but at least one warn; otherwise passed.
|
|
@@ -229,7 +236,7 @@ export async function runDoctor(opts) {
|
|
|
229
236
|
? 'warnings_only'
|
|
230
237
|
: 'passed';
|
|
231
238
|
return {
|
|
232
|
-
|
|
239
|
+
kind: 'doctor',
|
|
233
240
|
schemaVersion: DOCTOR_REPORT_SCHEMA_VERSION,
|
|
234
241
|
id: nextReportId(),
|
|
235
242
|
timestamp: new Date().toISOString(),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Artifact, Report, Sample, Task, VariantResult, GitInfo, EvaluationJob, EvaluationRequest, EvaluationRun } from '../types/index.js';
|
|
2
2
|
export declare const DEFAULT_OUTPUT_DIR: string;
|
|
3
|
+
export declare const EVALUATION_REPORT_SCHEMA_VERSION = 4;
|
|
3
4
|
export declare function hashString(str: string): string;
|
|
4
5
|
/**
|
|
5
6
|
* Stable content hash of a sample. Hashes the prompt + assertions + dimensions/rubric
|
|
@@ -22,6 +22,7 @@ function findPackageJson(startDir) {
|
|
|
22
22
|
}
|
|
23
23
|
const PKG = JSON.parse(readFileSync(findPackageJson(__dirname), 'utf-8'));
|
|
24
24
|
export const DEFAULT_OUTPUT_DIR = join(homedir(), '.oh-my-knowledge', 'reports');
|
|
25
|
+
export const EVALUATION_REPORT_SCHEMA_VERSION = 4;
|
|
25
26
|
export function hashString(str) {
|
|
26
27
|
return createHash('sha256').update(str).digest('hex').slice(0, 12);
|
|
27
28
|
}
|
|
@@ -182,7 +183,7 @@ export function aggregateReport({ runId, variants, model, judgeModel, noJudge, e
|
|
|
182
183
|
debiasModeList.push('length');
|
|
183
184
|
const totalCostReported = Object.values(summary).every((variant) => variant.execCostReported !== false && variant.judgeCostReported !== false);
|
|
184
185
|
return {
|
|
185
|
-
|
|
186
|
+
kind: 'evaluation',
|
|
186
187
|
id: runId,
|
|
187
188
|
meta: {
|
|
188
189
|
variants,
|
|
@@ -197,9 +198,10 @@ export function aggregateReport({ runId, variants, model, judgeModel, noJudge, e
|
|
|
197
198
|
cliVersion: getCliVersion(),
|
|
198
199
|
nodeVersion: process.version,
|
|
199
200
|
// schemaVersion 3 起,所有 dir-skill(本地 + git)都经隔离副本物化、整棵可分发树哈,与 install
|
|
200
|
-
// 受管记录 contentHash 同空间(evidence 全绑)。
|
|
201
|
-
//
|
|
202
|
-
|
|
201
|
+
// 受管记录 contentHash 同空间(evidence 全绑)。4 延续 v3 的哈/绑定义,并作为当前 canonical
|
|
202
|
+
// 顶层判别字段纪元,方便外部消费方按版本识别 JSON 形状。2 是过渡纪元(本地 dir-skill 树哈、
|
|
203
|
+
// git dir-skill 仅 SKILL.md 字节、不绑);git dir-skill 的 v2 与 v3+ 不可比。
|
|
204
|
+
schemaVersion: EVALUATION_REPORT_SCHEMA_VERSION,
|
|
203
205
|
artifactHashes,
|
|
204
206
|
sampleHashes,
|
|
205
207
|
...(noJudge ? {} : { judgePromptHash: getJudgePromptHash(lengthDebiasOn) }),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { dirname } from 'node:path';
|
|
2
|
-
import { DEFAULT_OUTPUT_DIR, generateRunId, getCliVersion, getGitInfo, persistReport } from '../eval-core/evaluation-reporting.js';
|
|
2
|
+
import { DEFAULT_OUTPUT_DIR, EVALUATION_REPORT_SCHEMA_VERSION, generateRunId, getCliVersion, getGitInfo, persistReport, } from '../eval-core/evaluation-reporting.js';
|
|
3
3
|
import { buildEvaluationRequest, createEvaluationRun, createSucceededJob, finalizeEvaluationRun } from '../eval-core/evaluation-job.js';
|
|
4
4
|
import { getExecutorRuntimeFingerprint } from '../executors/runtime-fingerprint.js';
|
|
5
5
|
import { createFileJobStore, DEFAULT_JOBS_DIR } from '../server/job-store.js';
|
|
@@ -135,11 +135,12 @@ export function buildBatchEvaluationReport({ batchRunId, skillDir, skillEntries,
|
|
|
135
135
|
...(noJudge ? {} : { runtime: getExecutorRuntimeFingerprint(jc.executor, jc.model, { skillDir }) }),
|
|
136
136
|
}));
|
|
137
137
|
const report = {
|
|
138
|
-
|
|
138
|
+
kind: 'batch-evaluation',
|
|
139
139
|
id: batchRunId,
|
|
140
140
|
mode: 'skill',
|
|
141
141
|
meta: {
|
|
142
142
|
mode: 'skill',
|
|
143
|
+
schemaVersion: EVALUATION_REPORT_SCHEMA_VERSION,
|
|
143
144
|
model,
|
|
144
145
|
executor: executorName,
|
|
145
146
|
skillDir,
|
|
@@ -97,7 +97,7 @@ export async function runEvaluation({ samplesPath, skillDir, variantSpecs = [],
|
|
|
97
97
|
const { createFileStore } = await import('../server/report-store.js');
|
|
98
98
|
const store = createFileStore(resolve(outputDir || DEFAULT_OUTPUT_DIR));
|
|
99
99
|
const existing = await store.get(resume);
|
|
100
|
-
if (existing?.
|
|
100
|
+
if (existing?.kind === 'evaluation') {
|
|
101
101
|
existingResults = {};
|
|
102
102
|
for (const entry of existing.results || []) {
|
|
103
103
|
existingResults[entry.sample_id] = entry.variants;
|
|
@@ -121,7 +121,7 @@ export async function runEvaluation({ samplesPath, skillDir, variantSpecs = [],
|
|
|
121
121
|
+ ` 新 entries 不隔离 → 与现有 entries 不可比。建议恢复默认 strict-baseline。\n`);
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
|
-
else if (existing?.
|
|
124
|
+
else if (existing?.kind === 'batch-evaluation') {
|
|
125
125
|
process.stderr.write(`\n⚠️ report ${resume} is a BatchEvaluationReport; resume needs a child EvaluationReport, starting from scratch\n`);
|
|
126
126
|
}
|
|
127
127
|
else {
|
|
@@ -113,7 +113,11 @@ export function buildCodexArgs({ model, cwd, prompt }) {
|
|
|
113
113
|
args.push('--model', model);
|
|
114
114
|
if (cwd)
|
|
115
115
|
args.push('-C', cwd);
|
|
116
|
-
|
|
116
|
+
// `--` end-of-options 分隔符:prompt 必须放在 `--` 之后。system prompt 被 prepend 时,
|
|
117
|
+
// skill 内容常以 YAML frontmatter `---` 开头,codex(clap)会把以 `-`/`--` 开头的位置参数
|
|
118
|
+
// 当未知 flag → exit 2(unexpected argument),72ms 秒退、不跑 agent。`--` 之后一律当
|
|
119
|
+
// positional,不再按 flag 解析(codex 自身的报错 tip 即建议此法)。bug:整个 skill eval 全失败。
|
|
120
|
+
args.push('--', prompt);
|
|
117
121
|
return args;
|
|
118
122
|
}
|
|
119
123
|
// codex 看到 stdin 是 pipe 就当作 `<stdin>` 块读,会卡到 timeout。spawn 后立刻
|
|
@@ -52,7 +52,10 @@ const GIT_PROBE_STDIO = ['ignore', 'pipe', 'ignore'];
|
|
|
52
52
|
// 由 resolveGitRepoContext 解出 repoRoot 后逐处显式传入。
|
|
53
53
|
export function gitShowFile(ref, filePath, cwd = process.cwd()) {
|
|
54
54
|
try {
|
|
55
|
-
|
|
55
|
+
// `--` 隔断 tree-ish:ref 可能来自盘上受管记录的 locator(用户可手改 / 随仓库分发,被 omk list 等只读
|
|
56
|
+
// 命令喂进来),前缀 `-` 的 ref 不得被当成 git 选项解析(与 #219 fetch 路径同口径,见
|
|
57
|
+
// feedback_git_subprocess_dashdash)。加 `--` 后 dash-ref 退化为「非法 object name」fail-closed,普通 ref 输出不变。
|
|
58
|
+
return execFileSync('git', ['cat-file', 'blob', '--', `${ref}:${filePath}`], { cwd, encoding: 'utf-8', stdio: GIT_PROBE_STDIO }).trim();
|
|
56
59
|
}
|
|
57
60
|
catch {
|
|
58
61
|
return null;
|
|
@@ -64,7 +67,7 @@ export function gitShowFile(ref, filePath, cwd = process.cwd()) {
|
|
|
64
67
|
*/
|
|
65
68
|
export function gitShowBytes(ref, filePath, cwd = process.cwd()) {
|
|
66
69
|
try {
|
|
67
|
-
return execFileSync('git', ['cat-file', 'blob', `${ref}:${filePath}`], { cwd, stdio: GIT_PROBE_STDIO }); // 无 encoding → Buffer
|
|
70
|
+
return execFileSync('git', ['cat-file', 'blob', '--', `${ref}:${filePath}`], { cwd, stdio: GIT_PROBE_STDIO }); // 无 encoding → Buffer;`--` 隔断同 gitShowFile
|
|
68
71
|
}
|
|
69
72
|
catch {
|
|
70
73
|
return null;
|
|
@@ -80,7 +83,7 @@ export function gitShowBytes(ref, filePath, cwd = process.cwd()) {
|
|
|
80
83
|
export function gitLsTreeBlobs(ref, treePath, cwd = process.cwd()) {
|
|
81
84
|
let out;
|
|
82
85
|
try {
|
|
83
|
-
out = execFileSync('git', ['ls-tree', '-r', '-z', '--full-tree', `${ref}:${treePath}`], { cwd, encoding: 'utf-8', stdio: GIT_PROBE_STDIO });
|
|
86
|
+
out = execFileSync('git', ['ls-tree', '-r', '-z', '--full-tree', '--', `${ref}:${treePath}`], { cwd, encoding: 'utf-8', stdio: GIT_PROBE_STDIO });
|
|
84
87
|
}
|
|
85
88
|
catch {
|
|
86
89
|
return [];
|
package/dist/managed/index.d.ts
CHANGED