dsh-jev-guard 0.5.2 → 0.5.4
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/CHANGELOG.md +32 -0
- package/CHANGELOG.zh-CN.md +32 -0
- package/DEPLOY.md +4 -2
- package/DEPLOY.zh-CN.md +4 -2
- package/README.md +9 -2
- package/README.zh-CN.md +9 -2
- package/adapters/dsh/index.js +16 -0
- package/bin/guard.mjs +17 -6
- package/docs/AGENT-TASK-dsh.md +2 -9
- package/docs/AGENT-TASK-dsh.zh-CN.md +2 -9
- package/docs/DECISIONS.md +34 -3
- package/docs/DECISIONS.zh-CN.md +29 -3
- package/docs/DSH-INTEGRATION.md +7 -1
- package/docs/DSH-INTEGRATION.zh-CN.md +6 -1
- package/docs/MEASUREMENTS.md +28 -3
- package/docs/MEASUREMENTS.zh-CN.md +26 -3
- package/docs/VERIFICATION.md +5 -4
- package/docs/VERIFICATION.zh-CN.md +5 -4
- package/lib/gate.js +68 -4
- package/lib/i18n.js +14 -0
- package/lib/quota.js +71 -5
- package/package.json +2 -2
- package/tools/check-doc-pairs.mjs +2 -0
- package/tools/selftest-quota.mjs +164 -4
- package/tools/smoke-dsh-adapter.mjs +56 -4
- package/tools/smoke-dsh-pipeline.mjs +62 -9
package/tools/selftest-quota.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* @module jev-guard/tools/selftest-quota
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
|
14
|
+
import { mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
|
15
15
|
import { tmpdir } from 'node:os'
|
|
16
16
|
import { join } from 'node:path'
|
|
17
17
|
import { DEFAULTS, evaluateCommand } from '../lib/gate.js'
|
|
@@ -53,26 +53,48 @@ globalThis.fetch = async () => {
|
|
|
53
53
|
const next = script.shift()
|
|
54
54
|
if (next === undefined) throw new Error('fetch called more times than scripted')
|
|
55
55
|
if (next instanceof Error) throw next
|
|
56
|
+
// `before`:让用例在"请求进行中"制造真实的环境故障(例如把状态文件换成目录,使随后的
|
|
57
|
+
// unlink 真的失败)。只读文件系统造不出来,但它的**后果**可以这样逐字复现。
|
|
58
|
+
if (typeof next.before === 'function') await next.before()
|
|
56
59
|
return {
|
|
57
60
|
ok: next.status >= 200 && next.status < 300,
|
|
58
61
|
status: next.status,
|
|
62
|
+
// content-type 是分辨"应用层鉴权失败"与"边缘拦截"最干净的信号,所以替身也得给。
|
|
63
|
+
headers: { get: name => next.headers?.[String(name).toLowerCase()] ?? null },
|
|
59
64
|
text: async () => next.body ?? '',
|
|
60
65
|
json: async () => next.json ?? {},
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
const httpError = (status, body = '') => {
|
|
69
|
+
const httpError = (status, body = '', contentType = undefined) => {
|
|
65
70
|
const e = new Error(`HTTP ${status}: ${body}`)
|
|
66
71
|
e.status = status
|
|
67
72
|
e.body = body
|
|
73
|
+
if (contentType) e.contentType = contentType
|
|
68
74
|
return e
|
|
69
75
|
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 边缘拦下时的真实形状(2026-09-23 现场观测):Cloudflare 的通用错误页 —— 403 + **HTML**,
|
|
79
|
+
* 请求在边缘就被挡了,服务端连密钥都没看过。与"应用层鉴权失败"(401 + JSON)是两回事。
|
|
80
|
+
*/
|
|
81
|
+
const CF_403 = '<!DOCTYPE html><html class="no-js ie6 oldie" lang="en-US"><head><title>Attention Required! | Cloudflare</title></head>'
|
|
82
|
+
+ `<body><h1>Error code: 1020</h1><p>Access denied. cf-ray: 8f2c1d0e4b7a9c11</p></body></html>`
|
|
83
|
+
|
|
84
|
+
/** 应用层鉴权失败的真实形状(2026-09-20 实测):401 + JSON。 */
|
|
85
|
+
const AUTH_JSON = '{"detail":{"error_type":"authentication_error","message":"Cannot authenticate with the server. Please check your API key and try again."}}'
|
|
70
86
|
const okAnswer = (p, usage) => ({ status: 200, json: { model: 'jev-1.13.0', answers: { destroys_data: { noul: p } }, ...(usage ? { usage } : {}) } })
|
|
71
87
|
|
|
72
88
|
// 1) 分类:能定就定,定不了就不降级(宁可少降级,不要误降级)
|
|
73
89
|
expect('402 → quota', classifyFailure(httpError(402, 'insufficient credits')).kind === 'quota')
|
|
74
|
-
expect('401
|
|
75
|
-
expect('403 → auth', classifyFailure(httpError(403, 'forbidden')).kind === 'auth')
|
|
90
|
+
expect('401(JSON)→ auth', classifyFailure(httpError(401, AUTH_JSON, 'application/json')).kind === 'auth')
|
|
91
|
+
expect('403(非 HTML 正文)→ auth', classifyFailure(httpError(403, 'forbidden')).kind === 'auth')
|
|
92
|
+
expect('403(JSON 鉴权错误)→ auth:正文是 JSON 就一定是应用发的', classifyFailure(httpError(403, AUTH_JSON, 'application/json')).kind === 'auth')
|
|
93
|
+
// 边缘/WAF 拦下(2026-09-23):同一个 403,来源不同、处置相反 —— 这三种形状都必须归 edge。
|
|
94
|
+
expect('403(Cloudflare HTML)→ edge', classifyFailure(httpError(403, CF_403, 'text/html; charset=UTF-8')).kind === 'edge')
|
|
95
|
+
expect('403(content-type 是 HTML,正文不典型)→ edge', classifyFailure(httpError(403, 'Forbidden', 'text/html')).kind === 'edge')
|
|
96
|
+
expect('401(边缘 HTML)→ 同样是 edge', classifyFailure(httpError(401, CF_403, 'text/html')).kind === 'edge')
|
|
97
|
+
expect('edge 不降级(一次边缘抖动不该换来 30 分钟全局失能)', KINDS.edge.degraded === false)
|
|
76
98
|
expect('429(纯限流)→ rate-limit', classifyFailure(httpError(429, 'slow down')).kind === 'rate-limit')
|
|
77
99
|
expect('429(含额度字样)→ quota', classifyFailure(httpError(429, 'quota exceeded')).kind === 'quota')
|
|
78
100
|
expect('500 → server', classifyFailure(httpError(500)).kind === 'server')
|
|
@@ -179,6 +201,33 @@ script = [Object.assign(new Error('timed out'), { name: 'TimeoutError' })]
|
|
|
179
201
|
const transient = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined })
|
|
180
202
|
expect('超时 → fail-open 但不降级', transient.source === 'error' && transient.errorKind === 'timeout' && (await readDegraded(cfg)) === null)
|
|
181
203
|
|
|
204
|
+
// 9b) **边缘拦截与鉴权失败必须分开**(2026-09-23)。现场故障:同一条命令、同一把密钥,
|
|
205
|
+
// 403 + Cloudflare HTML 被旧规则一刀切成 auth → 30 分钟全局冷却 + 一句"密钥无效或被撤销",
|
|
206
|
+
// 而请求根本没到应用层。真正的鉴权失败长什么样,上面 401 + JSON 已经复现过了。
|
|
207
|
+
await clearDegraded(cfg)
|
|
208
|
+
script = [httpError(403, CF_403, 'text/html; charset=UTF-8')]
|
|
209
|
+
calls = []
|
|
210
|
+
const edgeBlocked = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined })
|
|
211
|
+
expect('403+HTML → fail-open(allow)', edgeBlocked.action === 'allow' && edgeBlocked.source === 'error', `${edgeBlocked.action}/${edgeBlocked.source}`)
|
|
212
|
+
expect('403+HTML → errorKind=edge(标签不再指向密钥)', edgeBlocked.errorKind === 'edge', String(edgeBlocked.errorKind))
|
|
213
|
+
expect('403+HTML → 判定上**没有** degraded 字段', edgeBlocked.degraded === undefined, JSON.stringify(edgeBlocked.degraded))
|
|
214
|
+
expect('403+HTML → 没有写出降级状态(= 没有那次 30 分钟冷却)', (await readDegraded(cfg)) === null)
|
|
215
|
+
// "没有冷却"的可观测后果:下一条命令照常联网判定。旧行为下这一条会变成 source=degraded 且零请求。
|
|
216
|
+
script = [okAnswer(0.1)]
|
|
217
|
+
calls = []
|
|
218
|
+
const afterEdge = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined })
|
|
219
|
+
expect('403+HTML 之后下一条命令照常判定(没有被按停)', afterEdge.source === 'jev' && calls.length === 1, `${afterEdge.source}/${calls.length}`)
|
|
220
|
+
|
|
221
|
+
// 9c) 反向:同一个状态码配 JSON 正文 = 应用层鉴权失败,**照旧降级**。
|
|
222
|
+
// 修掉误判不能顺手把真问题也放过 —— 那才是"密钥无效或被撤销"该说的话。
|
|
223
|
+
await clearDegraded(cfg)
|
|
224
|
+
script = [httpError(403, AUTH_JSON, 'application/json')]
|
|
225
|
+
calls = []
|
|
226
|
+
const app403 = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined })
|
|
227
|
+
expect('403+JSON → 仍是 auth,而且降级', app403.errorKind === 'auth' && (await readDegraded(cfg))?.kind === 'auth',
|
|
228
|
+
`${app403.errorKind}/${(await readDegraded(cfg))?.kind}`)
|
|
229
|
+
expect('403+JSON → 那条状态确实是 auth 的 30 分钟', cooldownMs('auth', {}) === 30 * 60 * 1000)
|
|
230
|
+
|
|
182
231
|
// 10) 状态文件坏掉 → 当作健康(宁可去问一次 API,也不要卡在降级里)
|
|
183
232
|
const { writeFile } = await import('node:fs/promises')
|
|
184
233
|
await writeFile(cfg.degradedPath, '{ 这不是 JSON')
|
|
@@ -203,6 +252,35 @@ expect('健康状态报告会指出"没有密钥"', healthy.includes('正常') &
|
|
|
203
252
|
await clearDegraded(cfg)
|
|
204
253
|
expect('清除后 isDegraded=false', isDegraded(await readDegraded(cfg)) === false)
|
|
205
254
|
|
|
255
|
+
// 12b) 清除的三种结果必须分开报(2026-09-23):清掉了 / 本来就没有 / **清不掉**。
|
|
256
|
+
// 旧版把后两者都返回 false,于是"只读文件系统"这类真实故障被报成"当前没有降级状态",
|
|
257
|
+
// 状态文件还在,人却以为已经清了。这里用一个目录冒充状态文件 —— unlink 一个目录必定失败
|
|
258
|
+
// (EISDIR / EPERM),与现场那个 EROFS 同类:非 ENOENT 的失败必须把 errno 交出来。
|
|
259
|
+
const nothing = await clearDegraded(cfg)
|
|
260
|
+
expect('本来就没有状态文件 → ok=true / removed=false(这是成功,不是故障)', nothing.ok === true && nothing.removed === false, JSON.stringify(nothing))
|
|
261
|
+
await enterDegraded('quota', { cfg })
|
|
262
|
+
const oneRemoved = await clearDegraded(cfg)
|
|
263
|
+
expect('确实删掉了一个 → ok=true / removed=true', oneRemoved.ok === true && oneRemoved.removed === true, JSON.stringify(oneRemoved))
|
|
264
|
+
const asDir = join(dir, 'degraded-as-dir')
|
|
265
|
+
await mkdir(asDir, { recursive: true })
|
|
266
|
+
const clearFailed = await clearDegraded({ degradedPath: asDir })
|
|
267
|
+
expect('清不掉 → ok=false 且报出 errno(不再谎报"无需清除")',
|
|
268
|
+
clearFailed.ok === false && typeof clearFailed.code === 'string' && clearFailed.code !== 'ENOENT', JSON.stringify(clearFailed))
|
|
269
|
+
|
|
270
|
+
// 12c) 冷却已过期时的那句告警**不能说"暂停 0 分钟"**(2026-09-23)—— 它既自相矛盾,
|
|
271
|
+
// 又会在"状态文件删不掉"那种永远过期的处境下被每条 CLI 命令念一遍。
|
|
272
|
+
const expiredState = {
|
|
273
|
+
kind: 'quota', label: '判定服务额度已用尽', since: new Date(Date.now() - 20 * 60 * 1000).toISOString(),
|
|
274
|
+
until: Date.now() - 1000, cooldownMs: 900000, failures: 1, probes: 0, policy: 'l0-only', path: cfg.degradedPath,
|
|
275
|
+
}
|
|
276
|
+
const expiredWarn = warningLine(expiredState)
|
|
277
|
+
expect('冷却已过期的告警不再说"暂停 0 分钟"', !expiredWarn.includes('暂停 0 分钟'), expiredWarn)
|
|
278
|
+
expect('冷却已过期的告警改说"下一条命令会放一次探测"',
|
|
279
|
+
expiredWarn.includes('下一条命令') && expiredWarn.includes('探测'), expiredWarn)
|
|
280
|
+
// 仍在窗口内的一侧不能被顺手改坏:它还该报剩余分钟数。
|
|
281
|
+
const inWindowWarn = warningLine({ ...expiredState, until: Date.now() + 12 * 60 * 1000 })
|
|
282
|
+
expect('仍在冷却窗口内 → 照旧报剩余分钟数', inWindowWarn.includes('暂停 12 分钟'), inWindowWarn)
|
|
283
|
+
|
|
206
284
|
// 13) **没有密钥 → 粘性降级**(D15):一次 HTTP 都不发,而且不靠时间结束
|
|
207
285
|
script = []
|
|
208
286
|
calls = []
|
|
@@ -255,6 +333,88 @@ expect('密钥出现 → 恢复成正常判定', revived.source === 'jev' && rev
|
|
|
255
333
|
expect('密钥出现 → 状态文件当场清掉', (await readDegraded(cfg)) === null)
|
|
256
334
|
expect('密钥出现 → 只花了一次请求(没有多余探测)', calls.length === 1, String(calls.length))
|
|
257
335
|
|
|
336
|
+
// 16) **探测成功、但状态文件删不掉**(只读文件系统 / 权限不足)—— 2026-09-23。
|
|
337
|
+
// 这是"状态文件是唯一持久记忆"的另一面:删不掉的时候谁也改不动它,于是每条命令重读它都会
|
|
338
|
+
// 得到"探测到期" → 每条命令都被当成一次新探测,审计里反复写 probe/recovered。现在要求:
|
|
339
|
+
// ① 那次判定带着 clearFailed(含 errno)与一句告警;② 本进程内不再据这份过期状态判断;
|
|
340
|
+
// ③ 而**新落盘的**状态照常生效 —— 记忆不许吃掉一次真实的降级。
|
|
341
|
+
const stuckDir = join(dir, 'stuck')
|
|
342
|
+
await mkdir(stuckDir, { recursive: true })
|
|
343
|
+
const stuckCfg = { degradedPath: join(stuckDir, 'degraded.json') }
|
|
344
|
+
// 只读文件系统下这份文件是**逐字节不变**的,所以两次写入用同一个字符串(也正因此 `until` 相同,
|
|
345
|
+
// 才能测出"同一份状态"被认出来)。
|
|
346
|
+
const STUCK_STATE = `${JSON.stringify({
|
|
347
|
+
kind: 'quota', label: '判定服务额度已用尽', since: new Date(Date.now() - 20 * 60 * 1000).toISOString(),
|
|
348
|
+
until: new Date(Date.now() - 60 * 1000).toISOString(), cooldownMs: 900000, failures: 1, probes: 0, policy: 'l0-only',
|
|
349
|
+
})}\n`
|
|
350
|
+
await writeFile(stuckCfg.degradedPath, STUCK_STATE)
|
|
351
|
+
// 在探测请求"进行中"把状态文件换成一个目录:随后的 unlink 必定失败(EISDIR / EPERM),
|
|
352
|
+
// 而"读"已经发生过了 —— 这正是只读文件系统下的处境,且两个平台都能造出来。
|
|
353
|
+
script = [{
|
|
354
|
+
...okAnswer(0.2),
|
|
355
|
+
before: async () => {
|
|
356
|
+
await rm(stuckCfg.degradedPath, { force: true })
|
|
357
|
+
await mkdir(stuckCfg.degradedPath, { recursive: true })
|
|
358
|
+
},
|
|
359
|
+
}]
|
|
360
|
+
calls = []
|
|
361
|
+
const stuckProbe = await evaluateCommand(CMD, { ...DEFAULTS, ...stuckCfg, apiKey: 'x', cache: undefined })
|
|
362
|
+
expect('探测成功+文件清不掉 → 判定照旧成立(服务确实回来了)',
|
|
363
|
+
stuckProbe.source === 'jev' && stuckProbe.probe === true, `${stuckProbe.source}/${stuckProbe.probe}`)
|
|
364
|
+
expect('探测成功+文件清不掉 → 判定上带 clearFailed 与 errno',
|
|
365
|
+
typeof stuckProbe.clearFailed?.code === 'string' && stuckProbe.clearFailed.code !== 'ENOENT', JSON.stringify(stuckProbe.clearFailed))
|
|
366
|
+
expect('探测成功+文件清不掉 → 告警直说清不掉,而不是沉默',
|
|
367
|
+
String(stuckProbe.warning ?? '').includes('删不掉'), String(stuckProbe.warning).slice(0, 50))
|
|
368
|
+
// 把文件恢复成同一份"已过期"状态:文件还在、窗口过期,而下一条命令不该再被当成一次探测。
|
|
369
|
+
await rm(stuckCfg.degradedPath, { recursive: true, force: true })
|
|
370
|
+
await writeFile(stuckCfg.degradedPath, STUCK_STATE)
|
|
371
|
+
script = [okAnswer(0.1)]
|
|
372
|
+
calls = []
|
|
373
|
+
const afterStuck = await evaluateCommand(CMD, { ...DEFAULTS, ...stuckCfg, apiKey: 'x', cache: undefined })
|
|
374
|
+
expect('清不掉之后 → 下一条命令不再被当成一次新探测(这是这次的修复点)',
|
|
375
|
+
afterStuck.source === 'jev' && afterStuck.probe === undefined, `${afterStuck.source}/${afterStuck.probe}`)
|
|
376
|
+
expect('清不掉之后 → 不再反复记 recovered', afterStuck.recovered === undefined, String(afterStuck.recovered))
|
|
377
|
+
expect('清不掉之后 → 该花的那次请求照花(命令仍被完整判定)', calls.length === 1, String(calls.length))
|
|
378
|
+
// ③ 记忆只压制"同一份或更老"的窗口:真有一份新状态落盘(只读可能只是暂时的),它照常生效。
|
|
379
|
+
await writeFile(stuckCfg.degradedPath, `${JSON.stringify({
|
|
380
|
+
kind: 'quota', label: '判定服务额度已用尽', since: new Date().toISOString(),
|
|
381
|
+
until: new Date(Date.now() + 15 * 60 * 1000).toISOString(), cooldownMs: 900000, failures: 1, probes: 0, policy: 'l0-only',
|
|
382
|
+
})}\n`)
|
|
383
|
+
script = []
|
|
384
|
+
calls = []
|
|
385
|
+
const newerState = await evaluateCommand(CMD, { ...DEFAULTS, ...stuckCfg, apiKey: 'x', cache: undefined })
|
|
386
|
+
expect('有更新的状态落盘 → 照常降级(记忆不吃掉真实的降级)',
|
|
387
|
+
newerState.source === 'degraded' && calls.length === 0, `${newerState.source}/${calls.length}`)
|
|
388
|
+
await rm(stuckDir, { recursive: true, force: true })
|
|
389
|
+
|
|
390
|
+
// 17) **缓存里放的是判定本身,不是那一次调用的附带信息**(2026-09-23)。
|
|
391
|
+
// 回放 `usage` 的代价是实测出来的:一次真实调用(700 input tokens)会被 `guard log --stats`
|
|
392
|
+
// 按缓存命中的次数重复计价(实测 1 次调用 → 2 条计价记录、1400 tokens、成本翻倍);
|
|
393
|
+
// 回放 `probe`/`recovered` 则会让一条 `source: cache` 的记录自称"这次是一次探测"。
|
|
394
|
+
const cache = new Map() // VerdictCache 的接口就是 get/set,自检用普通 Map 即可
|
|
395
|
+
await clearDegraded(cfg)
|
|
396
|
+
script = [okAnswer(0.9, { input_tokens: 700, output_tokens: 20 })]
|
|
397
|
+
calls = []
|
|
398
|
+
const judged = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache })
|
|
399
|
+
const reused = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache })
|
|
400
|
+
expect('同一进程内第二次 → 缓存命中,不再发请求', reused.source === 'cache' && calls.length === 1, `${reused.source}/${calls.length}`)
|
|
401
|
+
expect('首次判定带着真实用量(不能被抹掉)', judged.usage?.input_tokens === 700, JSON.stringify(judged.usage))
|
|
402
|
+
expect('缓存命中不带 usage(否则成本被重复计价)', reused.usage === undefined, JSON.stringify(reused.usage))
|
|
403
|
+
expect('缓存命中仍带着判定本身(p / action / 模型)',
|
|
404
|
+
reused.p === 0.9 && reused.action === 'block' && reused.model === 'jev-1.13.0', `${reused.p}/${reused.action}`)
|
|
405
|
+
// 探测的标记同样不该被回放:同一条命令再来一次,它只是缓存命中,不是探测。
|
|
406
|
+
await seedExpired(1)
|
|
407
|
+
script = [okAnswer(0.9)]
|
|
408
|
+
calls = []
|
|
409
|
+
const probeCache = new Map()
|
|
410
|
+
const probed = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache: probeCache })
|
|
411
|
+
const probeReused = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache: probeCache })
|
|
412
|
+
expect('铺垫:第一次确实是探测', probed.probe === true && probed.recovered === true,
|
|
413
|
+
JSON.stringify({ probe: probed.probe, recovered: probed.recovered }))
|
|
414
|
+
expect('缓存命中不再自称探测(记录不能写没发生的事)',
|
|
415
|
+
probeReused.source === 'cache' && probeReused.probe === undefined && probeReused.recovered === undefined,
|
|
416
|
+
`${probeReused.source}/${probeReused.probe}/${probeReused.recovered}`)
|
|
417
|
+
|
|
258
418
|
await rm(dir, { recursive: true, force: true })
|
|
259
419
|
process.stdout.write(`\n${failed === 0 ? '全部通过' : `${failed} 项失败`}(${checks} 例)\n`)
|
|
260
420
|
process.exit(failed === 0 ? 0 : 1)
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* @module jev-guard/tools/smoke-dsh-adapter
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
24
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
25
25
|
import { tmpdir } from 'node:os'
|
|
26
26
|
import { join } from 'node:path'
|
|
27
27
|
import { flush } from '../lib/audit.js'
|
|
@@ -180,6 +180,10 @@ async function main() {
|
|
|
180
180
|
const d = mockContext(undefined) // 没有凭据
|
|
181
181
|
applySmoke(d.ctx)
|
|
182
182
|
const handlerNoKey = d.handlers.get('tools/pre-execute')
|
|
183
|
+
// 从这里到「联网用例」之前,环境变量必须一直是空的。适配器的凭据解析顺序是
|
|
184
|
+
// ctx.credentials → `apiKeyEnv`(默认 `TYPESAFE_API_KEY`)→ `apiKeyFile`,而这一段测的正是
|
|
185
|
+
// **没有密钥**时的行为:环境里留着真钥匙,前提就直接不成立(2026-09-30 实测:带着
|
|
186
|
+
// `TYPESAFE_API_KEY` 跑本文件时,notice 那 3 组断言会全部假失败)。摘掉一次,联网用例前再放回。
|
|
183
187
|
process.env.TYPESAFE_API_KEY = ''
|
|
184
188
|
// 命令必须绕开预筛(/tmp 之类会被当"可重建内容"直接放行,那样根本到不了需要密钥的语义层)。
|
|
185
189
|
expect('无密钥 + 需要语义判定 → allow(fail-open)',
|
|
@@ -189,9 +193,49 @@ async function main() {
|
|
|
189
193
|
expectTrue('无密钥 → 状态是粘性的、且作用域只限本入口',
|
|
190
194
|
nkState?.sticky === true && nkState?.scope === 'dsh-adapter',
|
|
191
195
|
JSON.stringify({ sticky: nkState?.sticky, scope: nkState?.scope }))
|
|
192
|
-
if (apiKey) process.env.TYPESAFE_API_KEY = apiKey
|
|
193
196
|
await rm(degradedPath, { force: true })
|
|
194
197
|
|
|
198
|
+
// ---- 探测成功、但状态文件清不掉(只读文件系统):原因必须有人能发现(2026-09-23)----
|
|
199
|
+
//
|
|
200
|
+
// 现场是只读沙箱:服务已经用一次成功判定证明自己活着,可 `unlink` 删不掉 degraded.json。
|
|
201
|
+
// gate 把那次判定标成 clearFailed + 一句告警;适配器的责任是让它**落地**:host 日志一条
|
|
202
|
+
// warn + 审计一条 level:'warn' 记录(带 errno 与路径)。否则这个原因就只剩 `guard status`
|
|
203
|
+
// 里一句"已降级",没人知道该去删那个文件。
|
|
204
|
+
// 用替身 fetch + 一个假密钥就能离线跑:不需要真网络,也不需要真密钥。
|
|
205
|
+
const stuckPath = join(dir, 'degraded-as-dir.json')
|
|
206
|
+
await writeFile(stuckPath, `${JSON.stringify({
|
|
207
|
+
kind: 'quota', label: '判定服务额度已用尽', since: new Date(Date.now() - 20 * 60 * 1000).toISOString(),
|
|
208
|
+
until: new Date(Date.now() - 60 * 1000).toISOString(), cooldownMs: 900000, failures: 1, probes: 0, policy: 'l0-only',
|
|
209
|
+
})}\n`)
|
|
210
|
+
const stuckCtx = mockContext('smoke-fake-key')
|
|
211
|
+
applySmoke(stuckCtx.ctx, { degradedPath: stuckPath })
|
|
212
|
+
const handlerStuck = stuckCtx.handlers.get('tools/pre-execute')
|
|
213
|
+
const realFetch = globalThis.fetch
|
|
214
|
+
globalThis.fetch = async () => {
|
|
215
|
+
// 请求"进行中"把状态文件换成同路径的目录 —— 于是随后的 unlink 必定失败(EISDIR/EPERM),
|
|
216
|
+
// 而"读"已经发生过了。这正是只读文件系统下的处境,两个平台都能造出来。
|
|
217
|
+
await rm(stuckPath, { force: true })
|
|
218
|
+
await mkdir(stuckPath, { recursive: true })
|
|
219
|
+
return {
|
|
220
|
+
ok: true, status: 200, headers: { get: () => 'application/json' },
|
|
221
|
+
text: async () => '', json: async () => ({ model: 'jev-1.13.0', answers: { destroys_data: { noul: 0.1 } } }),
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
expect('探测成功+状态文件清不掉 → 判定仍是 allow(服务确实答了这次探测)',
|
|
226
|
+
await handlerStuck(fakeExec('rm -rf /home/user/jev-guard-stuck-demo'), nextAllow), 'allow')
|
|
227
|
+
} finally {
|
|
228
|
+
globalThis.fetch = realFetch
|
|
229
|
+
}
|
|
230
|
+
await flush()
|
|
231
|
+
const stuckRecords = (await readFile(auditPath, 'utf8')).split('\n').filter(Boolean)
|
|
232
|
+
.map(l => JSON.parse(l)).filter(r => r.clearFailed)
|
|
233
|
+
expectTrue('清不掉 → 审计里留下一条带 errno 的 warn 记录(不是静默)',
|
|
234
|
+
stuckRecords.length === 1 && stuckRecords[0].level === 'warn' && typeof stuckRecords[0].clearFailed.code === 'string'
|
|
235
|
+
&& String(stuckRecords[0].warning ?? '').includes('删不掉'),
|
|
236
|
+
JSON.stringify(stuckRecords.map(r => r.clearFailed)))
|
|
237
|
+
await rm(stuckPath, { recursive: true, force: true })
|
|
238
|
+
|
|
195
239
|
// ---- 会话内 notice:纯 host 插件唯一能让用户真看到的渠道(D15)----
|
|
196
240
|
//
|
|
197
241
|
// 这一段同时验证两件互相依存的事:① `agent/pre-step` 真的接线了,消息是**追加**而不是替换,
|
|
@@ -249,7 +293,9 @@ async function main() {
|
|
|
249
293
|
JSON.stringify(withFileKey.messages?.map(m => m.id)))
|
|
250
294
|
|
|
251
295
|
// ---- 联网用例(有密钥时才跑) ----
|
|
296
|
+
// 「没有密钥」的场景到此结束,把环境变量还回去。
|
|
252
297
|
if (apiKey) {
|
|
298
|
+
process.env.TYPESAFE_API_KEY = apiKey
|
|
253
299
|
const e = mockContext(apiKey)
|
|
254
300
|
applySmoke(e.ctx)
|
|
255
301
|
const h = e.handlers.get('tools/pre-execute')
|
|
@@ -286,10 +332,16 @@ async function main() {
|
|
|
286
332
|
expectTrue('审计里记下了权限 preset(danger-full-access)', auditText.includes('"preset":"danger-full-access"'), auditText.slice(0, 200))
|
|
287
333
|
await rm(audited, { force: true })
|
|
288
334
|
|
|
289
|
-
await flush() //
|
|
335
|
+
await flush() // 等审计日志落盘,否则退出时会丢掉尾部记录
|
|
290
336
|
await rm(dir, { recursive: true, force: true }) // 整个测试的产物都在这个临时目录里
|
|
291
337
|
process.stdout.write(`\n${failures === 0 ? `全部通过(${checks} 组断言)` : `${failures} 组失败 / 共 ${checks} 组`}\n`)
|
|
292
|
-
|
|
338
|
+
|
|
339
|
+
// **不要**改成 `process.exit(code)`。联网用例跑完后进程里还留着 undici 的 keep-alive
|
|
340
|
+
// socket,Windows 上在这个时刻强退会撞进 libuv 的
|
|
341
|
+
// `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 76`,
|
|
342
|
+
// 进程以 0xC0000409 abort,退出码从 0 变成 -1073740791(2026-09-30 实测)。设 `exitCode`
|
|
343
|
+
// 让事件循环自然排空即可,实测退出码与耗时都正常。
|
|
344
|
+
process.exitCode = failures === 0 ? 0 : 1
|
|
293
345
|
}
|
|
294
346
|
|
|
295
347
|
await main()
|
|
@@ -7,11 +7,30 @@
|
|
|
7
7
|
* (pre-execute → guards → execute → post-execute → result),看最终给出的
|
|
8
8
|
* `ToolExecutionResult` 是不是我们要的拒绝/放行。
|
|
9
9
|
*
|
|
10
|
-
*
|
|
10
|
+
* 运行方式:这个测试只依赖两件事,而它们必须**同时**成立
|
|
11
|
+
* ① 裸 `@deepseek-ai/*` 能解析 —— 注意 ESM 是按**文件自身所在目录**向上找 node_modules 的(不是
|
|
12
|
+
* cwd),所以在 pnpm 工作区检出里必须让本文件待在**包目录**里(如 `apps/cli`);
|
|
13
|
+
* ② `./packages/util/values/lib/index.js` 存在(第 6 项拿 DSH 自己的 `snapshotJsonValue` 校验
|
|
14
|
+
* notice 形状,那是 `Session.append` 之前的一步)—— 这一条按 **cwd** 算,只有检出**根**满足。
|
|
15
|
+
* 检出的两个目录各满足一条,所以做法是搭一个临时目录把两者接过来(2026-09-23 实测:6/6;带密钥 7/7):
|
|
11
16
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
17
|
+
* H=<DSH 检出>; T=/tmp/jev-guard-pipeline; rm -rf $T; mkdir -p $T/node_modules/@deepseek-ai
|
|
18
|
+
* for p in cordis dsh-tools dsh-session dsh-system-prompt; do
|
|
19
|
+
* ln -s $H/apps/cli/node_modules/@deepseek-ai/$p $T/node_modules/@deepseek-ai/$p; done
|
|
20
|
+
* ln -s $H/packages $T/packages
|
|
21
|
+
* cp <本文件> $T/ && cd $T && JEV_GUARD_ROOT=/mnt/t/jev-guard node smoke-dsh-pipeline.mjs
|
|
22
|
+
* # 加 TYPESAFE_API_KEY 则第 4 项会真的走一次联网判定(7/7)
|
|
23
|
+
*
|
|
24
|
+
* Windows 上同一件事(2026-09-30 实测:离线 6/6、带密钥 7/7):临时目录同样要备齐 ① 和 ②
|
|
25
|
+
* (目录联接,或直接用 npm 装宿主包),然后 `JEV_GUARD_ROOT` **给裸路径就行** —— 脚本自己
|
|
26
|
+
* 转成 `file://` URL,不用手写 `file:///T:/...`:
|
|
27
|
+
*
|
|
28
|
+
* $T = <临时目录>; $env:JEV_GUARD_ROOT = "T:\dsh-jev-guard"; node smoke-dsh-pipeline.mjs
|
|
29
|
+
*
|
|
30
|
+
* 三个**别照抄**的旧配方:从检出根直接跑会以 `ERR_MODULE_NOT_FOUND` 崩在下面的 import 上;
|
|
31
|
+
* 从 `packages/core/agent-loop` 跑则第 6 项会假报 FAIL(那个 cwd 下没有 `./packages/...`);
|
|
32
|
+
* 用绝对路径跑本文件(不复制)同样在 import 那一步就崩 —— 裸说明符不认 cwd。
|
|
33
|
+
* 测试自身不写真实 `~/.jev-guard/`:日志与降级状态都钉在临时目录里(与 `smoke-dsh-adapter` 同规矩)。
|
|
15
34
|
*
|
|
16
35
|
* @module jev-guard/tools/smoke-dsh-pipeline
|
|
17
36
|
*/
|
|
@@ -20,10 +39,24 @@ import { Context } from '@deepseek-ai/cordis'
|
|
|
20
39
|
import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
|
21
40
|
import SessionStore from '@deepseek-ai/dsh-session'
|
|
22
41
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
42
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
43
|
+
import { tmpdir } from 'node:os'
|
|
44
|
+
import { join } from 'node:path'
|
|
23
45
|
import { pathToFileURL } from 'node:url'
|
|
24
46
|
|
|
25
47
|
const ROOT = process.env.JEV_GUARD_ROOT ?? '/mnt/t/dsh-jev-guard'
|
|
26
|
-
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `ROOT` 作为**模块说明符**。裸文件系统路径不能直接拼进 `import()`:ESM 加载器先按 URL
|
|
51
|
+
* 解析,Windows 的 `T:\jev-guard` 会被读成协议 `t:`,报 `ERR_UNSUPPORTED_ESM_URL_SCHEME`
|
|
52
|
+
* (2026-09-30 实测)。所以统一转成 `file://` URL —— POSIX 的 `/mnt/t/...` 与 Windows 的
|
|
53
|
+
* `T:\...` 都适用;已经是 URL 的原样保留,免得把 `file:///T:/...` 二次转义。
|
|
54
|
+
*/
|
|
55
|
+
const ROOT_URL = /^[a-z][a-z0-9+.-]*:\/\//i.test(ROOT) ? ROOT : pathToFileURL(ROOT).href
|
|
56
|
+
const mod = await import(`${ROOT_URL}/adapters/dsh/index.js`)
|
|
57
|
+
|
|
58
|
+
/** 审计日志与降级状态都落在这里 —— 跑测试不该改动本机真实的阀门状态。 */
|
|
59
|
+
const SANDBOX = await mkdtemp(join(tmpdir(), 'jev-guard-pipeline-'))
|
|
27
60
|
|
|
28
61
|
let failures = 0
|
|
29
62
|
let checks = 0
|
|
@@ -86,9 +119,11 @@ function expect(label, actual, wantError, needles = []) {
|
|
|
86
119
|
|
|
87
120
|
async function main() {
|
|
88
121
|
const hasKey = Boolean(process.env.TYPESAFE_API_KEY)
|
|
89
|
-
process.stdout.write(`DSH 工具管线集成测试 root=${ROOT} ${hasKey ? '(含联网)' : '(无密钥)'}\n\n`)
|
|
122
|
+
process.stdout.write(`DSH 工具管线集成测试 root=${ROOT} sandbox=${SANDBOX} ${hasKey ? '(含联网)' : '(无密钥)'}\n\n`)
|
|
90
123
|
|
|
91
|
-
|
|
124
|
+
// 日志/状态钉在临时目录:真实 `~/.jev-guard/` 不该因为跑一次测试而被写入(与 smoke-dsh-adapter 同规矩)。
|
|
125
|
+
const paths = { logPath: join(SANDBOX, 'guard.log'), degradedPath: join(SANDBOX, 'degraded.json') }
|
|
126
|
+
const ctx = await harness({ tools: ['bash'], inlineScripts: false, ...paths })
|
|
92
127
|
|
|
93
128
|
// 1. 只读命令:预筛放行 → 假工具真的被执行
|
|
94
129
|
expect('ls -la → 真的执行(管线走通)', await run(ctx, 'ls -la'), false, ['RAN: ls -la'])
|
|
@@ -115,7 +150,7 @@ async function main() {
|
|
|
115
150
|
}
|
|
116
151
|
|
|
117
152
|
// 5. 非目标工具不受影响
|
|
118
|
-
const ctx2 = await harness({ tools: ['read'], inlineScripts: false })
|
|
153
|
+
const ctx2 = await harness({ tools: ['read'], inlineScripts: false, ...paths })
|
|
119
154
|
expect('tools 不匹配 → 放行', await run(ctx2, 'ls -la'), false, ['RAN: ls -la'])
|
|
120
155
|
|
|
121
156
|
// 6. 会话内 notice 的形状:交给 DSH 自己的 JSON 快照校验(真实 Session.append 之前那一步)
|
|
@@ -139,8 +174,26 @@ async function main() {
|
|
|
139
174
|
if (!noticeOk) failures += 1
|
|
140
175
|
process.stdout.write(`${noticeOk ? 'ok ' : 'FAIL'} notice 通过 DSH 自己的 snapshotJsonValue(Session.append 之前那一步)\n ${noticeWhy}\n`)
|
|
141
176
|
|
|
177
|
+
// 收尾与 smoke-dsh-adapter 同规矩:先等审计队列落盘(record() 是 fire-and-forget,
|
|
178
|
+
// 不等它 process.exit 会丢尾部记录),再把整个临时目录删掉。
|
|
179
|
+
// 审计模块从 ROOT_URL 动态导入 —— 本文件可能被复制到临时目录里运行,相对路径在那里是无效的。
|
|
180
|
+
try {
|
|
181
|
+
const audit = await import(`${ROOT_URL}/lib/audit.js`)
|
|
182
|
+
await audit.flush()
|
|
183
|
+
} catch {
|
|
184
|
+
// 清理失败不该改变测试结论
|
|
185
|
+
}
|
|
186
|
+
await rm(SANDBOX, { recursive: true, force: true }).catch(() => {})
|
|
187
|
+
|
|
142
188
|
process.stdout.write(`\n${failures === 0 ? `全部通过(${checks} 组断言)` : `${failures} 组失败 / 共 ${checks} 组`}\n`)
|
|
143
|
-
|
|
189
|
+
|
|
190
|
+
// **不要**改成 `process.exit(code)`。联网用例跑完后进程里还留着 undici 的 keep-alive
|
|
191
|
+
// socket(以及一个未决的 fs 请求);Windows 上在这个时刻强退会撞进 libuv 的
|
|
192
|
+
// `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 76`,
|
|
193
|
+
// 进程以 0xC0000409 abort,退出码从 0 变成 -1073740791 —— 一次"全部通过"被报成失败
|
|
194
|
+
// (2026-09-30 实测,连跑两次都复现)。设 `exitCode` 让事件循环自然排空即可:同一份用例
|
|
195
|
+
// 自然退出的实测耗时约 1.0s,退出码 0,连跑三次稳定。
|
|
196
|
+
process.exitCode = failures === 0 ? 0 : 1
|
|
144
197
|
}
|
|
145
198
|
|
|
146
199
|
await main()
|