dsh-jev-guard 0.5.1

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +285 -0
  2. package/CHANGELOG.zh-CN.md +271 -0
  3. package/DEPLOY.md +202 -0
  4. package/DEPLOY.zh-CN.md +200 -0
  5. package/LICENSE +21 -0
  6. package/README.md +316 -0
  7. package/README.zh-CN.md +315 -0
  8. package/START-HERE.md +97 -0
  9. package/START-HERE.zh-CN.md +97 -0
  10. package/adapters/README.md +37 -0
  11. package/adapters/README.zh-CN.md +37 -0
  12. package/adapters/dsh/index.js +502 -0
  13. package/bin/guard.mjs +634 -0
  14. package/config.example.json +52 -0
  15. package/cordis.patch.yml +120 -0
  16. package/docs/AGENT-TASK-dsh.md +134 -0
  17. package/docs/AGENT-TASK-dsh.zh-CN.md +131 -0
  18. package/docs/ARCHITECTURE.md +118 -0
  19. package/docs/ARCHITECTURE.zh-CN.md +117 -0
  20. package/docs/DECISIONS.md +469 -0
  21. package/docs/DECISIONS.zh-CN.md +449 -0
  22. package/docs/DSH-INTEGRATION.md +178 -0
  23. package/docs/DSH-INTEGRATION.zh-CN.md +171 -0
  24. package/docs/MEASUREMENTS.md +433 -0
  25. package/docs/MEASUREMENTS.zh-CN.md +450 -0
  26. package/docs/USER-INTERVENTION.md +141 -0
  27. package/docs/USER-INTERVENTION.zh-CN.md +143 -0
  28. package/docs/VERIFICATION.md +279 -0
  29. package/docs/VERIFICATION.zh-CN.md +278 -0
  30. package/lib/audit.js +228 -0
  31. package/lib/gate.js +720 -0
  32. package/lib/i18n.js +575 -0
  33. package/lib/quota.js +389 -0
  34. package/lib/rules.js +174 -0
  35. package/lib/token.js +154 -0
  36. package/lib/verdict.js +285 -0
  37. package/package.json +82 -0
  38. package/tools/check-doc-pairs.mjs +158 -0
  39. package/tools/extract-commands.mjs +156 -0
  40. package/tools/gate-cli.mjs +240 -0
  41. package/tools/probe-prompt-lang.mjs +238 -0
  42. package/tools/probe-scripts.mjs +143 -0
  43. package/tools/report-result.mjs +146 -0
  44. package/tools/selftest-audit.mjs +93 -0
  45. package/tools/selftest-entry.mjs +177 -0
  46. package/tools/selftest-i18n.mjs +177 -0
  47. package/tools/selftest-quota.mjs +260 -0
  48. package/tools/selftest-reason.mjs +266 -0
  49. package/tools/selftest-rules.mjs +107 -0
  50. package/tools/selftest-token.mjs +100 -0
  51. package/tools/smoke-dsh-adapter.mjs +295 -0
  52. package/tools/smoke-dsh-pipeline.mjs +146 -0
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 双语文案自检:目录完整、两语言同键、英文侧真的没有残留中文,以及一个关键的不变量 ——
4
+ * **换了界面语言不得改变发给判定服务的那句话**。
5
+ *
6
+ * 为什么单独有这份自检:
7
+ * 1. 文案开始按语言分叉之后,"某个键只写了一种语言"是一种**静默降级** ——
8
+ * 运行时退回中文,英文用户看到一句中文,没有任何报错。
9
+ * 2. 占位符(`{token}`、`{cli}`)漏一个,输出就会少一段关键信息,而复制粘贴的
10
+ * 授权行一旦不完整,用户拿到的令牌与 AI 重试的命令就对不上(见 selftest-reason)。
11
+ * 3. 判定层与界面层**必须解耦**:那句问话与 state 的键属于 `promptLang`,默认是
12
+ * 标定过的中文;界面切成英文时它**不能**跟着变,否则阈值这条被测过的边界会悄悄挪动。
13
+ *
14
+ * 全部离线:不联网、不碰 ~/.jev-guard。语言切来切去是本文件自己的事,结束时还原。
15
+ *
16
+ * @module jev-guard/tools/selftest-i18n
17
+ */
18
+
19
+ import { ASK_RULES, DENY_RULES, staticRule } from '../lib/rules.js'
20
+ import { FALLBACK_LANG, LANGS, detectLang, keysOf, normalizeLang, setLang, t } from '../lib/i18n.js'
21
+ import { judgeQuestion } from '../lib/gate.js'
22
+ import { explain, reviseGuidance, shellName, templates } from '../lib/verdict.js'
23
+
24
+ let failed = 0
25
+ let checks = 0
26
+
27
+ /**
28
+ * @param label - 用例名。
29
+ * @param ok - 断言结果。
30
+ * @param detail - 失败细节。
31
+ */
32
+ function expect(label, ok, detail = '') {
33
+ checks += 1
34
+ if (!ok) failed += 1
35
+ process.stdout.write(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ` ${detail}`}\n`)
36
+ }
37
+
38
+ /** 取出一条消息里的占位符名字(排序后便于比较)。 */
39
+ const placeholders = (text) => [...String(text).matchAll(/\{(\w+)\}/g)].map(m => m[1]).sort()
40
+
41
+ const CJK = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3000-\u303f\uff00-\uffef]/
42
+
43
+ // ── 1. 目录完整性 ───────────────────────────────────────────────────────────
44
+ const zhKeys = keysOf('zh-CN')
45
+ const enKeys = keysOf('en')
46
+ const missingEn = zhKeys.filter(k => !enKeys.includes(k))
47
+ const missingZh = enKeys.filter(k => !zhKeys.includes(k))
48
+ expect('两种语言的键集合一致', missingEn.length === 0 && missingZh.length === 0,
49
+ `只有中文:${missingEn.join(',') || '-'} / 只有英文:${missingZh.join(',') || '-'}`)
50
+ expect('目录非空且覆盖到 CLI 与判定理由', zhKeys.length > 80 && zhKeys.some(k => k.startsWith('verdict.')) && zhKeys.some(k => k.startsWith('cli.')),
51
+ `${zhKeys.length} 个键`)
52
+
53
+ const badPlaceholders = []
54
+ for (const key of zhKeys) {
55
+ setLang('zh-CN')
56
+ const zh = placeholders(t(key))
57
+ setLang('en')
58
+ const en = placeholders(t(key))
59
+ if (zh.join() !== en.join()) badPlaceholders.push(`${key}(${zh.join('+') || '-'} vs ${en.join('+') || '-'})`)
60
+ }
61
+ expect('同一键的占位符在两种语言里一致', badPlaceholders.length === 0, badPlaceholders.join(', '))
62
+
63
+ // 会话内 notice 的摘要会渲染成对话里的折叠行,DSH 对它的上限是 120 字符
64
+ // (CONTEXT_SUMMARY_MAX_CHARS;超了就被截断,读者看到的话断在半句上)。英文通常更长,
65
+ // 所以两种语言都量一遍 —— "只有英文超了"这类问题只有双语并排才看得见。
66
+ const summaryKeys = zhKeys.filter(k => /^notice\..*\.summary$/.test(k))
67
+ expect('notice 摘要键齐备(没有密钥 / 降级 / 恢复)', summaryKeys.length === 3, summaryKeys.join(','))
68
+ const overlongSummaries = []
69
+ for (const lang of ['zh-CN', 'en']) {
70
+ setLang(lang)
71
+ for (const key of summaryKeys) {
72
+ const text = t(key)
73
+ if (text.length > 120 || text.includes('\n')) overlongSummaries.push(`${lang}:${key}(${text.length})`)
74
+ }
75
+ }
76
+ expect('notice 摘要 ≤120 字符且不含换行', overlongSummaries.length === 0, overlongSummaries.join(', '))
77
+ setLang('zh-CN')
78
+
79
+ // ── 2. 英文侧不得残留中文(半翻译是最常见的静默缺陷)─────────────────────────
80
+ setLang('en')
81
+ const leftovers = enKeys.filter(k => CJK.test(t(k)))
82
+ expect('英文文案里没有残留中文', leftovers.length === 0, leftovers.slice(0, 5).join(','))
83
+ const keyAsValue = enKeys.filter(k => t(k) === k)
84
+ expect('英文查表不会返回键名本身(返回键名 = 键写错或漏翻)', keyAsValue.length === 0, keyAsValue.slice(0, 5).join(','))
85
+
86
+ // ── 3. 语言解析 ─────────────────────────────────────────────────────────────
87
+ expect('zh / zh-TW / zh-Hans 都归到 zh-CN', ['zh', 'zh-TW', 'zh-Hans'].every(v => normalizeLang(v) === 'zh-CN'))
88
+ expect('en / en-US / EN 都归到 en', ['en', 'en-US', 'EN'].every(v => normalizeLang(v) === 'en'))
89
+ expect('不认识的语言返回 undefined(由调用方决定怎么办)', normalizeLang('klingon') === undefined && normalizeLang('') === undefined)
90
+
91
+ // 探测链只认**显式信号**。这一段是 2026-09-20 真机踩坑后的护栏:DSH 插件跑在 WSL 里,
92
+ // 那里 LANG=C.UTF-8,当时链条落到 Intl → Node 报 en-US(ICU 兜底值,不是用户偏好),
93
+ // 于是会话里的理由悄悄变英文,而 Windows 侧 CLI 仍是中文 —— 同一台机器两种语言。
94
+ expect('JEV_GUARD_LANG 优先于 locale 变量', detectLang({ JEV_GUARD_LANG: 'en', LANG: 'zh_CN.UTF-8' }) === 'en')
95
+ expect('locale 变量能定语言(LC_ALL 优先于 LANG)', detectLang({ LC_ALL: 'zh_CN.UTF-8', LANG: 'en_US.UTF-8' }) === 'zh-CN')
96
+ expect('LANG=en_US.UTF-8 这样的真实 locale 会解析成 en', detectLang({ LANG: 'en_US.UTF-8' }) === 'en')
97
+ expect('C / POSIX / 空 = 没有信号,不是英文', detectLang({ LANG: 'C.UTF-8' }) === undefined && detectLang({ LANG: 'POSIX' }) === undefined && detectLang({}) === undefined)
98
+ expect('没有信号时的落点是项目主语言 zh-CN', FALLBACK_LANG === 'zh-CN')
99
+ const autoPick = setLang('auto')
100
+ const intlNow = (() => { try { return Intl.DateTimeFormat().resolvedOptions().locale } catch { return '?' } })()
101
+ expect('本机 auto 解析出的语言受支持(且不会因为 Intl 兜底值而变成英文)',
102
+ LANGS.includes(autoPick.lang), `解析=${autoPick.lang} 理由=${autoPick.reason} 本机 Intl=${intlNow}`)
103
+
104
+ // ── 4. 英文判定理由:形状与中文一致(路径、引号、令牌都不能少)────────────────
105
+ // 上面探测链那一节以 `setLang('auto')` 收尾(在本机 = 中文),所以这里显式切回英文。
106
+ setLang('en')
107
+ const EN_CMD = "rm -rf /tmp/it's"
108
+ const risk = { action: 'block', source: 'jev', p: 0.912, model: 'm', ms: 1 }
109
+ const enReason = explain(EN_CMD, risk, { policy: 'never', token: 'ALLOW-0A2DB6157F', platform: 'win32' })
110
+ expect('英文理由带四态抬头与概率', enReason.includes('Jev guard [blocked]') && enReason.includes('91.2%'), enReason.slice(0, 160))
111
+ expect('英文理由说明这是自动判定', enReason.includes('automatic decision'))
112
+ expect('英文理由里的授权行按 win32 用 PowerShell 引号', enReason.includes("allow 'rm -rf /tmp/it''s'"), enReason)
113
+ expect('英文理由带令牌原文', enReason.includes('ALLOW-0A2DB6157F'))
114
+ expect('英文理由的平台名是英文', shellName('linux') === 'a POSIX shell (bash etc.)' && shellName('win32') === 'PowerShell', shellName('linux'))
115
+
116
+ const enRule = staticRule('git push --force origin main')
117
+ expect('命中规则的英文理由来自规则本身(不是退回 id)', Boolean(enRule) && /force-pushes/.test(enRule.why), String(enRule?.why))
118
+ const enRuleReason = explain('git push --force origin main', { action: 'block', source: 'static-rule', rule: enRule }, {})
119
+ expect('英文理由把规则 id 与英文 why 一起写出', enRuleReason.includes('hard rule `git-force-push` hit (force-pushes'), enRuleReason.slice(0, 200))
120
+
121
+ const enGuidance = reviseGuidance(EN_CMD, { action: 'revise', source: 'jev', p: 0.6 }, { policy: 'never' })
122
+ expect('英文 revise 指导语含三条模板', enGuidance.includes('Safer forms to try:') && (enGuidance.match(/^- /gm) ?? []).length === 3, enGuidance)
123
+
124
+ // ── 5. 规则与模板:两种语言都得有 ─────────────────────────────────────────────
125
+ const allRules = [...DENY_RULES, ...ASK_RULES]
126
+ const ruleGaps = allRules.filter(r => LANGS.some(l => typeof r.why?.[l] !== 'string' || r.why[l].trim() === ''))
127
+ expect(`全部 ${allRules.length} 条 L0 规则都有两种语言的理由`, ruleGaps.length === 0, ruleGaps.map(r => r.id).join(','))
128
+ for (const lang of LANGS) {
129
+ setLang(lang)
130
+ const list = templates()
131
+ expect(`${lang}:三条降级模板都带至少 2 个例子`, list.length === 3 && list.every(x => x.examples.length >= 2), JSON.stringify(list.map(x => [x.id, x.examples.length])))
132
+ }
133
+
134
+ // 规则理由必须**跟着界面语言走**(它是给人/模型读的拒绝理由的一部分)。
135
+ setLang('en')
136
+ const enWhy = staticRule('mkfs.ext4 /dev/sdb1')?.why
137
+ setLang('zh-CN')
138
+ const zhWhy = staticRule('mkfs.ext4 /dev/sdb1')?.why
139
+ expect('规则理由随界面语言切换', enWhy !== zhWhy && /formats a filesystem/.test(enWhy) && /格式化文件系统/.test(zhWhy), `${enWhy} | ${zhWhy}`)
140
+
141
+ // ── 6. 不变量:界面语言**不得**改变发给判定服务的东西 ─────────────────────────
142
+ // 这是本次改造最要紧的一条:阈值是在中文问话上标定的(114 例),英文界面不能把它挪走。
143
+ setLang('en')
144
+ const qEnUi = judgeQuestion(undefined)
145
+ const qDefault = judgeQuestion()
146
+ expect('界面切成英文后,发给 Jev 的问话仍是标定过的中文',
147
+ qEnUi.instructions === qDefault.instructions && /不可逆/.test(qEnUi.instructions), qEnUi.instructions)
148
+ const qEnPrompt = judgeQuestion('en')
149
+ expect('promptLang: "en" 时才用英文问话(显式选择)', qEnPrompt.instructions !== qDefault.instructions && /irreversibly/.test(qEnPrompt.instructions), qEnPrompt.instructions)
150
+ expect('英文问话的 criteria 也是英文', qEnPrompt.criteria.true !== qDefault.criteria.true && /block-device|version history/.test(qEnPrompt.criteria.true))
151
+
152
+ // state 的键同理:它属于 promptLang。用 buildState 的**键**来断言,不联网。
153
+ const { buildState } = await import('../lib/gate.js')
154
+ const zhState = await buildState('node scripts/x.mjs', { promptLang: 'zh-CN', inlineScripts: false })
155
+ const enState = await buildState('node scripts/x.mjs', { promptLang: 'en', inlineScripts: false })
156
+ expect('state 的键随 promptLang 切换', Object.keys(zhState)[0] === '命令' && Object.keys(enState)[0] === 'command',
157
+ `${Object.keys(zhState)[0]} / ${Object.keys(enState)[0]}`)
158
+
159
+ // 更硬的一条:**界面语言不得进入请求体**。同一 promptLang 下,中英两种界面必须构造出
160
+ // 逐字节相同的 state 与问话 —— 否则"换文案"就悄悄变成了"换判定",而 p 的抖动会把它掩盖掉。
161
+ const { evaluateCommand } = await import('../lib/gate.js')
162
+ const payloads = []
163
+ for (const uiLang of ['zh-CN', 'en']) {
164
+ setLang(uiLang)
165
+ payloads.push(JSON.stringify({
166
+ state: await buildState('node scripts/x.mjs', { promptLang: 'zh-CN', inlineScripts: false }),
167
+ question: judgeQuestion('zh-CN'),
168
+ }))
169
+ }
170
+ expect('界面语言不进请求体:中英两种界面构造出完全相同的 state 与问话', payloads[0] === payloads[1], payloads.join(' vs '))
171
+ expect('evaluateCommand 也不会把界面语言带进判定(离线:预筛路径)', typeof evaluateCommand === 'function')
172
+
173
+ // 还原:本文件把语言切来切去,退出前恢复中文,免得留在英文上。
174
+ setLang('zh-CN')
175
+
176
+ process.stdout.write(`\n${failed === 0 ? '全部通过' : `${failed} 项失败`}(${checks} 例)\n`)
177
+ process.exit(failed === 0 ? 0 : 1)
@@ -0,0 +1,260 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 额度降级自检 —— 离线,靠替身 `fetch` 模拟各种失败。
4
+ *
5
+ * 为什么这份自检重要:这里测的全是"出问题时的行为",而**出问题时的静默失效**是这个项目
6
+ * 已经踩过的坑(审计日志那一次整整白跑一轮)。所以每个断言都在问同一个问题:
7
+ * "额度/密钥坏掉以后,人还能不能发现,以及阀门还剩哪一层在工作?"
8
+ *
9
+ * 全部用临时目录与显式路径,不碰 `~/.jev-guard`,不联网。
10
+ *
11
+ * @module jev-guard/tools/selftest-quota
12
+ */
13
+
14
+ import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
15
+ import { tmpdir } from 'node:os'
16
+ import { join } from 'node:path'
17
+ import { DEFAULTS, evaluateCommand } from '../lib/gate.js'
18
+ import { setLang } from '../lib/i18n.js'
19
+ import {
20
+ KINDS, classifyFailure, clearDegraded, cooldownMs, enterDegraded, isDegraded, isSticky, kindScope, probeDue,
21
+ readDegraded, statusText, warningLine,
22
+ } from '../lib/quota.js'
23
+ import { explain } from '../lib/verdict.js'
24
+
25
+ // 下面的断言读的是中文文案(告警行、状态报告),先把语言钉死;英文侧见 selftest-i18n。
26
+ setLang('zh-CN')
27
+
28
+ let failed = 0
29
+ let checks = 0
30
+
31
+ /**
32
+ * @param label - 用例名。
33
+ * @param ok - 断言结果。
34
+ * @param detail - 失败细节。
35
+ */
36
+ function expect(label, ok, detail = '') {
37
+ checks += 1
38
+ if (!ok) failed += 1
39
+ process.stdout.write(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : ` ${detail}`}\n`)
40
+ }
41
+
42
+ const dir = await mkdtemp(join(tmpdir(), 'jev-guard-quota-'))
43
+ const cfg = { degradedPath: join(dir, 'degraded.json') }
44
+
45
+ /** 一条会走联网判定的命令(不在 L0、也不是只读预筛)。 */
46
+ const CMD = 'rm -rf /home/user/jev-guard-demo'
47
+
48
+ /** 替身 fetch:按脚本依次返回响应或抛错,并记录调用次数。 */
49
+ let calls = []
50
+ let script = []
51
+ globalThis.fetch = async () => {
52
+ calls.push(1)
53
+ const next = script.shift()
54
+ if (next === undefined) throw new Error('fetch called more times than scripted')
55
+ if (next instanceof Error) throw next
56
+ return {
57
+ ok: next.status >= 200 && next.status < 300,
58
+ status: next.status,
59
+ text: async () => next.body ?? '',
60
+ json: async () => next.json ?? {},
61
+ }
62
+ }
63
+
64
+ const httpError = (status, body = '') => {
65
+ const e = new Error(`HTTP ${status}: ${body}`)
66
+ e.status = status
67
+ e.body = body
68
+ return e
69
+ }
70
+ const okAnswer = (p, usage) => ({ status: 200, json: { model: 'jev-1.13.0', answers: { destroys_data: { noul: p } }, ...(usage ? { usage } : {}) } })
71
+
72
+ // 1) 分类:能定就定,定不了就不降级(宁可少降级,不要误降级)
73
+ 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')
76
+ expect('429(纯限流)→ rate-limit', classifyFailure(httpError(429, 'slow down')).kind === 'rate-limit')
77
+ expect('429(含额度字样)→ quota', classifyFailure(httpError(429, 'quota exceeded')).kind === 'quota')
78
+ expect('500 → server', classifyFailure(httpError(500)).kind === 'server')
79
+ expect('503 → server', classifyFailure(httpError(503)).kind === 'server')
80
+ expect('超时 → timeout', classifyFailure(Object.assign(new Error('timed out'), { name: 'TimeoutError' })).kind === 'timeout')
81
+ expect('网络 → network', classifyFailure(new TypeError('fetch failed')).kind === 'network')
82
+ const noKey = Object.assign(new Error('no key'), { code: 'no-key' })
83
+ expect('无密钥 → no-key', classifyFailure(noKey).kind === 'no-key')
84
+ expect('未知 → unknown(不降级)', classifyFailure(new Error('???')) && !KINDS.unknown.degraded)
85
+
86
+ // 2) 会降级的类别,以及**两种降级方式**(2026-09-20,D15):
87
+ // · quota / auth = 服务侧状况 → 冷却式:到期放一次探测;
88
+ // · no-key = 本地配置状况 → **粘性**:没密钥时零 HTTP、没有可探测对象,所以靠"密钥出现"结束,
89
+ // 而且带作用域 —— 只压制写下它的那条入口(密钥解析各入口独立,degraded.json 却是全机共享)。
90
+ const degrading = Object.entries(KINDS).filter(([, v]) => v.degraded).map(([k]) => k).sort()
91
+ expect('会降级的类别 = auth / no-key / quota', degrading.join(',') === 'auth,no-key,quota', degrading.join(','))
92
+ expect('no-key 降级(没有效密钥必须像额度耗尽那样明说,而不是静默 fail-open)', KINDS['no-key'].degraded === true)
93
+ expect('no-key 是粘性的(不靠时间结束)', KINDS['no-key'].sticky === true && cooldownMs('no-key', {}) === 0)
94
+ expect('no-key 的作用域是本地(只压制写下它的入口)', KINDS['no-key'].scope === 'local' && kindScope('no-key') === 'local')
95
+ expect('quota / auth 的作用域是全局(服务侧状况影响所有入口)',
96
+ kindScope('quota') === 'global' && kindScope('auth') === 'global' && KINDS.quota.scope === 'global')
97
+ expect('rate-limit / server / timeout / network 也都不降级',
98
+ !KINDS['rate-limit'].degraded && !KINDS.server.degraded && !KINDS.timeout.degraded && !KINDS.network.degraded)
99
+
100
+ // 3) 额度耗尽:这次判定 fail-open,并且**留下可读状态**
101
+ script = [httpError(402, 'insufficient credits')]
102
+ calls = []
103
+ const first = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined, quotaGuard: true })
104
+ expect('402 → 判定仍是 allow(fail-open)', first.action === 'allow' && first.source === 'error', `${first.action}/${first.source}`)
105
+ expect('402 → 带上分类 errorKind=quota', first.errorKind === 'quota', String(first.errorKind))
106
+ expect('402 → 判定上带着降级信息', first.degraded?.kind === 'quota')
107
+ expect('402 → 判定上带着一句告警', typeof first.warning === 'string' && first.warning.includes('降级'))
108
+ expect('402 → 确实发了一次请求', calls.length === 1, String(calls.length))
109
+ const state = await readDegraded(cfg)
110
+ expect('402 → 写出了降级状态文件', state !== null && state.kind === 'quota')
111
+ expect('402 → 状态里有恢复时间与失败次数', Number.isFinite(state.until) && state.failures === 1)
112
+ expect('402 → 状态文件里是人可读的 ISO 时间', typeof JSON.parse(await readFile(cfg.degradedPath, 'utf8')).until === 'string')
113
+
114
+ // 4) 降级窗口内:不再发请求、直接放行、并告诉调用方为什么
115
+ script = []
116
+ calls = []
117
+ const second = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined })
118
+ expect('降级窗口内 → source=degraded', second.source === 'degraded', String(second.source))
119
+ expect('降级窗口内 → 完全没有发请求(省钱)', calls.length === 0, String(calls.length))
120
+ expect('降级窗口内 → allow', second.action === 'allow')
121
+ expect('降级窗口内 → 理由里说明"没经过语义判定"', explain(CMD, second).includes('降级'), explain(CMD, second).slice(0, 80))
122
+ // 这条单独立着:降级放行曾经复用预筛的文案("确定性预筛"),读者会以为它被确认过无害。
123
+ expect('降级放行的理由不说"确定性预筛"', !explain(CMD, second).includes('确定性预筛'), explain(CMD, second).slice(0, 80))
124
+ expect('降级放行的理由明确说未经过语义判定', explain(CMD, second).includes('未经过语义判定'))
125
+
126
+ // 5) **免费的 L0 照常工作**(默认策略的核心承诺)
127
+ calls = []
128
+ const l0 = await evaluateCommand('mkfs.ext4 /dev/sdb1', { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined })
129
+ expect('降级期间 L0 deny 仍然拦(免费层没停)', l0.action === 'block' && l0.source === 'static-rule', `${l0.action}/${l0.source}`)
130
+ expect('降级期间 L0 判定也带着降级信息', l0.degraded?.kind === 'quota')
131
+ expect('降级期间 L0 判定不发请求', calls.length === 0, String(calls.length))
132
+ const l0ask = await evaluateCommand('truncate -s 0 /tmp/x.txt', { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined })
133
+ expect('降级期间 L0 ask 仍然要求人工确认', l0ask.action === 'escalate' && l0ask.source === 'static-rule', `${l0ask.action}`)
134
+ const fast = await evaluateCommand('ls -la /var/log', { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined })
135
+ expect('降级期间预筛仍然放行只读命令', fast.source === 'prefilter')
136
+
137
+ // 6) degradePolicy:'off' = 连 L0 一起暂停(显式选择,不是默认)
138
+ const off = await evaluateCommand('mkfs.ext4 /dev/sdb1', { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined, degradePolicy: 'off' })
139
+ expect("degradePolicy:'off' → 连 L0 也放行", off.action === 'allow' && off.source === 'degraded', `${off.action}/${off.source}`)
140
+
141
+ // 7) 冷却到期 → 放**一次**探测;成功即自动恢复(不需要人做任何事)
142
+ //
143
+ // 种一个"已到期"的状态时**直接写文件**,不用 enterDegraded() —— 后者正是被测对象的一部分,
144
+ // 用它来铺垫会污染 failures 计数(第一版就这么写,断言当场对不上)。
145
+ const seedExpired = async (failures = 1) => {
146
+ const { writeFile } = await import('node:fs/promises')
147
+ await writeFile(cfg.degradedPath, `${JSON.stringify({
148
+ kind: 'quota', label: '判定服务额度已用尽', since: new Date(Date.now() - 20 * 60 * 1000).toISOString(),
149
+ until: new Date(Date.now() - 60 * 1000).toISOString(), cooldownMs: 900000, failures, probes: 0, policy: 'l0-only',
150
+ })}\n`)
151
+ }
152
+ await seedExpired(1)
153
+ const st = await readDegraded(cfg)
154
+ expect('冷却已到期 → probeDue', probeDue(st, Date.now()) === true)
155
+ script = [okAnswer(0.9, { input_tokens: 500, output_tokens: 20 })]
156
+ calls = []
157
+ const recovered = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined })
158
+ expect('探测成功 → 恢复成正常判定', recovered.source === 'jev' && recovered.action === 'block', `${recovered.source}/${recovered.action}`)
159
+ expect('探测成功 → 记录为 probe/recovered', recovered.probe === true && recovered.recovered === true, JSON.stringify({ probe: recovered.probe, recovered: recovered.recovered }))
160
+ expect('探测成功 → 降级状态被清掉', (await readDegraded(cfg)) === null)
161
+ expect('探测成功 → 拿到了 usage(成本可见性)', recovered.usage?.input_tokens === 500, JSON.stringify(recovered.usage))
162
+ expect('此时文件目录里没有残留 tmp 文件', (await readdir(dir)).every(f => !f.endsWith('.tmp')), (await readdir(dir)).join(','))
163
+
164
+ // 8) 探测失败 → 继续降级(而且不会每命令都试)
165
+ await seedExpired(1)
166
+ script = [httpError(402, 'still no credits')]
167
+ calls = []
168
+ const stillDown = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined })
169
+ expect('探测失败 → 仍然 fail-open', stillDown.action === 'allow' && stillDown.source === 'error')
170
+ expect('探测失败 → 判定上标记为一次探测', stillDown.probe === true)
171
+ const renewed = await readDegraded(cfg)
172
+ expect('探测失败 → 续期(failures 1 → 2,probes 0 → 1)', renewed.failures === 2 && renewed.probes === 1, JSON.stringify({ f: renewed.failures, p: renewed.probes }))
173
+ const afterProbe = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined })
174
+ expect('续期之后不再试(一次探测只花一次钱)', afterProbe.source === 'degraded' && calls.length === 1, `${afterProbe.source}/${calls.length}`)
175
+
176
+ // 9) 瞬态失败**不**降级(超时/网络/5xx 只逐次放行)
177
+ await clearDegraded(cfg)
178
+ script = [Object.assign(new Error('timed out'), { name: 'TimeoutError' })]
179
+ const transient = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined })
180
+ expect('超时 → fail-open 但不降级', transient.source === 'error' && transient.errorKind === 'timeout' && (await readDegraded(cfg)) === null)
181
+
182
+ // 10) 状态文件坏掉 → 当作健康(宁可去问一次 API,也不要卡在降级里)
183
+ const { writeFile } = await import('node:fs/promises')
184
+ await writeFile(cfg.degradedPath, '{ 这不是 JSON')
185
+ expect('损坏的状态文件 → readDegraded 返回 null', (await readDegraded(cfg)) === null)
186
+ script = [okAnswer(0.1)]
187
+ const afterCorrupt = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined })
188
+ expect('损坏状态下照常发请求', afterCorrupt.source === 'jev', String(afterCorrupt.source))
189
+
190
+ // 11) 告警与状态报告是给人看的,必须包含"原因/还剩多久/现在还剩哪一层"
191
+ await enterDegraded('quota', { cfg, detail: 'HTTP 402: insufficient credits' })
192
+ const warn = warningLine(await readDegraded(cfg))
193
+ expect('告警行含"降级"、类别与剩余时间', warn.includes('降级') && warn.includes('额度') && warn.includes('分钟'), warn)
194
+ const report = statusText(await readDegraded(cfg))
195
+ expect('状态报告含原因', report.includes('额度'))
196
+ expect('状态报告含恢复方式(自动探测)', report.includes('自动探测'))
197
+ expect('状态报告说明免费层仍在工作', report.includes('L0'))
198
+ expect('状态报告含原始错误', report.includes('402'))
199
+ const healthy = statusText(null, Date.now(), { apiKeyPresent: false })
200
+ expect('健康状态报告会指出"没有密钥"', healthy.includes('正常') && healthy.includes('没有解析到'))
201
+
202
+ // 12) 没有状态文件时,一切都是普通路径
203
+ await clearDegraded(cfg)
204
+ expect('清除后 isDegraded=false', isDegraded(await readDegraded(cfg)) === false)
205
+
206
+ // 13) **没有密钥 → 粘性降级**(D15):一次 HTTP 都不发,而且不靠时间结束
207
+ script = []
208
+ calls = []
209
+ await clearDegraded(cfg)
210
+ const noKeyVerdict = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: undefined, cache: undefined, scope: 'cli' })
211
+ expect('没有密钥 → 仍然 fail-open(allow)但带着分类', noKeyVerdict.action === 'allow' && noKeyVerdict.errorKind === 'no-key',
212
+ `${noKeyVerdict.action}/${noKeyVerdict.errorKind}`)
213
+ expect('没有密钥 → 一次请求都没发(没有可花钱的东西)', calls.length === 0, String(calls.length))
214
+ expect('没有密钥 → 判定上带着粘性告警', String(noKeyVerdict.warning ?? '').includes('条件消失'), String(noKeyVerdict.warning))
215
+ const nk = await readDegraded(cfg)
216
+ expect('没有密钥 → 写出了降级状态', nk !== null && nk.kind === 'no-key', String(nk?.kind))
217
+ expect('没有密钥 → 状态里记了粘性与入口身份', isSticky(nk) === true && nk.scope === 'cli', JSON.stringify({ sticky: nk?.sticky, scope: nk?.scope }))
218
+ const farFuture = nk.until + 24 * 3600 * 1000
219
+ expect('粘性不靠时间:一天之后仍然是降级', isDegraded(nk, farFuture, 'cli') === true)
220
+ expect('粘性永不探测(没有可探测对象)', probeDue(nk, farFuture, 'cli') === false)
221
+ expect('粘性告警不说"等 N 分钟"(它不等时间)', !warningLine(nk).includes('分钟'), warningLine(nk))
222
+ expect('粘性状态报告写明"当场自动恢复"', statusText(nk).includes('当场自动恢复'), statusText(nk).split('\n')[3] ?? '')
223
+ expect('粘性状态报告说明影响范围仅本入口', statusText(nk).includes('cli'), statusText(nk))
224
+ calls = []
225
+ const stillNoKey = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: undefined, cache: undefined, scope: 'cli' })
226
+ expect('粘性窗口内 → 直接放行,不再去撞密钥', stillNoKey.source === 'degraded' && calls.length === 0, `${stillNoKey.source}/${calls.length}`)
227
+
228
+ // 14) **作用域隔离**:某条入口读不到密钥,不该把密钥正常的其它入口按停
229
+ await clearDegraded(cfg)
230
+ await enterDegraded('no-key', { cfg, scope: 'cli' })
231
+ const scoped = await readDegraded(cfg)
232
+ expect('CLI 的 no-key 状态对自己生效', isDegraded(scoped, Date.now(), 'cli') === true)
233
+ expect('CLI 的 no-key 状态**不**压制 DSH 侧', isDegraded(scoped, Date.now(), 'dsh-adapter') === false)
234
+ expect('CLI 的 no-key 状态对 DSH 侧也不算探测到期', probeDue(scoped, Date.now(), 'dsh-adapter') === false)
235
+ script = [okAnswer(0.9)]
236
+ calls = []
237
+ const otherEntry = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined, scope: 'dsh-adapter' })
238
+ expect('别的入口照常判定(没有被按停)', otherEntry.source === 'jev' && calls.length === 1, `${otherEntry.source}/${calls.length}`)
239
+ // 服务侧状态相反:quota 描述的是"服务坏了",它对每条入口都成立,所以是全局的。
240
+ await clearDegraded(cfg)
241
+ await enterDegraded('quota', { cfg })
242
+ const globalState = await readDegraded(cfg)
243
+ expect('服务侧(quota)状态压制所有入口',
244
+ isDegraded(globalState, Date.now(), 'cli') === true && isDegraded(globalState, Date.now(), 'dsh-adapter') === true)
245
+ expect('服务侧状态的作用域记为 global', globalState.scope === 'global', String(globalState.scope))
246
+
247
+ // 15) **密钥一出现 → 粘性状态当场清除**(不重启、不等冷却)
248
+ await clearDegraded(cfg)
249
+ await enterDegraded('no-key', { cfg, scope: 'cli' })
250
+ expect('铺垫:粘性 no-key 状态在位', (await readDegraded(cfg))?.kind === 'no-key')
251
+ script = [okAnswer(0.9)]
252
+ calls = []
253
+ const revived = await evaluateCommand(CMD, { ...DEFAULTS, ...cfg, apiKey: 'x', cache: undefined, scope: 'cli' })
254
+ expect('密钥出现 → 恢复成正常判定', revived.source === 'jev' && revived.action === 'block', `${revived.source}/${revived.action}`)
255
+ expect('密钥出现 → 状态文件当场清掉', (await readDegraded(cfg)) === null)
256
+ expect('密钥出现 → 只花了一次请求(没有多余探测)', calls.length === 1, String(calls.length))
257
+
258
+ await rm(dir, { recursive: true, force: true })
259
+ process.stdout.write(`\n${failed === 0 ? '全部通过' : `${failed} 项失败`}(${checks} 例)\n`)
260
+ process.exit(failed === 0 ? 0 : 1)