dsh-jev-guard 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/gate.js CHANGED
@@ -516,12 +516,15 @@ export async function callJev(state, cfg, signal) {
516
516
  signal: combined,
517
517
  })
518
518
  if (!res.ok) {
519
- // 把状态码与正文挂在异常上:**分类器靠它们区分"额度用完"和"抖了一下"**,
520
- // 而这两者的处置完全不同(前者停 15 分钟并告警,后者只逐次 fail-open)。
519
+ // 把状态码、正文与 content-type 挂在异常上:**分类器靠它们区分"额度用完"、"抖了一下"
520
+ // 和"被边缘/WAF 挡下"**,而这三者的处置完全不同(第一个停 15 分钟并告警,后两个只逐次
521
+ // fail-open,但原因不同 —— 见 quota.js 的 looksLikeEdgeBlock)。
521
522
  const text = (await res.text()).slice(0, 300)
522
523
  const error = new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`)
523
524
  error.status = res.status
524
525
  error.body = text
526
+ // 边缘拦下时给的是 HTML 错误页,而应用层鉴权失败是 JSON —— 这是分辨两者最干净的信号。
527
+ error.contentType = res.headers?.get?.('content-type') ?? ''
525
528
  throw error
526
529
  }
527
530
  const json = await res.json()
@@ -599,6 +602,38 @@ function degradedVerdict(state, cfg, now, probe = false) {
599
602
  }
600
603
  }
601
604
 
605
+ /**
606
+ * 进程内记忆:哪些降级状态文件"探测已经成功、但清不掉"(只读文件系统 / 权限不足)。
607
+ *
608
+ * 为什么需要它(2026-09-23):状态文件是**唯一**的持久记忆,而删不掉它的时候,谁也改不动它。
609
+ * 那份文件的窗口明明已经过期、服务也刚刚用一次成功判定证明自己是活的,可每条命令重读它都会
610
+ * 得到"探测到期" —— 于是每条命令都被当成一次新探测,审计里反复出现 `probe`/`recovered`,
611
+ * 而 `guard status` 永远显示"降级中"。既然删不掉,本进程就**不再拿它当依据**(服务回来了就是
612
+ * 回来了),同时把"清不掉"这件事连同 errno 挂在发现它的那次判定上,别让原因消失。
613
+ *
614
+ * 键是状态文件路径,值是那份过期状态的 `until`(毫秒)。只压制**同一份或更老**的窗口:
615
+ * 万一之后真有一份**新的**状态落盘了(比如文件系统只读是暂时的),新窗口照常生效 ——
616
+ * 记忆绝不能顺手吃掉一次真实的降级。
617
+ */
618
+ const stuckClears = new Map()
619
+
620
+ /**
621
+ * @param state - 刚刚探测成功后清不掉的那份降级状态。
622
+ */
623
+ function markClearStuck(state) {
624
+ if (state?.path) stuckClears.set(state.path, Number(state.until) || 0)
625
+ }
626
+
627
+ /**
628
+ * 这份状态是不是"已知清不掉、且已被一次成功判定证伪"的那一份。
629
+ * @param state - readDegraded() 的结果。
630
+ * @returns true = 不再拿它当降级依据。
631
+ */
632
+ function isObsoleteByStuckClear(state) {
633
+ const stuckUntil = stuckClears.get(state?.path)
634
+ return stuckUntil !== undefined && Number(state.until) <= stuckUntil
635
+ }
636
+
602
637
  /**
603
638
  * Decide what to do with one command.
604
639
  *
@@ -617,6 +652,10 @@ export async function evaluateCommand(command, options = {}) {
617
652
  // 读失败 = 当作没降级(宁可去问一次 API,也不要被一个坏文件卡在降级态里)。
618
653
  let degradedState = cfg.quotaGuard === false ? null : await readDegraded(cfg)
619
654
 
655
+ // 本进程已经确认"服务答得上来、只是那份状态文件删不掉" → 不再拿它当降级依据。
656
+ // 没有这一步,清不掉的状态会让**每条命令**都重新走一次"冷却到期后的探测"(见 stuckClears)。
657
+ if (degradedState !== null && isObsoleteByStuckClear(degradedState)) degradedState = null
658
+
620
659
  // 粘性状态("没有解析到密钥")靠**条件消失**结束,不靠时间:密钥一旦能解析到,这份状态
621
660
  // 就没有存在理由了,当场清掉。这一步零 HTTP(密钥解析是纯本地的事),所以不必等冷却、
622
661
  // 也不必等探测窗口 —— 插上密钥后的第一条命令就恢复成正常判定。
@@ -676,7 +715,12 @@ export async function evaluateCommand(command, options = {}) {
676
715
  try {
677
716
  const { p, model, usage } = await callJev(state, cfg, cfg.signal)
678
717
  // 探测成功 = 服务回来了 → 清掉降级状态。人不需要做任何事。
679
- if (isProbe) await clearDegraded(cfg)
718
+ // 清不掉(只读文件系统 / 权限)不是"恢复失败":这次判定是真的,服务确实答了;但那份
719
+ // 过期状态文件会继续被每一次读取如实报成"已降级"。所以既不静默吞掉,也不假装没发生:
720
+ // 本进程内不再据它判断(见 stuckClears),并把 errno 挂到这次判定上。
721
+ const clearResult = isProbe ? await clearDegraded(cfg) : null
722
+ const stuck = clearResult !== null && !clearResult.ok
723
+ if (stuck) markClearStuck(degradedState)
680
724
  const verdict = {
681
725
  action: p >= cfg.highThreshold ? 'block' : p >= cfg.lowThreshold ? 'revise' : 'allow',
682
726
  source: 'jev',
@@ -688,8 +732,28 @@ export async function evaluateCommand(command, options = {}) {
688
732
  enriched: Object.keys(state).filter(k => k !== commandKey),
689
733
  ms: Date.now() - started,
690
734
  ...(isProbe ? { probe: true, recovered: true } : {}),
735
+ ...(stuck
736
+ ? {
737
+ clearFailed: { path: degradedState.path, code: clearResult.code, error: clearResult.error },
738
+ warning: t('quota.clearFailed.warning', { code: clearResult.code ?? '?', path: degradedState.path }),
739
+ }
740
+ : {}),
691
741
  }
692
- cache.set(key, verdict)
742
+ // 缓存里只放**这份判定本身** —— 同一命令在任何时刻都成立的那些字段。`usage` / `probe` /
743
+ // `recovered` / `clearFailed` / `warning` / `ms` 属于"这一次调用"的附带信息,回放它们
744
+ // 等于把没发生的事再记一遍(2026-09-23):
745
+ // · `usage` 被回放时,`guard log --stats` 会把一次真实调用按缓存命中的次数重复计价
746
+ // (实测 1 次调用、700 tokens 被算成 1400 tokens、成本翻倍);
747
+ // · `probe`/`recovered` 被回放时,一条 `source: cache` 的记录会自称"这次是一次探测"。
748
+ // 所以这里显式列字段(白名单):将来判定形状长大时,新字段默认**不进**缓存,而不是被静默回放。
749
+ cache.set(key, {
750
+ action: verdict.action,
751
+ p: verdict.p,
752
+ model: verdict.model,
753
+ lowThreshold: verdict.lowThreshold,
754
+ highThreshold: verdict.highThreshold,
755
+ enriched: verdict.enriched,
756
+ })
693
757
  return applyToken(command, verdict, cfg)
694
758
  } catch (error) {
695
759
  // fail-open: availability of the valve must never block the agent; the
package/lib/i18n.js CHANGED
@@ -101,6 +101,8 @@ const MESSAGES = {
101
101
  'quota.quota.cliHint.1': '查状态:`guard status`',
102
102
  'quota.auth.label': '判定服务的密钥无效或被撤销',
103
103
  'quota.auth.hint': '检查 secrets.json / 环境变量里的 TYPESAFE_API_KEY(别把密钥贴进对话)。',
104
+ 'quota.edge.label': '请求被边缘/WAF 挡下(不是密钥问题)',
105
+ 'quota.edge.hint': '响应不是判定服务给的,而是 CDN/WAF(常见 Cloudflare)的错误页 —— 请求没到应用层,密钥根本没被检查。逐次放行、不降级;若持续出现,查网络出口是否被拦,并看 `guard log --stats` 的 edge 计数。',
104
106
  'quota.no-key.label': '没有解析到判定服务的密钥',
105
107
  'quota.no-key.hint': '三种录入方式(任选):① 跑 `node bin/guard.mjs key set`(从标准输入读,不进 shell 历史与进程列表);② 设环境变量 `TYPESAFE_API_KEY`;③ 在包根写 `secrets.json`(相对路径的 apiKeyFile 按**包根**解析,与 cwd 无关)。录入后不需要重启。别把密钥贴进对话。',
106
108
  'quota.rate-limit.label': '被判定服务限流(429)',
@@ -121,6 +123,10 @@ const MESSAGES = {
121
123
  + '联网语义判定暂停 {mins} 分钟;{policy}。',
122
124
  'quota.warning.sticky': '⚠️ Jev 安全阀门已降级:{label}(自 {since}Z,已失败 {failures} 次)。'
123
125
  + '联网语义判定暂停;这个状态不靠时间结束,靠它描述的**条件消失**(如密钥一出现);{policy}。',
126
+ 'quota.warning.expired': '⚠️ Jev 安全阀门已降级:{label}(自 {since}Z,已失败 {failures} 次)。'
127
+ + '冷却已到期:下一条命令会放一次探测请求,成功即当场恢复;{policy}。',
128
+ 'quota.clearFailed.warning': '⚠️ 语义判定已恢复(这次探测成功了),但降级状态文件删不掉({code}):{path}。'
129
+ + '只要它还在,之后每一次读取都会把它显示成"已降级" —— 请手工删除,或检查该目录的权限/是否只读挂载。',
124
130
  'quota.status.ok': '✅ Jev 安全阀门:正常',
125
131
  'quota.status.ok.layers': ' L0 静态硬规则 + 预筛 + Jev 语义判定,四态齐全。',
126
132
  'quota.status.ok.noKey': ' ⚠️ 但是:当前没有解析到 API 密钥(`guard judge` 会退回 L0/预筛)。',
@@ -211,6 +217,7 @@ const MESSAGES = {
211
217
  'cli.log.costUnknown': ' 语义判定成本: 未记录(最近的调用没有返回 usage;升级前写入的旧记录也不含)',
212
218
  'cli.status.cleared': '已清除降级状态。下一条命令会重新尝试联网判定(失败会再次进入降级)。',
213
219
  'cli.status.nothingToClear': '当前没有降级状态,无需清除。',
220
+ 'cli.status.clearFailed': '清除失败({code}):降级状态文件还在 —— 别当成"没有降级"。只读文件系统、权限不足都会这样;状态文件读得出却删不掉时,阀门在冷却到期后会**每条命令**都当一次探测重试(各花一次请求)。',
214
221
  'cli.status.stateFile': ' 状态文件: {path}{missing}',
215
222
  'cli.status.stateFileMissing': '(不存在 = 健康)',
216
223
  'cli.status.explainer': ' 说明:降级 = 停用**要花钱的语义判定**;免费的 L0 规则与预筛照常工作(当前 degradePolicy={policy})。',
@@ -302,6 +309,8 @@ const MESSAGES = {
302
309
  'quota.quota.cliHint.1': 'to check: `guard status`',
303
310
  'quota.auth.label': 'the judging service rejected or revoked the key',
304
311
  'quota.auth.hint': 'Check TYPESAFE_API_KEY in secrets.json / the environment (never paste a key into a conversation).',
312
+ 'quota.edge.label': 'the request was blocked at the edge/WAF (not a key problem)',
313
+ 'quota.edge.hint': 'The response did not come from the judging service but from a CDN/WAF (usually Cloudflare) error page — it never reached the application, so the key was never checked. Each call fails open and nothing degrades; if it persists, check whether your egress is being blocked and read the `edge` count in `guard log --stats`.',
305
314
  'quota.no-key.label': 'no key for the judging service could be resolved',
306
315
  'quota.no-key.hint': 'Three ways to set it (pick one): (1) run `node bin/guard.mjs key set` (reads the key from stdin — never shell history or the process list); (2) set the environment variable `TYPESAFE_API_KEY`; (3) write `secrets.json` in the package root (a relative apiKeyFile resolves against the **package root**, independent of cwd). No restart is needed afterwards. Never paste a key into a conversation.',
307
316
  'quota.rate-limit.label': 'rate-limited by the judging service (429)',
@@ -322,6 +331,10 @@ const MESSAGES = {
322
331
  + ' Online semantic judgment paused for {mins} min; {policy}.',
323
332
  'quota.warning.sticky': '⚠️ Jev guard is degraded: {label} (since {since}Z, {failures} failures).'
324
333
  + ' Online semantic judgment is paused; this state does not end with time — it ends when the condition it describes goes away (a key appearing, for instance); {policy}.',
334
+ 'quota.warning.expired': '⚠️ Jev guard is degraded: {label} (since {since}Z, {failures} failures).'
335
+ + ' The cooldown has expired: the next command sends one probe request, and a success restores it right away; {policy}.',
336
+ 'quota.clearFailed.warning': '⚠️ Semantic judgment is back (this probe succeeded), but the degraded-state file could not be removed ({code}): {path}.'
337
+ + ' While it stays there, every later read presents it as "degraded" — remove it by hand, or check that directory permissions / read-only mount.',
325
338
  'quota.status.ok': '✅ Jev guard: healthy',
326
339
  'quota.status.ok.layers': ' L0 static rules + pre-screen + Jev semantic judgment, all four states available.',
327
340
  'quota.status.ok.noKey': ' ⚠️ But: no API key is currently resolved (`guard judge` falls back to L0/pre-screen).',
@@ -412,6 +425,7 @@ const MESSAGES = {
412
425
  'cli.log.costUnknown': ' semantic judgment cost: not recorded (recent calls returned no usage; records written before the upgrade carry none either)',
413
426
  'cli.status.cleared': 'Degradation state cleared. The next command will try the online judge again (a failure re-enters degradation).',
414
427
  'cli.status.nothingToClear': 'Nothing to clear: the valve is not degraded.',
428
+ 'cli.status.clearFailed': 'Could not clear ({code}): the degraded-state file is still there — do not read this as "not degraded". A read-only filesystem or missing directory permissions both look like this; while the file is readable but undeletable, the valve treats **every** command after the cooldown expires as a probe (one request each).',
415
429
  'cli.status.stateFile': ' state file: {path}{missing}',
416
430
  'cli.status.stateFileMissing': ' (absent = healthy)',
417
431
  'cli.status.explainer': ' note: degraded = the **paid semantic judgment** is off; the free L0 rules and pre-screen keep working (currently degradePolicy={policy}).',
package/lib/quota.js CHANGED
@@ -31,6 +31,13 @@
31
31
  * `scope: 'local'` 的本地状态**只压制写下它的那个入口**。这正是"局部问题不该造成全局
32
32
  * 失能"的解法 —— 旧版本的做法是干脆不降级,代价是没人看得见(见 KINDS 里的长注释)。
33
33
  *
34
+ * 6. **`403` 不一定是"我们的密钥坏了"**(2026-09-23,见 docs/DECISIONS.md D16)。401/403 里有
35
+ * 一类响应根本不是那个 JSON 应用发的:CDN/WAF(常见 Cloudflare)在**边缘**就把请求拦了,
36
+ * 回一个 HTML 错误页 —— 密钥连被看过都没有。旧行为把 `401 || 403` 一并归成 `auth`,
37
+ * 于是一次边缘抖动换来 30 分钟全局失能,外加一个误导人的标签"密钥无效或被撤销"。
38
+ * 现在分类器先问"这份响应像不像那个 API 发的",不像就归 `edge`:与第 4 条同一条规矩
39
+ * (瞬态、逐次 fail-open、只留一条分类痕迹),因为它描述的是"路上的机器",不是服务对我们的态度。
40
+ *
34
41
  * @module jev-guard/quota
35
42
  */
36
43
 
@@ -55,6 +62,11 @@ export const DEFAULT_DEGRADED_PATH = process.env.JEV_GUARD_DEGRADED_STATE ?? joi
55
62
  export const KINDS = Object.freeze({
56
63
  quota: Object.freeze({ degraded: true, scope: 'global', cooldownMs: 15 * 60 * 1000 }),
57
64
  auth: Object.freeze({ degraded: true, scope: 'global', cooldownMs: 30 * 60 * 1000 }),
65
+ // 边缘/WAF 拦下(403 或 401 配一个 HTML 错误页,见 classifyFailure 与 D16)。它**不降级**:
66
+ // 那个 HTML 不是判定服务发的,所以它既没告诉我们密钥坏了、也没告诉我们额度没了 —— 它只说明
67
+ // "这一次请求没走到"。按 `auth` 处理会让一次边缘抖动造成 30 分钟全局失能,而且把原因写错。
68
+ // 与 timeout/network 同类:逐次 fail-open,只在 `guard log --stats` 里留下 edge 计数。
69
+ edge: Object.freeze({ degraded: false, cooldownMs: 0 }),
58
70
  // "没解析到密钥"是**本地配置**状况,不是服务状况。它**也要降级**(没有效密钥时必须像
59
71
  // 额度耗尽那样明说,而不是每条命令静默 fail-open),但降级方式与服务侧相反:
60
72
  // · `sticky: true` —— 一次 HTTP 都不发,没有可探测对象,所以不靠冷却到期结束,
@@ -155,7 +167,10 @@ export function cooldownMs(kind, cfg = {}) {
155
167
  * 还定不了就是 `unknown`(不降级,只逐次放行并留痕)。宁可少降级,不要误降级 —— 误降级会让
156
168
  * 阀门在额度充足时也停止防护。
157
169
  *
158
- * @param error - 抛出的异常(带 `status` / `code` / `name` / `message`)。
170
+ * 唯一的"一个状态码对应两个来源"是 401/403(应用层鉴权失败 vs 边缘/WAF 拦截),它靠
171
+ * `looksLikeEdgeBlock()` 分辨 —— 判不准时归 `auth`,因为"提醒人去查密钥"比"静默地少降级"更该发生。
172
+ *
173
+ * @param error - 抛出的异常(带 `status` / `contentType` / `body` / `code` / `name` / `message`)。
159
174
  * @returns `{ kind, status?, detail }`。
160
175
  */
161
176
  export function classifyFailure(error) {
@@ -168,6 +183,11 @@ export function classifyFailure(error) {
168
183
  if (code === 'no-key') return { kind: 'no-key', detail }
169
184
  if (code === 'shape') return { kind: 'shape', detail }
170
185
  if (status === 402) return { kind: 'quota', status, detail }
186
+ // 401/403 这两种码有两个来源,处置完全相反,所以先分辨来源再分类(2026-09-23,D16):
187
+ // · 应用层鉴权失败 —— 密钥无效/被撤销 → `auth`,持久,冷却 30 分钟并提示去换密钥;
188
+ // · 边缘/WAF 把我们挡在门外 —— 请求没到应用层,密钥都没被看过 → `edge`,瞬态,逐次 fail-open。
189
+ // 分辨不了时按 `auth`(宁可多提醒人查钥匙,不要把真正的密钥失效放过)。
190
+ if ((status === 401 || status === 403) && looksLikeEdgeBlock(error, text)) return { kind: 'edge', status, detail }
171
191
  if (status === 401 || status === 403) return { kind: 'auth', status, detail }
172
192
  if (status === 429) {
173
193
  // 429 有两种含义:限流(等一下就好)与额度耗尽(得充钱)。正文能区分才降级。
@@ -265,20 +285,62 @@ export async function enterDegraded(kind, options = {}) {
265
285
 
266
286
  /**
267
287
  * 清除降级状态(探测成功后自动调用;也可由 `guard status --clear` 手动调用)。
288
+ *
289
+ * 返回值刻意把三件事分开(2026-09-23):**清掉了** / **本来就没有** / **清不掉**。
290
+ * 第一版只有 true/false,于是只读文件系统(`EROFS`)、权限不足这类真实故障被报成
291
+ * "当前没有降级状态,无需清除" —— 状态文件还在,人却以为已经清了,真正的原因被吞掉。
292
+ *
268
293
  * @param options - `{ degradedPath }`。
269
- * @returns true = 确实清掉了一个状态文件。
294
+ * @returns `{ removed, ok, code?, error? }`:`removed` = 这次真的删掉了一个文件;
295
+ * `ok` = "现在没有状态文件了"(删掉了,或本来就没有 —— 后者是成功,不是故障);
296
+ * 失败时 `code` 是 errno 名(`EROFS` / `EACCES` / …),`error` 是原始消息。
270
297
  */
271
298
  export async function clearDegraded(options = {}) {
272
299
  const path = resolveDegradedPath(options)
273
300
  try {
274
301
  const { unlink } = await import('node:fs/promises')
275
302
  await unlink(path)
276
- return true
277
- } catch {
278
- return false
303
+ return { removed: true, ok: true }
304
+ } catch (error) {
305
+ // ENOENT = 状态文件本来就不存在,想要的结果已经成立 —— 报失败会误导人。
306
+ if (error?.code === 'ENOENT') return { removed: false, ok: true }
307
+ return {
308
+ removed: false,
309
+ ok: false,
310
+ code: typeof error?.code === 'string' ? error.code : undefined,
311
+ error: String(error?.message ?? error).slice(0, 200),
312
+ }
279
313
  }
280
314
  }
281
315
 
316
+ /**
317
+ * 这个 401/403 是**边缘/WAF 拦的**,还是应用层鉴权失败?
318
+ *
319
+ * 依据只有一个问题:"这份响应像不像那个 JSON 应用发的?"API 只讲 JSON,所以下面任一条成立
320
+ * 就说明拦截发生在应用之前 —— 密钥根本没被检查过:
321
+ *
322
+ * ① `content-type` 明确是 HTML(`text/html`、`application/xhtml+xml`)—— nginx/apache 与
323
+ * Cloudflare 的默认错误页都在这一类;
324
+ * ② 正文里有 HTML 结构(`<!doctype html` / `<html` / `<head` / `<body>`)—— 应用即使
325
+ * 返回 403 也只会给 JSON,给 HTML 的一定不是它;
326
+ * ③ 正文里有边缘厂商的指纹(Cloudflare 的 `cf-ray` / `Attention Required` / `Error code: 102x`,
327
+ * 以及 Sucuri / Akamai / Imperva 的错误页措辞)。
328
+ *
329
+ * 反过来:**正文是 JSON 就一律不算边缘**,照旧按应用层鉴权失败(`auth`)处理 —— 那才是真正的
330
+ * "密钥无效或被撤销",必须让人去换密钥,不能当成路过的抖动。宁可在少见的自定义错误页上多降级
331
+ * 一次(标签会指向"密钥"),也不要漏掉一次真正的密钥失效。
332
+ *
333
+ * @param error - 抛出的异常(带 `contentType` / `body` / `message`)。
334
+ * @param text - 已小写化的 `code + body + message` 拼接串(调用方已算好,避免重复拼)。
335
+ * @returns true = 判为边缘拦截(不降级)。
336
+ */
337
+ function looksLikeEdgeBlock(error, text) {
338
+ if (/html/.test(String(error?.contentType ?? '').toLowerCase())) return true
339
+ if (/<!doctype\s+html|<html[\s>]|<head[\s>]|<body[\s>]/.test(text)) return true
340
+ if (/cloudflare|cf-ray|cf-error|attention required|you have been blocked|error code:\s*1\d{3}|sucuri|akamai|incapsula|imperva/.test(text)) return true
341
+ return false
342
+ }
343
+
282
344
  /**
283
345
  * `until` 的毫秒表示(ISO 字符串;兼容直接存数字的老状态文件)。
284
346
  * @param state - 降级状态。
@@ -351,6 +413,10 @@ export function warningLine(state, now = Date.now()) {
351
413
  }
352
414
  // 粘性状态没有"多少分钟后自动恢复"这句话 —— 对它说"等 0 分钟"等于骗人,所以换一条文案。
353
415
  if (isSticky(state)) return t('quota.warning.sticky', common)
416
+ // 冷却已到期 = 现在等的就是"下一条命令那一次探测"。这时说"暂停 0 分钟"是自相矛盾的:
417
+ // 它既会进每条非 allow 判定的理由,也会被 `guard log` / `guard allow` 打印出来 —— 而
418
+ // 在只读文件系统那种"永远到期"的状态下,那两位会被反复念(2026-09-23)。
419
+ if (retryInMs(state, now) <= 0) return t('quota.warning.expired', common)
354
420
  return t('quota.warning', { ...common, mins: Math.round(retryInMs(state, now) / 60000) })
355
421
  }
356
422
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-jev-guard",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Pre-execution safety valve for DeepSeek Harness: a TypeSafe Jev question decides whether a shell command/script would irreversibly delete or overwrite real data. Four states (allow/revise/block/escalate), mounted on tools/pre-execute. Zero dependencies; WSL/Linux (bash) and Windows (pwsh); one-shot tokens, credit-exhaustion degradation, shared audit log; bilingual (zh-CN / en) messages.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -31,7 +31,7 @@
31
31
  "./package.json": "./package.json"
32
32
  },
33
33
  "bin": {
34
- "jev-guard": "./bin/guard.mjs"
34
+ "jev-guard": "bin/guard.mjs"
35
35
  },
36
36
  "scripts": {
37
37
  "selftest": "node bin/guard.mjs selftest",
@@ -40,6 +40,7 @@ const FILES = [
40
40
  'CHANGELOG.md',
41
41
  'DEPLOY.md',
42
42
  'START-HERE.md',
43
+ 'RELEASING.md',
43
44
  'adapters/README.md',
44
45
  'verification-results/README.md',
45
46
  'docs/ARCHITECTURE.md',
@@ -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 → auth', classifyFailure(httpError(401, 'bad key')).kind === 'auth')
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'
@@ -192,6 +192,47 @@ async function main() {
192
192
  if (apiKey) process.env.TYPESAFE_API_KEY = apiKey
193
193
  await rm(degradedPath, { force: true })
194
194
 
195
+ // ---- 探测成功、但状态文件清不掉(只读文件系统):原因必须有人能发现(2026-09-23)----
196
+ //
197
+ // 现场是只读沙箱:服务已经用一次成功判定证明自己活着,可 `unlink` 删不掉 degraded.json。
198
+ // gate 把那次判定标成 clearFailed + 一句告警;适配器的责任是让它**落地**:host 日志一条
199
+ // warn + 审计一条 level:'warn' 记录(带 errno 与路径)。否则这个原因就只剩 `guard status`
200
+ // 里一句"已降级",没人知道该去删那个文件。
201
+ // 用替身 fetch + 一个假密钥就能离线跑:不需要真网络,也不需要真密钥。
202
+ const stuckPath = join(dir, 'degraded-as-dir.json')
203
+ await writeFile(stuckPath, `${JSON.stringify({
204
+ kind: 'quota', label: '判定服务额度已用尽', since: new Date(Date.now() - 20 * 60 * 1000).toISOString(),
205
+ until: new Date(Date.now() - 60 * 1000).toISOString(), cooldownMs: 900000, failures: 1, probes: 0, policy: 'l0-only',
206
+ })}\n`)
207
+ const stuckCtx = mockContext('smoke-fake-key')
208
+ applySmoke(stuckCtx.ctx, { degradedPath: stuckPath })
209
+ const handlerStuck = stuckCtx.handlers.get('tools/pre-execute')
210
+ const realFetch = globalThis.fetch
211
+ globalThis.fetch = async () => {
212
+ // 请求"进行中"把状态文件换成同路径的目录 —— 于是随后的 unlink 必定失败(EISDIR/EPERM),
213
+ // 而"读"已经发生过了。这正是只读文件系统下的处境,两个平台都能造出来。
214
+ await rm(stuckPath, { force: true })
215
+ await mkdir(stuckPath, { recursive: true })
216
+ return {
217
+ ok: true, status: 200, headers: { get: () => 'application/json' },
218
+ text: async () => '', json: async () => ({ model: 'jev-1.13.0', answers: { destroys_data: { noul: 0.1 } } }),
219
+ }
220
+ }
221
+ try {
222
+ expect('探测成功+状态文件清不掉 → 判定仍是 allow(服务确实答了这次探测)',
223
+ await handlerStuck(fakeExec('rm -rf /home/user/jev-guard-stuck-demo'), nextAllow), 'allow')
224
+ } finally {
225
+ globalThis.fetch = realFetch
226
+ }
227
+ await flush()
228
+ const stuckRecords = (await readFile(auditPath, 'utf8')).split('\n').filter(Boolean)
229
+ .map(l => JSON.parse(l)).filter(r => r.clearFailed)
230
+ expectTrue('清不掉 → 审计里留下一条带 errno 的 warn 记录(不是静默)',
231
+ stuckRecords.length === 1 && stuckRecords[0].level === 'warn' && typeof stuckRecords[0].clearFailed.code === 'string'
232
+ && String(stuckRecords[0].warning ?? '').includes('删不掉'),
233
+ JSON.stringify(stuckRecords.map(r => r.clearFailed)))
234
+ await rm(stuckPath, { recursive: true, force: true })
235
+
195
236
  // ---- 会话内 notice:纯 host 插件唯一能让用户真看到的渠道(D15)----
196
237
  //
197
238
  // 这一段同时验证两件互相依存的事:① `agent/pre-step` 真的接线了,消息是**追加**而不是替换,