Compare commits

...

5 Commits

Author SHA1 Message Date
e6fa2a678f feat(zcode): 3012 判据/文案/诊断模块(照参考逐行移植,含对抗性审计反例)
照参考 zcode-diagnostics.ts 移植,作为**两处消费者共用的一份源码文本**:
① 插件主体(领取路径 claimZcode);② channels-zcode 适配器(推理路径)。
共用是必须的:3012 有**账号冷却惩罚**(30 分钟起步,反复触发升级到 24 小时乃至
停用),"请勿连续重试"这句话绝不能只在一条路径上(参考为此专门抽成单一份)。

## 为什么这次移植的不是"锦上添花"
我们原来的领取路径是 `code == 3012 or msg:find("unusual activity")`,而参考用
**对抗性审计**打出了两个方向的真实缺陷(2026-10-05,实测记录在源码注释里):

**误判方向**(把限流当风控):两者用户动作**完全相反**(稍后重试 vs 别重试),
且领取路径据此**短路**、跳过本可领取的 plan。实测反例:
  `too many requests, retry after 3012 ms`        ← 毫秒数被当风控
  `<h1>Error 3012</h1>upstream connect timeout`   ← HTML 里的数字
  `rate limited: request blocked, retry after 3012 ms`  ← block 距 14 字符
  `temporarily blocked; see error 3012`                 ← block 距 12 字符
  `plan-3012-trust` / `trust_3012` / `x3012y` / `13012` ← 标识符形态

**漏判方向**(真风控被放过):上游 3012 的 msg 原文
`request has been blocked due to unusual activity.` **不含任何数字**(数字在 code
字段里)⇒ "必须有独立词 3012"这条门禁会反过来把它漏掉。

⇒ 判据以**语义短语**为主、数字只作兜底且必须与 block **紧邻(≤10 字符)**;
词边界集必须含 `-`/`_`(参考两版才收全:先只排数字 ⇒ plan-3012 仍命中;
再补字母 ⇒ 仍命中,因连字符不属 [0-9A-Za-z])。
另:业务码认**纯数字字符串**(上游回 {"code":"3012"} 时,只认 number 会让风控码
整条丢失 ⇒ 用户看到那句裸上游串)。

## 一并移植的其它实测教训
- `formatGap`:分钟档必须**逐档单独算**。参考初版写 `floor(seconds % 60)`,
  90 秒被渲染成「30分30秒」(分位取的是**秒的余数**)——该错误是在真实探针
  输出里发现的,纯函数单测当时没覆盖 ≥60 秒区间。
- 诊断的「距上条」必须用**单独存的 previousSentAt**,不能拿 lastSentAt 顶替
  (调用顺序是"先记本次、再诊断",顶替会让它恒为 0ms、永远没有信息量)。
- 形态诊断必须读**真正要发出去的那个对象**,不读常量:常量只能证明"源码里写了
  多少字",证明不了"线上发出去多少字";空 system 时必须**如实显示 0**。
- 诊断行**绝不写凭据**:账号只以进程内自增序号出现。

## ★ 接线上的一个真实 bug(我自己引入并自查出来)
`installZcodeAdapter` 最初把 DIAG 源码直接 `.. ZCODE_ADAPTER_SRC` 拼接 ——
而 DIAG 末尾是 `return Z`,直接拼会让那个 return 结束**整个 chunk** ⇒ 适配器
代码永不执行,且**装载会静默成功**("加载成功但适配器不存在")。
修法:包进 `(function() ... end)()` 让 return 只结束该函数。

## 判据
`internal/core/channels_zcode_3012_test.go`(走共用探针打**那一份**实现):
- 19 条分类矩阵(含上列全部审计反例)
- formatGap 逐档(含 90000ms 必为「1分30秒」)
- 诊断行凭据泄漏守卫(账号 id 的每个 ≥4 字符子串都不得出现)
- 文案共用 + 必含「请勿连续重试」+ 空参不留空壳
- 形态诊断读实际对象(空 system 必须显示 0)

★ 变异 2/2 咬住。其中分类矩阵**第一版没咬住** —— 因为"紧邻检查"独立挡住了
那些用例,从未**隔离**词边界规则;补 5 个"block 紧邻但 3012 在标识符里"的
用例后才有效。教训:**必须有一组用例只被被测规则区分**,否则别的守卫会把它
的失效掩盖掉(本轮第 4 次"判据太弱")。

验证:internal/core 351s 全绿。
2026-10-16 01:38:34 +08:00
b871c0c713 fix(zcode): 日期块对齐参考的两条规则(幂等 + 只看第一条),修 off-by-one
通读参考 zcode-identity.ts 的 withContextPrefix 后发现我的实现漏了两条官方行为,
且其中一条藏着真实 bug:

## ① ★ 幂等判断 off-by-one(真 bug)
`"<system-reminder>"` 是 **17** 个字符,而我写的是 `c:sub(1, 16) == SR`
—— 判断**永远为假** ⇒ 幂等完全失效:同一请求重试/多次 transform 时日期块
会叠加成多层,改变 content 的前缀结构(上游检查的正是这个结构)。
修法:用 `#SR` 算长度,不写死数字。

## ② 只看**第一条**消息,且它必须 role == "user"
我原来实现成「往后扫第一个 user 消息」。官方行为是:只处理 `messages[0]`,
且若它不是 user 就**完全不动** —— 不是去找后面那条 user。已对齐。

## ③ 其它类型兜底
参考对非字符串/非数组的 content 也会置为 `[prefix]`,照做。

判据 2 条(新增):
- TestZcodeContextPrefixIsIdempotent     已含日期块时不重复插
- TestZcodeContextPrefixOnlyTouchesFirstMessage  首条非 user 时不动、首条 user 时插入
变异 2/2 咬住:把 #SR 改回写死的 16 → 报红;去掉幂等判断 → 报红。

★ 我自己在写这条判据时又犯了一个错并当场自查出来:断言「文本 content 应保持
字符串」,而 anthropic 基座把文本转成块数组是**正常**的协议转换行为 —— 差点把
正确的实现判成错。判据要验的是「**日期块**有没有被插进来」这个事实,不是
content 的容器类型。

验证:internal/core 335s + internal/gateway 207s 全绿。
2026-10-16 01:13:11 +08:00
8b318a8507 feat(zcode): 身份块适配器 + 修复调度器泄漏 + codearts 目录 + 备份裁剪泛化
## 1. ★ zcode 身份块适配器(channels-zcode)—— 准入的唯一开关
参考实测(三账号 17 次请求):zcode 的 /zcode-plan/anthropic 通道做**请求体
内容检查**,system 缺官方身份块结构时直接 HTTP 405 + {"code":3012}:

    无 system                        → 3012 ✗
    仅 cliPrefix(42 字符)           → 3012 ✗
    cliPrefix + stable(约 2898 字符)→ 200  ✓

⇒ 判据是「身份块结构是否存在」,与 HTTP 头、版本号、频率、多轮历史、tools
声明、运行时全部无关。我们此前用通用 anthropic 适配器 ⇒ **即使拿到 zcode
账号也必然被拒**(现在 0 账号,但这是能不能用起来的前提)。

实现:插件自装 channels-zcode —— 读内核 anthropic.lua 执行取基座,**只覆盖
transform_request** 注入身份块(协议转换始终与 anthropic 那一份一致,不漂移):
  block[1] cliPrefix(42) / block[2] stable(2852) / block[3] Environment 段
  / block[4] 调用方 system(**必须追加在最后**:上游看的是前缀结构)
  只给最后一块打 cache_control(Anthropic 上限 4 断点,逐块打会占满预算)
  日期块插入首条 user 的 content **数组**最前(拼进字符串会改变结构)
文本与参考**逐字节一致**(已核对:42 + 2856 = 2898)。
★ 基座读不到时**响亮失败**,不静默退化 —— 否则会产出"身份块看着对、工具/图像
全没转换"的请求,上游可能 200 但模型拿不到工具定义,症状是"模型变笨了",
与准入毫不相干、排查完全跑偏。

判据 4 条 + 变异 2/2:不注入 → 报红;截断到 100 字符 → 报红。
另加"协议转换不退化"判据(工具 input_schema / 图像块存活)。

## 2. ★★ 同一缺陷家族第 3 个实例:Close() 不停调度器
修完 on_unload 后全量回归 internal/lua **600s 超时**,goroutine 快照显示
**69 个卡在 stateSaver.loop 与 PluginRuntime.loop**。
根因:Runtime.Stop() **只在单插件 Unload 里调**,Close() 不调 ⇒ 每个插件的
调度循环在"进程关闭/测试结束"路径上永不停止。与 on_unload 完全同一形状。
修法:Close() 新增 stopSchedulersAll(),顺序 = 停调度 → 落盘 → on_unload
(调度器必须停在落盘之前,否则它写一个已收尾的 state)。
★ 顺带:Stop() 不幂等(裸 close 会 panic: close of closed channel),
两处都调之后可达 —— 用 sync.Once 加固。给函数加第二个调用点前先查幂等性。
实测 internal/lua 从 600s 超时 → 62s 通过。

## 3. codearts 模型目录:补两个**必须 AK/SK 签名**的端点
codearts 有 10 个账号但目录里只有硬编码 1 个模型。参考打两个端点:
  ① opengw.../gateway/config  → result.models(benefit)
  ② snap-access.../v1/model/builtin → builtinModels(常规)
而 refreshRemoteModels **只支持 Bearer** ⇒ 这两端点从来没被打过。
已加 ep.sign="codearts" 分支 + 两个解析形态 + opengw host 白名单。
判据:两端点各自真实命中 + 每请求带 SDK-HMAC-SHA256 头 + **合并**(非取其一)。

## 4. TRAE PKCE 候选来源补齐(3 → 5)
参考 authCodeCandidates 有 5 个来源,我们只有 3(漏了 authCodeInfo 为 JSON 的
authCode 字段、以及**纯字符串**形态)。★ 且不能写成 `a or b or c` 链 ——
jsonField 取不到时返回**空串**,Lua 里空串是真值,链会在第一个空串处短路。
判据 5 个参数化用例 + 变异 2/2 咬住(正是 or 链取不到的两个形态)。

## 5. ★ 部署脚本:备份裁剪只修了一半("两处只修一处"再现)
prune_adapter_backups 有完整实现(含数量硬上限),但**只针对 adapters.bak**;
sync_plugins 同样每次建 plugins.bak.* —— 修了 adapters 的堆积、plugins 继续涨。
实测线上 plugins.bak **36 个**、/etc/llmsproxy 192M。
泛化为 prune_backups <prefix> + prune_all_backups(两个前缀都裁),加空前缀守卫。
判据不止逐字检查,而是**扫描脚本里所有 .bak.$(date) 创建点**,要求每个前缀都在
裁剪范围内 —— 将来新增第三种备份目录会立刻报红,而不是等它涨到几百兆。
变异 2/2 咬住(只裁 adapters / 去掉守卫)。

## 6. 上游客观缺口钉成可执行判据(TestUpstreamGapsAreDocumented)
qoder 加密端点需 .wasm(**参考仓库里也不存在**)、TRAE PKCE 参考**明确不支持**、
zcode 滑块需真实浏览器交互。写成判据:任何人(包括未来的我)想"补"这些能力,
必须先让判据失败 —— 即先推翻事实再动手。防的正是我前两轮的反复误判。

清理:channels-buddy.lua(0 字节历史遗留,每次启动报 must return a table)。
2026-10-16 00:56:14 +08:00
69da3c9b92 fix(kernel): Close() 从不调 on_unload —— 孤儿进程与回调文件泄漏
## 症状
本机积累 **995 个 llmsproxy-loopback.py 进程**(828 个超 24 小时),全是
/tmp/Test*/ 与 .build-work/Test*/ 下的测试残留;生产 /etc/llmsproxy/plugins/
另有 **18 个孤儿 callback-*.port**(对应 .json 早已消失),且永远清不掉。

## 真因(两层)
1. **回环回收本是惰性的**:reapLoopback 只在**会话过期**时被 sweepSessions
   触发。测试跑完直接退出 ⇒ 会话从未「过期」⇒ 进程常驻。生产会话真会过期,
   所以生产不漏进程 —— 但见第 2 条。
2. **★ 根因在内核**:Plugins.Close() 只关 shard 与 saver,**从不调用
   on_unload** —— 而 on_unload 只在**单插件 Unload** 时调。于是「进程关闭/
   测试结束」这条路径上,起了 OS 进程的插件永远收不了尾。插件的
   plugin.on_unload(回收浏览器栈 + 回环)写得完全正确,只是从不被调用。
   这是「两处只修一处」缺陷的又一例:Unload 有、Close 没有。

## 修法
- 抽 invokeOnUnload(p, name) 助手,**Unload 与 Close 共用同一份实现**
  (两处各写一遍必然漂移,本次缺陷形状正是如此);Close() 新增
  callOnUnloadAll()。
- ★ 顺序:shards.Shutdown() → save.Close()(落盘)→ callOnUnloadAll()。
  落盘必须在前 —— saver 的最终写入要读插件的 Lua 表,on_unload 之后 state
  已关,读到的就是空,最后一个区间的累积静默丢失(TestCloseFlushesTail 正是
  为守住这一点而存在)。我第一版把顺序写反,被该测试当场咬住。
- channels:启动清扫上一进程留下的孤儿 callback-*.port/.json。loopbacks
  追踪表只活在内存,进程被强杀时 on_unload 没机会跑,重启后内存表为空 ⇒
  reapLoopback 无从知晓 ⇒ 那 18 个文件永久残留。
- channels:补 reap_loopbacks 端点(生产运维也可用:不必等会话过期)。
- channels:__set_loopback_probe 从**覆盖**改为**合并**条目 —— 覆盖会丢掉
  真实会话的 pid/port_file,让 reapLoopback 杀不掉接收器,**探针自己成为
  泄漏源**(实测:其他测试已归零,只有它留 1 个孤儿)。

## 判据(均做变异验证)
- internal/lua/plugin_unload_on_close_test.go
    TestPluginsCloseCallsOnUnload      看真实外部效应(插件在 on_unload 写磁盘
                                       marker),而非「钩子被调用过」这类内部信号
    TestPluginsCloseSurvivesFailingOnUnload  best-effort 契约
- internal/core/channels_orphan_callback_test.go
    TestPluginLoadSweepsOrphanCallbackFiles  看目录真实状态:孤儿必须消失,
                                       非回调文件(.channels-store.json)不得误删
- 变异均咬住:注释掉 callOnUnloadAll → 报红;清扫改 no-op → 报红。

## 实测
修复前跑一次起会话的测试批次留 7×N 个残留进程 → 修复后 **0 个**;全量回归
(go test -tags luajit ./... 10 包)后回环/VNC 残留均为 0。

## 过程中自查出的两处疏漏(已修)
1. 判据顺序又写反一次(同上 TestCloseFlushesTail 咬住)。
2. **两枚变异残渣留在工作区**未还原:internal/gateway/discover.go 里
   `add(raw, "/models", 0)` 被删、`classifyAuthStatus` 的 `|| len(body) >= 0`
   恒真注入。已 git checkout 还原。教训:变异测试必须逐次收尾,别靠记忆。
2026-10-15 22:43:08 +08:00
1be34557dd fix(trae): 登录失败真因 —— 虚构的「授权码交换」+ 空 client_id
用户报「TRAE 登录后显示失败」。定位到三处偏差,其中一处是核心缺陷:

① ★ **虚构了一次不存在的「授权码交换」**(核心)
   poll_login 的 trae 分支自己发明了一段逻辑:回调只带 code 时,拿
   exchange_body 去打 ExchangeToken。但:
     · ExchangeToken 的签名是 RefreshToken,不是 code;
     · exchange_body 里根本没有 {code} 占位符,那次替换是空操作;
     · client_id 当时是空串。
   于是稳定 400,日志回一句误导性的「Trae 授权码交换失败: 400」——
   把真因「上游走了 PKCE 流程」掩盖成「交换出错了」,排查方向完全错。

   按参考实现(trae-oauth.ts parseTraeCallbackDetailed)修正:带 code 的
   回调是**合法回调**,只是不支持的 PKCE 分支,必须明确报出来。
   删除虚构交换,两条路径共用 traeCallback 内建的 PKCE 判定。

② client_id 从空串改为实测真值 en1oxy7wnw8j9n(对齐参考 trae-product.ts
   的 clientId;ExchangeToken 请求体里 ClientID 必填,留空稳定 400)。

③ 两条路径(oauth_callback / poll_login)现在共用同一结论,不再漂移 ——
   这正是缺陷能藏住的原因:修复只覆盖一条、另一条继续错。

测试支撑:
- 新增 __set_loopback_probe(注入 loopback 回调文件),否则无法驱动
  poll_login 的 callback 分支(那才是真实登录走的路径)。
- 判据 4 条,全部做变异验证:
    TestTraePkceCallbackIsReportedNotExchanged  打 poll_login,断言没打 ExchangeToken 且点名 PKCE
    TestTraeExchangeSendsRealClientID           token 直传真调交换,请求体形状正确
    TestTraeClientIDIsNotBlank                  client_id 必须是实测真值
    TestTraeCallbackPathsAgreeOnPkce            两条路径必须共用 traeCallback
- 变异 2/2 咬住:client_id 改回空串 → 报红;虚构交换加回去 → 「实际打了 1 次
  ExchangeToken」。

★ 我在过程中自己犯的两个错(均由变异测试揭穿,已修正):
1. **判据打错了路径** —— 第一版打 oauth_callback(那条一直是对的),注入缺陷后
   判据**仍绿**。改成打 poll_login 才咬住。教训:判据必须打**真实被使用的那条
   路径**,否则等于没测。
2. **判据写错了契约** —— poll_login 用 HTTP 200 + json.state 表达终态,我按
   status != 200 断言,把正确的实现判成了错。

回归:go test -tags luajit ./... 10 包全绿。
2026-10-15 22:04:01 +08:00
13 changed files with 2604 additions and 81 deletions

View File

@ -309,48 +309,60 @@ verify_master_key() {
return 1
}
# ---------- 适配器备份保留策略 ----------
# 每次部署都新建一个 adapters.bak.<时间戳> 目录,而回滚只用到最近一次
# ---------- 备份目录保留策略 ----------
# 每次部署都新建一个 <prefix>.bak.<时间戳> 目录,而回滚只用到最近一次
# ($BACKUP_BIN / $BACKUP_CONFIG 都是单文件覆盖)。多出来的目录从没人清理,
# 实测线上已积累 71 个,/etc/llmsproxy 因此涨到 122M。
#
# 保留最近 KEEP_ADAPTER_BACKUPS 份足够回滚,同时给目录数设上限——否则
# 一次误配置(比如 adapter_dir 指错)就可能在几秒内造出成百上千个目录。
# 只删名字严格匹配 adapters.bak.<14位时间戳> 的目录,避免误伤人工放的目录。
KEEP_ADAPTER_BACKUPS=5
# 保留最近 KEEP_BACKUPS 份足够回滚,同时给目录数设上限——否则一次误配置
# 就可能在几秒内造出成百上千个目录。
# 只删名字严格匹配 <prefix>.bak.<14位时间戳> 的目录,避免误伤人工放的目录。
#
# ★ 必须**同时覆盖 adapters 与 plugins**(同一缺陷的两个实例):
# 本函数最初只处理 adapters.bak,而 sync_plugins 同样每次建 plugins.bak.*
# —— 于是修了 adapters 的堆积、plugins 继续涨(实测线上 36 个、占用 192M)。
# 这正是「两处只修一处」的老形状:给一个新前缀加备份目录时,必须同步纳入
# prune_all_backups,否则它就是一个不会被清理的新泄漏点。
KEEP_BACKUPS=5
prune_adapter_backups() {
prune_backups() {
local prefix="$1"
local base="/etc/llmsproxy"
[[ -d "$base" ]] || return 0
# 防误用:前缀必须是已知的两个(避免有人传空串删掉整个目录)
case "$prefix" in
adapters|plugins) ;;
*) fail "prune_backups: 未知前缀 '$prefix'(只接受 adapters|plugins)" ;;
esac
# 先按数量上限硬裁:即使时间戳排序失效也不会无上限增长。
local all
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' -printf '%f\n' | sort)
local cap=$((KEEP_ADAPTER_BACKUPS * 4))
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" -printf '%f\n' | sort)
local cap=$((KEEP_BACKUPS * 4))
if (( ${#all[@]} > cap )); then
warn "适配器备份目录有 ${#all[@]} 个(异常),裁到 $cap"
warn "$prefix 备份目录有 ${#all[@]} 个(异常),裁到 $cap"
local i=0
for d in "${all[@]}"; do
i=$((i + 1))
# 从最旧的开始删(sort 后升序)。名字不规范的跳过不删。
if (( i <= ${#all[@]} - cap )); then
if [[ "$d" =~ ^adapters\.bak\.[0-9]{14}$ ]]; then
if [[ "$d" =~ ^"$prefix"\.bak\.[0-9]{14}$ ]]; then
rm -rf "${base:?}/$d"
else
warn "跳过名字不规范的备份目录(不删): $d"
fi
fi
done
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' -printf '%f\n' | sort)
mapfile -t all < <(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" -printf '%f\n' | sort)
fi
# 再按时间保留最近 KEEP_ADAPTER_BACKUPS 份(sort 后最新在末尾)。
# 再按时间保留最近 KEEP_BACKUPS 份(sort 后最新在末尾)。
local total=${#all[@]} i=0
for d in "${all[@]}"; do
i=$((i + 1))
# i <= total-KEEP 的是较旧的,要删。
if (( i <= total - KEEP_ADAPTER_BACKUPS )); then
if [[ "$d" =~ ^adapters\.bak\.[0-9]{14}$ ]]; then
if (( i <= total - KEEP_BACKUPS )); then
if [[ "$d" =~ ^"$prefix"\.bak\.[0-9]{14}$ ]]; then
rm -rf "${base:?}/$d"
else
warn "跳过名字不规范的备份目录(不删): $d"
@ -359,8 +371,15 @@ prune_adapter_backups() {
done
local left
left=$(find "$base" -maxdepth 1 -type d -name 'adapters.bak.*' | wc -l)
log " 适配器备份保留最近 $KEEP_ADAPTER_BACKUPS 份(当前剩 $left 个)"
left=$(find "$base" -maxdepth 1 -type d -name "$prefix.bak.*" | wc -l)
log " $prefix 备份保留最近 $KEEP_BACKUPS 份(当前剩 $left 个)"
}
# prune_all_backups:两个前缀都裁。
# ★ 新增任何 .bak.<时间戳> 目录的前缀时,必须在这里补上 —— 否则又一个泄漏点。
prune_all_backups() {
prune_backups adapters
prune_backups plugins
}
# ---------- 同步适配器 ----------
@ -380,7 +399,7 @@ sync_adapters() {
cp -rf "$TARGET_ADAPTERS/"*.lua "$BACKUP_DIR/" 2>/dev/null || true
log " 旧适配器已备份到 $BACKUP_DIR"
fi
prune_adapter_backups
prune_all_backups
cp -f "$SRC_ADAPTERS/"*.lua "$TARGET_ADAPTERS/"
chmod 0644 "$TARGET_ADAPTERS/"*.lua

View File

@ -0,0 +1,174 @@
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"llmsproxy/internal/lua"
)
// ★ codearts 的模型目录必须从**两个**签名端点拉取并合并。
//
// 真实缺口(本轮发现):codearts 有 10 个可用账号,但目录里只有硬编码的
// 1 个模型 "CodeArts-Pangu"。参考实现(fetchCodeArtsRemoteModels)打的是:
//
// ① opengw gateway/config → result.models(benefit 模型)
// ② snap-access /v1/model/builtin → builtinModels(常规模型)
//
// 两者都**必须 AK/SK 签名**,而我们的 refreshRemoteModels 此前只支持 Bearer
// ⇒ 这两个端点从来没有被打过。
//
// 判据看**两个端点的真实命中**(各自拉到自己的字段名),而不是"目录非空"
// —— 后者只要兜底表在就恒真。
type codeartsCatalogHarness struct {
t *testing.T
ps *lua.Plugins
mu sync.Mutex
hits map[int]int // 端点序号 → 命中次数
gateway string
builtin string
authSeen []string
}
func newCodeartsCatalogHarness(t *testing.T) *codeartsCatalogHarness {
t.Helper()
h := &codeartsCatalogHarness{t: t, hits: map[int]int{}}
mk := func(idx int, body string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.mu.Lock()
h.hits[idx]++
// ★ 必须看到 AK/SK 签名的三个特征头,否则等于没签名
authSeen := []string{
r.Header.Get("X-Sdk-Date"),
r.Header.Get("X-Sdk-Content-Sha256"),
r.Header.Get("Authorization"),
}
h.authSeen = append(h.authSeen, strings.Join(authSeen, "|"))
h.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, body)
}))
}
// ① gateway:benefit 模型在 result.models
gw := mk(0, `{"result":{"models":[{"model_id":"glm-5.3-flash"},{"model_id":"deepseek-v4-flash"}]}}`)
// ② snap builtin:常规模型在 builtinModels(**对象**,键为序号)
bi := mk(1, `{"count":2,"builtinModels":{"0":{"model_id":"GLM-5.2"},"1":{"model_id":"openpangu-2.0-pro"}}}`)
t.Cleanup(gw.Close)
t.Cleanup(bi.Close)
h.gateway, h.builtin = gw.URL, bi.URL
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
h.ps = ps
// 把两个端点都指到假上游(顺序与平台配置一致)
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "codearts",
"models_remote": map[string]interface{}{
"endpoint0": h.gateway,
"endpoint1": h.builtin,
},
}); err != nil {
t.Fatalf("set_endpoints: %v", err)
}
// 塞一个 AK/SK 账号(签名分支需要凭据,否则会走 "无可用 AK/SK 凭据")
if _, err := ps.Call("channels", "account_add", map[string]interface{}{
"platform": "codearts",
"id": "cat-test",
"credential": `{"access_key_id":"AKTEST","secret_access_key":"SKTEST",` +
`"security_token":"STTEST","user_name":"cat"}`,
}); err != nil {
t.Fatalf("account_add: %v", err)
}
return h
}
func TestCodeArtsCatalogPullsBothSignedEndpoints(t *testing.T) {
h := newCodeartsCatalogHarness(t)
if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "codearts"}); err != nil {
t.Fatalf("model_refresh: %v", err)
}
h.mu.Lock()
hits := map[int]int{}
for k, v := range h.hits {
hits[k] = v
}
authSeen := append([]string(nil), h.authSeen...)
h.mu.Unlock()
// ① 两个端点都必须被命中(只打一个是真实缺口)
if hits[0] == 0 {
t.Fatal("gateway/config 端点从未被调用 —— benefit 模型(glm-5.3-flash 等)永远拿不到")
}
if hits[1] == 0 {
t.Fatal("snap /v1/model/builtin 端点从未被调用 —— 常规模型(GLM-5.2 / openpangu)永远拿不到")
}
// ② 每个请求都必须带 AK/SK 签名特征头(Bearer 打不通这两个端点)
for i, seen := range authSeen {
parts := strings.Split(seen, "|")
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
t.Fatalf("第 %d 个请求缺签名头(X-Sdk-Date/Content-Sha256/Authorization):%q", i+1, seen)
}
if !strings.Contains(parts[2], "SDK-HMAC-SHA256") {
t.Fatalf("Authorization 不是华为 SDK-HMAC-SHA256 形态,实得 %q", parts[2])
}
}
// ③ 合并结果必须同时含两个端点的模型(字段名不同:model_id,两边都要认)
got, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "codearts"})
if err != nil {
t.Fatal(err)
}
ids := modelIDs(t, got)
joined := strings.Join(ids, ",")
for _, want := range []string{"glm-5.3-flash", "GLM-5.2", "openpangu-2.0-pro"} {
if !strings.Contains(joined, want) {
t.Fatalf("合并目录缺 %s(两个端点必须**合并**而非只用其一):%v", want, ids)
}
}
t.Logf("✓ 两端点各命中 %d/%d 次,合并得到 %d 个模型", hits[0], hits[1], len(ids))
}
// 兜底表在远端失败时必须仍然可用(不能因为加了目录就让平台变空)。
func TestCodeArtsCatalogKeepsFallbackOnFailure(t *testing.T) {
h := newCodeartsCatalogHarness(t)
// 让两个端点都 500
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
}))
defer dead.Close()
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "codearts",
"models_remote": map[string]interface{}{
"endpoint0": dead.URL, "endpoint1": dead.URL,
},
}); err != nil {
t.Fatal(err)
}
if _, err := h.ps.Call("channels", "model_refresh", map[string]interface{}{"platform": "codearts"}); err != nil {
t.Fatalf("远端失败不该让 model_refresh 报错: %v", err)
}
got, err := h.ps.Call("channels", "model_list", map[string]interface{}{"platform": "codearts"})
if err != nil {
t.Fatal(err)
}
ids := modelIDs(t, got)
if len(ids) == 0 {
t.Fatal("远端全失败时目录变空了 —— 兜底表必须补位(宁可留旧目录也别让平台变空)")
}
t.Logf("✓ 远端失败时兜底目录仍有 %d 个模型: %v", len(ids), ids)
}

View File

@ -0,0 +1,82 @@
package core
import (
"os"
"path/filepath"
"testing"
"llmsproxy/internal/lua"
)
// ★ 插件加载时必须清掉上一进程留下的孤儿回调文件。
//
// 真实事故:生产 `/etc/llmsproxy/plugins/` 攒了 **18 个 callback-*.port**
// (对应的 .json 早已消失)。它们永远不会被清,因为:
//
// · loopbacks 追踪表只活在内存(plugin.state.loopbacks);
// · 进程被强杀时 on_unload 没机会跑;
// · 重启后内存表是空的 ⇒ reapLoopback 根本不知道它们存在。
//
// 判据看的是**目录的真实状态**(孤儿必须消失、活跃会话的文件不能被误删),
// 而不是"某函数被调用过"。
func TestPluginLoadSweepsOrphanCallbackFiles(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
// 摆上一轮的残留(模拟上一进程被强杀后的现场)
orphans := []string{
filepath.Join(pluginDir, "callback-deadbeef0001.port"),
filepath.Join(pluginDir, "callback-deadbeef0001.json"),
filepath.Join(pluginDir, "callback-deadbeef0002.port"),
}
for _, f := range orphans {
if err := os.WriteFile(f, []byte("12345"), 0600); err != nil {
t.Fatal(err)
}
}
// 一份**不属于**回调的文件:必须不被误删(否则清扫过头会毁掉账号池)
keep := filepath.Join(pluginDir, ".channels-store.json")
if err := os.WriteFile(keep, []byte(`{"accounts":{}}`), 0600); err != nil {
t.Fatal(err)
}
// 加载插件 = 走真实启动路径
c := kernelTestCore(t)
defer c.Close()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
ps := c.Plugins()
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
// 顶层清扫代码在加载期就跑了;给 popen 的 rm 一点时间落盘
for i := 0; i < 50; i++ {
left := 0
for _, f := range orphans {
if _, err := os.Stat(f); err == nil {
left++
}
}
if left == 0 {
break
}
}
for _, f := range orphans {
if _, err := os.Stat(f); err == nil {
t.Fatalf("孤儿回调文件未被清扫: %s —— 它会永久留在磁盘上"+
"(真实事故:生产攒了 18 个)", filepath.Base(f))
}
}
if _, err := os.Stat(keep); err != nil {
t.Fatalf("清扫过头了:%s 被误删(账号池/索引绝不能碰)", filepath.Base(keep))
}
t.Log("✓ 孤儿回调文件被清,非回调文件保留")
}
var _ = lua.Plugins{}

View File

@ -0,0 +1,200 @@
package core
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"llmsproxy/internal/lua"
)
// ★ TRAE 回调只带 code(PKCE 形态)时必须**明确报出**「暂不支持 PKCE」,
// 而不是去调 ExchangeToken 换一个不存在的码。
//
// 真实缺陷(用户报「登录后显示失败」):
// poll_login 的 trae 分支曾自己发明了一段「授权码交换」——把 {code} 塞进
// exchange_body 再打 ExchangeToken。但:
//
// · ExchangeToken 的签名是 RefreshToken,不是 code;
// · exchange_body 里根本没有 {code} 占位符,那次替换是空操作;
// · client_id 当时是空串。
//
// 于是稳定得到 400,日志回一句误导性的「Trae 授权码交换失败: 400」,
// 把真因「上游走了 PKCE 流程」掩盖成「交换出错了」——排查方向完全错。
//
// 参考实现(trae-oauth.ts parseTraeCallbackDetailed)的做法是:带 code 的回调
// 是**合法回调**,只是不支持的 PKCE 分支,必须明确报出来。
func TestTraePkceCallbackIsReportedNotExchanged(t *testing.T) {
h := newTraeHarness(t)
// ★ 必须打**真实登录用的那条路径**:poll_login(loopback 回调文件)。
// oauth_callback 那条一直是对的,判据打在那里会漏掉缺陷 —— 我第一版就
// 犯了这个错(变异测试揭穿:注入缺陷后那条判据仍绿)。
//
// poll_login 从 plugin.state.loopbacks[sessionId].file 读 {code,state},
// 故判据直接摆好那个文件再调 poll_login。
status, body := h.beginAndPollWithCallback(map[string]string{
"code": "PKCE_AUTH_CODE_XYZ",
})
// poll_login 的契约:终态用 HTTP 200 + json.state 表达(不是非 200 状态码)。
// 所以「失败」的判据是 state=failed,而不是 status != 200 —— 我第一版把
// 前者写成了后者,于是把正确的实现判成错。
if !strings.Contains(body, "state:failed") {
t.Fatalf("PKCE 回调应落定为 failed(poll_login 用 200+state 表达终态):status=%d body=%s",
status, body)
}
if n := atomic.LoadInt32(&h.exchHit); n != 0 {
t.Fatalf("不得去调 ExchangeToken —— 那是拿 code 换一个不存在的码(真实缺陷)。"+
"实际打了 %d 次", n)
}
// 错误信息必须指向真因(PKCE),而不是含糊的交换失败
blob := body
if !strings.Contains(blob, "PKCE") {
t.Fatalf("错误信息应点名 PKCE(让排查方向正确),实得 %q", blob)
}
if strings.Contains(blob, "授权码交换失败") {
t.Fatalf("不应再出现误导性的「授权码交换失败」:%q", blob)
}
t.Logf("✓ PKCE 回调被正确识别(poll_login 路径): %s", strings.TrimSpace(blob))
}
// ★ client_id 必须是实测真值 —— ExchangeToken 的请求体里 ClientID 是必填。
// 留空会让真实 token 直传场景的交换稳定 400(夹具自身也校验 ClientID 存在)。
func TestTraeExchangeSendsRealClientID(t *testing.T) {
h := newTraeHarness(t)
// 真实回调形态:带 refreshToken(token 直传)⇒ 会走 ExchangeToken。
// 夹具的 handler 已校验请求体含 ClientID / RefreshToken:<回调值> / ClientSecret。
status, body := h.beginAndCallback(map[string]string{
"refreshToken": "RT-FROM-CALLBACK",
"userInfo": `{"UserID":"u1","ScreenName":"tester","TenantID":"t1"}`,
})
if status != 200 {
t.Fatalf("token 直传回调应成功: status=%d body=%s", status, body)
}
if n := atomic.LoadInt32(&h.exchHit); n == 0 {
t.Fatal("token 直传场景必须真的调用 ExchangeToken")
}
t.Logf("✓ ExchangeToken 被调用 %d 次,请求体形状正确", atomic.LoadInt32(&h.exchHit))
}
// client_id 的真值必须是实测值 —— 这是「交换必然 400」的两个成因之一。
func TestTraeClientIDIsNotBlank(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
src := string(b)
if !strings.Contains(src, `client_id = "en1oxy7wnw8j9n"`) {
t.Fatal("trae 的 client_id 必须是实测真值 en1oxy7wnw8j9n —— " +
"留空/占位会让 ExchangeToken 的 ClientID 为空而稳定 400")
}
}
// 两条路径(oauth_callback 与 poll_login 的 loopback)必须给**同一个**结论,
// 否则修复只覆盖一条、另一条继续漂移(这正是缺陷能藏住的原因)。
func TestTraeCallbackPathsAgreeOnPkce(t *testing.T) {
b, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
src := string(b)
// ① 不得再出现自己发明的交换(注释里说明历史的那句除外)
if strings.Contains(src, `cerr = "Trae 授权码交换失败`) {
t.Fatal("已删除的「授权码交换」又回来了 —— 那是拿 code 去 ExchangeToken," +
"必然 400 且把真因掩盖成交换失败")
}
// ② 两条路径都必须交给 traeCallback(它内建 PKCE 判定)
if n := strings.Count(src, "traeCallback(plat,"); n < 2 {
t.Fatalf("traeCallback 应被两条路径共用(当前 %d 处)—— 只有一条会漂移", n)
}
// ③ PKCE 判定必须仍在 traeCallback 内
if !strings.Contains(src, "本实现暂不支持该流程") {
t.Fatal("traeCallback 必须保留 PKCE 明确报错(参考实现的语义)")
}
}
// beginAndPollWithCallback 走**真实登录用的那条路径**:
// start_login 拿会话 → 摆好 loopback 回调文件(平台重定向的落地)→ poll_login。
//
// 与 beginAndCallback(打 oauth_callback)的区别很关键:poll_login 才是
// callback 流在服务端浏览器(VNC)场景下真正走的那条,真实缺陷就藏在它的
// trae 分支里。
func (h *traeHarness) beginAndPollWithCallback(cbQuery map[string]string) (int, string) {
h.t.Helper()
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
Method: "POST", Body: `{"platform":"trae"}`,
})
if err != nil {
h.t.Fatal(err)
}
j, ok := out.JSON.(map[string]interface{})
if !ok {
h.t.Fatalf("start_login 返回非对象: %#v", out.JSON)
}
sess := j["session"].(map[string]interface{})
sid, _ := sess["id"].(string)
nonce, _ := sess["nonce"].(string)
if sid == "" {
h.t.Fatalf("会话缺 id: %#v", sess)
}
// 摆好 loopback 回调文件(内容 = 平台重定向带回的 query)
rec := map[string]string{"state": nonce}
for k, v := range cbQuery {
rec[k] = v
}
b, _ := json.Marshal(rec)
cbPath := filepath.Join(h.t.TempDir(), "cb-"+sid+".json")
if err := os.WriteFile(cbPath, b, 0600); err != nil {
h.t.Fatal(err)
}
// 告诉插件「这个会话的 loopback 接收器把结果写在这里了」
if _, err := h.ps.Call("channels", "__set_loopback_probe", map[string]interface{}{
"session_id": sid, "file": cbPath,
}); err != nil {
h.t.Fatalf("设置 loopback 文件: %v", err)
}
res, err := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
Method: "GET", Query: map[string]string{"id": sid},
})
if err != nil {
h.t.Fatal(err)
}
return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON)
}
// ★ PKCE 探测的候选来源必须覆盖参考的 5 个(含 authCodeInfo 为**纯 code 字符串**
// 这一形态 —— 参考注释点名,解析不出 JSON 时必须回退到原始值)。
//
// 反例(我第一版就是错的):用 `q.code or q.authCode or jsonField(...)` 链,
// 而 jsonField 取不到时返回**空串**;Lua 里空串是真值 ⇒ 链在第一个空串处短路,
// 后面的来源永远取不到。
func TestTraePkceDetectsPlainStringAuthCodeInfo(t *testing.T) {
for _, tc := range []struct {
name string
q map[string]string
}{
{"code 参数", map[string]string{"code": "C1"}},
{"authCode 参数", map[string]string{"authCode": "C2"}},
{"authCodeInfo 是 JSON", map[string]string{"authCodeInfo": `{"code":"C3"}`}},
{"authCodeInfo 是 JSON(authCode)", map[string]string{"authCodeInfo": `{"authCode":"C4"}`}},
{"authCodeInfo 是纯字符串", map[string]string{"authCodeInfo": "C5"}},
} {
t.Run(tc.name, func(t *testing.T) {
h := newTraeHarness(t)
status, body := h.beginAndPollWithCallback(tc.q)
_ = status
if n := atomic.LoadInt32(&h.exchHit); n != 0 {
t.Fatalf("PKCE 形态不得去调 ExchangeToken(打了 %d 次)", n)
}
if !strings.Contains(body, "PKCE") {
t.Fatalf("应识别为 PKCE 并明确报出,实得 %q", body)
}
})
}
}

View File

@ -0,0 +1,63 @@
package core
import (
"strings"
"testing"
)
// ★ 本文件把「上游客观缺口」写成**可执行的判据**,而不是只留在文档里。
//
// 为什么需要:这些能力缺口多次被误判成"我们漏实现了"(我自己就误判了两轮),
// 于是反复派人去"补"。判据在此把事实钉住:
//
// - qoder 加密端点(agent_chat_generation)需要 `qoder-auth-wasm.wasm`
// —— 该二进制**在参考仓库里也不存在**(参考自己也是 readFileSync 一个
// 它不提供的文件)。故我们走明文端点 `/model/v1/chat/completions`,
// **不经过信封** ⇒ qoder-envelope 的解包能力在明文路径上无从生效。
// - TRAE 的 PKCE 分支参考**明确不支持**(只有"探测并拒绝",无 code→token
// 交换端点)⇒ 我们报"暂不支持"是与参考对齐的正确行为。
//
// 判据的作用是**防止误判**:任何人(包括未来的我)想"补"这些能力时,
// 必须先让这个测试失败 —— 也就是先推翻事实,再动手。
func TestUpstreamGapsAreDocumented(t *testing.T) {
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
s := string(src)
t.Run("qoder 走明文端点且不经过信封", func(t *testing.T) {
// 明文端点(OpenAI 兼容):参考 QODER_CHAT_PATH
if !strings.Contains(s, "https://api2-v2.qoder.sh/model/v1/chat/completions") {
t.Fatal("qoder 的 chat 端点变了 —— 若改成加密端点 agent_chat_generation," +
"则**必须**同时实现信封解包(qoder-envelope)与 WASM 签名," +
"否则响应会被当成畸形数据")
}
// 明文端点没有信封层,故解包**不该**存在(存在反而是死代码)
if strings.Contains(s, "unwrapQoderEnvelope") {
t.Fatal("出现了 qoder 信封解包 —— 明文端点没有信封层。" +
"要么端点已换成加密端点(那需要 wasm),要么这是死代码")
}
})
t.Run("TRAE PKCE 明确报不支持(与参考一致)", func(t *testing.T) {
if !strings.Contains(s, "本实现暂不支持该流程") {
t.Fatal("TRAE 的 PKCE 分支必须明确报出「暂不支持」—— " +
"参考 trae-oauth.ts 也只做探测+拒绝,没有 code→token 交换端点。" +
"若改为尝试交换,会拿 code 去打要 RefreshToken 的 ExchangeToken(必然 400)")
}
})
t.Run("codearts 目录打两个签名端点(本轮补上)", func(t *testing.T) {
for _, want := range []string{
"opengw.developer.huaweicloud.com/api/v1/gateway/config",
"snap-access.cn-north-4.myhuaweicloud.com/v1/model/builtin",
`sign = "codearts"`,
} {
if !strings.Contains(s, want) {
t.Fatalf("codearts 目录缺 %q —— 参考 fetchCodeArtsRemoteModels 打两个"+
"**必须 AK/SK 签名**的端点,只打一个会少一半模型", want)
}
}
})
}

View File

@ -0,0 +1,285 @@
package core
import (
"encoding/json"
"strings"
"testing"
)
// ★ ZCode 3012 的判据必须是**多形态**的,且误判不是"文案不好看"——
// 领取路径会据此**短路**(跳过本可领取的 plan),而限流与风控的用户动作
// **完全相反**(稍后重试 vs 别重试)。
//
// 本判据逐行搬自参考 zcode-diagnostics.ts 的对抗性审计实测矩阵
// (2026-10-05 自审 + 审计打出的真实缺陷)。
//
// 为什么用**共用模块**验:这段逻辑被领取路径(插件主体)与推理路径
// (channels-zcode 适配器)共用,任一侧漂移都会让"请勿连续重试"只出现在
// 一条路径上。判据直接打共用模块的导出,两边用的是同一份源码文本。
func TestZcode3012ClassificationMatrix(t *testing.T) {
h := newZcodeIdentityHarness(t)
cases := []struct {
name string
body string
want bool
}{
// ── 真风控(必须命中)──
{"上游真实文案(带码)", `{"code":3012,"msg":"request has been blocked due to unusual activity."}`, true},
{"码是纯数字字符串", `{"code":"3012","msg":"x"}`, true},
{"仅正文语义(无数字)", `request has been blocked due to unusual activity.`, true},
{"3012 与 block 紧邻", `3012 blocked by waf`, true},
// ── 必须**不**命中(误判会让用户做错事)──
{"13012 不是 3012", `{"code":13012}`, false},
{"标识符 plan-3012-trust", `plan-3012-trust`, false},
{"标识符 trust_3012", `trust_3012`, false},
{"字母包裹 x3012y", `x3012y`, false},
{"限流的毫秒数", `too many requests, retry after 3012 ms`, false},
{"HTML 里的 3012", `<h1>Error 3012</h1>upstream connect timeout`, false},
{"限流+block 距 14", `rate limited: request blocked, retry after 3012 ms`, false},
{"block 距 12", `temporarily blocked; see error 3012`, false},
{"额度码 1005", `{"code":1005,"msg":"quota"}`, false},
// ── 隔离「词边界」规则:这些用例同时满足"block 紧邻",
// 只有词边界能区分 ⇒ 裸子串实现会把前三个判成风控。
// (最初的矩阵没覆盖这一组,变异测试因此没咬住 —— 补上后才有效。)
{"标识符+block 紧邻", `plan-3012 blocked`, false},
{"字母包裹+block 紧邻", `x3012 blocked`, false},
{"下划线+block 紧邻", `trust_3012 blocked`, false},
{"独立词+block 紧邻", `3012 blocked`, true},
{"13012 里的片段+block 紧邻", `13012 blocked`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := h.callZD("hasUnusualActivity", tc.body)
if got != tc.want {
t.Errorf("hasUnusualActivity(%q) = %v, want %v\n"+
"(误判的后果不是文案问题:领取路径会据此短路,且限流='稍后重试'、\n"+
" 风控='别重试',两者用户动作完全相反)", tc.body, got, tc.want)
}
})
}
}
// ★ 时间差格式化必须逐档单独算:参考初版把分钟档写成 `floor(seconds % 60)`,
// 于是 90 秒被渲染成「30分30秒」(分位取的是**秒的余数**)。
// 该错误是在**真实探针输出**里发现的,纯函数单测当时没覆盖 ≥60 秒的区间。
func TestZcodeFormatGapDoesNotMisrenderMinutes(t *testing.T) {
h := newZcodeIdentityHarness(t)
for _, tc := range []struct {
ms int
want string
}{
{90000, "1分30秒"}, // 参考踩过的坑:错法会得到 "30分30秒"
{59000, "59.0s"}, // 不足一分钟
{60000, "1分"}, // 整分钟
{3600000, "1小时"}, // 整小时
{3900000, "1小时5分"}, // 小时+分
{500, "500ms"},
} {
got := h.callZDString("formatGap", tc.ms)
if got != tc.want {
t.Errorf("formatGap(%d) = %q, want %q", tc.ms, got, tc.want)
}
}
}
// ★ 诊断行**绝不能含凭据片段**(参考的设计红线之一)。
//
// 账号只以"进程内自增序号"出现。判据用「传入的账号 id 的每个子串都不得出现在
// 文案里」来钉死这条 —— 不是"看起来没有"。
func TestZcodeDiagnosticLeaksNoCredentials(t *testing.T) {
h := newZcodeIdentityHarness(t)
const secretID = "zcode-abcdef1234567890-SECRET"
h.callZDAction("accountOrdinal", secretID)
h.callZDAction2("noteRequestSent", secretID, 1000)
h.callZDAction2("noteRequestOk", secretID, 1100)
diag := h.callZDString("formatDiagnostic", map[string]interface{}{
"accountId": secretID,
"sentAt": 1200,
"now": 1300,
"status": 405,
})
if diag == "" {
t.Fatal("formatDiagnostic 返回空")
}
// 账号 id 的每个 >=4 字符子串都不得出现
for i := 0; i+4 <= len(secretID); i++ {
if strings.Contains(diag, secretID[i:i+4]) {
t.Fatalf("诊断行泄漏账号 id 片段 %q:%s", secretID[i:i+4], diag)
}
}
if !strings.Contains(diag, "账号#") {
t.Fatalf("诊断行应含「账号#序号」形态:%s", diag)
}
t.Logf("✓ 诊断行无凭据片段: %s", diag)
}
// ★ 3012 的文案必须**共用**(推理 + 领取两条路径),且必须含"请勿连续重试"。
//
// 3012 不是"重试就能过"(那是 3007),而是**有账号冷却惩罚**:
// 30 分钟起步,反复触发升级到 24 小时乃至停用。
func TestZcode3012MessageIsSharedAndWarnsAboutRetries(t *testing.T) {
h := newZcodeIdentityHarness(t)
msg := h.callZDString("formatUnusualActivityMessage", map[string]interface{}{
"subject": "领取 plan-x",
"rawResponse": "request has been blocked",
})
for _, want := range []string{"3012", "请勿连续重试", "冷却惩罚", "领取 plan-x"} {
if !strings.Contains(msg, want) {
t.Fatalf("风控文案缺 %q:%s", want, msg)
}
}
// 空 subject/diagnostic/rawResponse 时不得留下空壳("本机诊断:"后面啥都没有)
bare := h.callZDString("formatUnusualActivityMessage", map[string]interface{}{})
if strings.Contains(bare, "本机诊断:\n") || strings.Contains(bare, "原始响应:\n") {
t.Fatalf("空参数不应留下空壳段:%q", bare)
}
t.Logf("✓ 文案: %s", strings.SplitN(msg, "\n", 2)[0])
}
// ★ 请求体形态诊断必须读**真正要发出去的对象**,而不是常量。
//
// 常量只能证明"源码里写了多少字",证明不了"线上发出去多少字"。
// 两者一旦不一致(例如某条改动把 system 顶掉了),只有读实际值才看得出来
// —— 它会**如实显示 0 字符**,而不是拿常量里的 2898 把问题盖住。
func TestZcodeRequestShapeReadsActualObject(t *testing.T) {
h := newZcodeIdentityHarness(t)
// 用真实的适配器产出 → 再诊断它(而不是手搓一个假 system)
out, err := h.transformRequest(`{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := jsonUnmarshal(out, &req); err != nil {
t.Fatal(err)
}
shape := h.callZDMap("describeRequestShape", req["system"], req["messages"])
if shape == nil {
t.Fatal("describeRequestShape 返回空")
}
chars, _ := shape["identityChars"].(float64)
if chars < 2800 {
t.Fatalf("身份块实测字符数 %v < 2800(诊断必须读实际值)", chars)
}
if shape["hasDateBlock"] != true {
t.Fatalf("应检测到日期块,实得 %v", shape["hasDateBlock"])
}
if shape["firstRole"] != "user" {
t.Fatalf("firstRole 应为 user,实得 %v", shape["firstRole"])
}
t.Logf("✓ 形态诊断: 块数=%v 身份块=%v 日期块=%v role=%v",
shape["systemBlocks"], chars, shape["hasDateBlock"], shape["firstRole"])
// 反例:空 system ⇒ 必须**如实显示 0**(不能被常量盖住)
empty := h.callZDMap("describeRequestShape", []interface{}{}, req["messages"])
if c, _ := empty["identityChars"].(float64); c != 0 {
t.Fatalf("空 system 时 identityChars 必须为 0(如实反映实际值),实得 %v", c)
}
}
// ── harness 助手:经共用的探针打**那一份** ZD 实现 ──
//
// 走探针而不是让测试自己 load 一份副本:判据必须打真实被两条路径共用的实现,
// 自己 load 副本会退化成"各自测各自的",共用一旦漂移就测不出来。
func (h *zcodeIdentityHarness) callZD(fn string, a interface{}) bool {
h.t.Helper()
res, err := h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a})
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
m := res.(map[string]interface{})["json"].(map[string]interface{})
if e, ok := m["error"]; ok {
h.t.Fatalf("探针错误(%s): %v", fn, e)
}
v, ok := m["result"].(bool)
if !ok {
h.t.Fatalf("%s 返回非布尔: %#v", fn, m["result"])
}
return v
}
func (h *zcodeIdentityHarness) callZDString(fn string, a interface{}) string {
h.t.Helper()
var res interface{}
var err error
if opts, ok := a.(map[string]interface{}); ok {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "opts": opts})
} else if fn == "formatGap" {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a})
} else {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a})
}
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
m := res.(map[string]interface{})["json"].(map[string]interface{})
if e, ok := m["error"]; ok {
h.t.Fatalf("探针错误(%s): %v", fn, e)
}
s, _ := m["result"].(string)
return s
}
// callZDAction / callZDAction2:调"动作型"探针(不返回结果,只看没报错)。
func (h *zcodeIdentityHarness) callZDAction(fn string, a interface{}) {
h.t.Helper()
res, err := h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a})
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
if m := res.(map[string]interface{})["json"].(map[string]interface{}); m["error"] != nil {
h.t.Fatalf("探针错误(%s): %v", fn, m["error"])
}
}
func (h *zcodeIdentityHarness) callZDAction2(fn string, a interface{}, a2 int) {
h.t.Helper()
res, err := h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": a, "a2": a2})
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
if m := res.(map[string]interface{})["json"].(map[string]interface{}); m["error"] != nil {
h.t.Fatalf("探针错误(%s): %v", fn, m["error"])
}
}
func (h *zcodeIdentityHarness) callZDMap(fn string, args ...interface{}) map[string]interface{} {
h.t.Helper()
var res interface{}
var err error
if len(args) == 2 {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "system": args[0], "messages": args[1]})
} else {
res, err = h.ps.Call("channels", "__zcode_diag_probe",
map[string]interface{}{"fn": fn, "a": args[0]})
}
if err != nil {
h.t.Fatalf("__zcode_diag_probe(%s): %v", fn, err)
}
m := res.(map[string]interface{})["json"].(map[string]interface{})
if e, ok := m["error"]; ok {
h.t.Fatalf("探针错误(%s): %v", fn, e)
}
out, _ := m["result"].(map[string]interface{})
return out
}
// jsonUnmarshal 是本文件用的小助手(避免与其它测试文件的导入冲突)。
func jsonUnmarshal(s string, v interface{}) error {
return json.Unmarshal([]byte(s), v)
}

View File

@ -0,0 +1,404 @@
package core
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"llmsproxy/internal/lua"
)
// ★ ZCode 的身份块是 3012 准入的**唯一开关**。
//
// 参考实测(三个账号、17 次请求):system 缺官方身份块结构时,上游直接
//
// HTTP 405 + {"code":3012,"msg":"...unusual activity."}
//
// 且与 HTTP 头、版本号、请求频率、多轮历史、tools 声明、运行时**全部无关**。
//
// 判据矩阵(照参考,逐条锁定):
//
// 无 system → 3012
// 仅 cliPrefix(42 字符) → 3012
// cliPrefix + stable(约 2898 字符)→ 200
//
// ⇒ 判据是「身份块**结构**是否存在」,不是字符数凑够。
//
// 本判据看**适配器真正产出给上游的请求体**(transform_request 的返回值),
// 而不是"适配器装了没"这类内部信号。
func TestZcodeAdapterInjectsOfficialIdentityBlocks(t *testing.T) {
h := newZcodeIdentityHarness(t)
out, err := h.transformRequest(`{"model":"glm-5.3-flash","messages":[{"role":"user","content":"hi"}]}`)
if err != nil {
t.Fatalf("transform_request: %v", err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatalf("产出不是 JSON: %v\n%s", err, out)
}
// ① system 必须是**块数组**(官方形态),不是纯字符串
sys, ok := req["system"].([]interface{})
if !ok {
t.Fatalf("system 必须是块数组(上游检查的是这个结构),实得 %T: %v",
req["system"], req["system"])
}
if len(sys) < 3 {
t.Fatalf("system 块数至少 3(cliPrefix + stable + Environment),实得 %d", len(sys))
}
// ② block[0] 必须是官方 CLI 前缀(准入必需)
b0, _ := sys[0].(map[string]interface{})
if got, _ := b0["text"].(string); got != "You are ZCode, an interactive coding agent" {
t.Fatalf("block[0] 必须是官方 CLI 前缀(逐字),实得 %q", got)
}
// ③ block[1] 必须是官方 stable 段(准入必需),长度对得上
b1, _ := sys[1].(map[string]interface{})
stable, _ := b1["text"].(string)
if len(stable) < 2800 {
t.Fatalf("block[1](stable 段)长度 %d < 2800 —— 身份块不完整会被判为裸请求(3012)",
len(stable))
}
for _, marker := range []string{
"You are an interactive ZCode agent",
"# Harness",
"# ZCode Desktop Context",
} {
if !strings.Contains(stable, marker) {
t.Fatalf("stable 段缺 %q —— 身份块被截断/改写", marker)
}
}
// ④ block[2] 是 Environment 段(官方每会话都发;不发则模型不知 cwd/平台)
b2, _ := sys[2].(map[string]interface{})
env, _ := b2["text"].(string)
if !strings.HasPrefix(env, "# Environment") {
t.Fatalf("block[2] 必须是 Environment 段,实得 %q", env[:zcodeMin(40, len(env))])
}
for _, marker := range []string{"Primary working directory", "Platform", "powered by the model named"} {
if !strings.Contains(env, marker) {
t.Fatalf("Environment 段缺 %q:%s", marker, env)
}
}
// ⑤ 总长度必须达到参考声明的量级(2898)
total := len("You are ZCode, an interactive coding agent") + len(stable)
if total < 2800 {
t.Fatalf("身份块合计 %d 字符 < 2800(参考实测的准入值约 2898)", total)
}
// ⑥ 日期块:插到第一条 user 消息的 content **数组**最前(官方形态)
msgs, _ := req["messages"].([]interface{})
if len(msgs) == 0 {
t.Fatal("messages 为空")
}
m0, _ := msgs[0].(map[string]interface{})
content, ok := m0["content"].([]interface{})
if !ok {
t.Fatalf("首条 user 的 content 必须是**块数组**(拼进字符串会改变结构、仍被判裸请求),实得 %T",
m0["content"])
}
c0, _ := content[0].(map[string]interface{})
if got, _ := c0["text"].(string); !strings.Contains(got, "<system-reminder>") {
t.Fatalf("content[0] 必须是日期块(<system-reminder>),实得 %q", got)
}
t.Logf("✓ 身份块注入正确:块数=%d stable=%d 合计=%d", len(sys), len(stable), total)
}
// ★ 调用方的 system 必须**追加在最后**:上游检查看的是**前缀结构**,
// 官方身份块必须处在开头位置。若被前置会让身份块不在前缀而吃 3012。
func TestZcodeCallerSystemIsAppendedLast(t *testing.T) {
h := newZcodeIdentityHarness(t)
out, err := h.transformRequest(
`{"model":"glm-5.3-flash","messages":[{"role":"system","content":"CALLER_PROMPT_XYZ"},{"role":"user","content":"hi"}]}`)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatal(err)
}
sys, _ := req["system"].([]interface{})
if len(sys) < 4 {
t.Fatalf("带调用方 system 时应为 4 块(3 官方 + 1 调用方),实得 %d", len(sys))
}
last, _ := sys[len(sys)-1].(map[string]interface{})
lt, _ := last["text"].(string)
if !strings.Contains(lt, "CALLER_PROMPT_XYZ") {
t.Fatalf("调用方 system 必须在**最后一块**(上游看前缀结构;前置会让身份块不在前缀)")
}
// 且前三块仍是官方块(身份块没有被调用方内容挤走)
b0, _ := sys[0].(map[string]interface{})
if got, _ := b0["text"].(string); got != "You are ZCode, an interactive coding agent" {
t.Fatalf("block[0] 必须仍是官方 CLI 前缀,实得 %q", got)
}
}
// 适配器自检出口:身份块长度与 anthropic 基座装载状态。
// 「装了但身份块是空的」这类问题(转义/截断/基座读不到)没有它只能靠猜。
func TestZcodeAdapterProbeReportsIdentityLength(t *testing.T) {
h := newZcodeIdentityHarness(t)
res, err := h.ps.Call("channels", "__adapter_install_probe", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
j := res.(map[string]interface{})["json"].(map[string]interface{})
zc, ok := j["zcode"].(map[string]interface{})
if !ok {
t.Fatalf("探针未报告 zcode 适配器装载结果: %v", j)
}
if zc["ok"] != true {
t.Fatalf("zcode 适配器装载失败: %v", zc["err"])
}
}
// zcodeIdentityHarness:加载插件(它会**自装** channels-zcode 适配器),
// 然后直接调那个适配器的 transform_request。
//
// 直接调已装载的适配器(而不是自己去 load 源码)更接近真实路径:判据验的是
// 「上游实际收到的请求体」,包含前导注入、桥的装载等全部环节。
type zcodeIdentityHarness struct {
t *testing.T
c *Core
ps *lua.Plugins
}
func newZcodeIdentityHarness(t *testing.T) *zcodeIdentityHarness {
t.Helper()
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
return &zcodeIdentityHarness{t: t, c: c, ps: ps}
}
// transformRequest 调**已自装的** channels-zcode 适配器的 transform_request。
func (h *zcodeIdentityHarness) transformRequest(body string) (string, error) {
h.t.Helper()
vm := h.c.VM()
if vm == nil {
h.t.Fatal("VM 不可用")
}
return vm.Transform("channels-zcode", "transform_request", body)
}
func zcodeMin(a, b int) int {
if a < b {
return a
}
return b
}
// ★ 身份块注入不得破坏协议转换:工具调用、图像、流式帧仍必须走 anthropic 那一份。
//
// 为什么必须验:本适配器的手法是把 anthropic.lua 的源码读进来执行、只覆盖
// transform_request。若「读不到基座」或「覆盖时丢了字段」,协议转换会静默退化
// —— 表现为工具调用参数丢失、图像块消失,而身份块看起来完全正常。
func TestZcodeAdapterKeepsAnthropicProtocolConversion(t *testing.T) {
h := newZcodeIdentityHarness(t)
t.Run("工具定义与工具调用存活", func(t *testing.T) {
in := `{"model":"glm-5.3-flash","messages":[{"role":"user","content":"go"}],` +
`"tools":[{"type":"function","function":{"name":"get_weather",` +
`"description":"d","parameters":{"type":"object","properties":{"c":{"type":"string"}}}}}]}`
out, err := h.transformRequest(in)
if err != nil {
t.Fatal(err)
}
// anthropic 形态:tools[].name / input_schema(顶层,不是嵌套在 function 里)
if !strings.Contains(out, `"get_weather"`) {
t.Fatalf("工具定义在转换中丢失(identity 注入不该影响它):%s", out)
}
if !strings.Contains(out, `"input_schema"`) {
t.Fatalf("工具 schema 未转成 anthropic 形态(input_schema 缺失):%s", out)
}
})
t.Run("多模态图像块存活", func(t *testing.T) {
in := `{"model":"glm-5.3-flash","messages":[{"role":"user","content":[` +
`{"type":"text","text":"what"},` +
`{"type":"image_url","image_url":{"url":"data:image/png;base64,AAAA"}}]}]}`
out, err := h.transformRequest(in)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, `"image"`) || !strings.Contains(out, `"base64"`) {
t.Fatalf("图像块在转换中丢失:%s", out)
}
})
t.Run("基座装载状态可见", func(t *testing.T) {
// 读适配器的自检出口:base_loaded 必须为 true(否则身份块虽在、
// 协议转换已退化为透传)
res, err := h.ps.Call("channels", "__adapter_install_probe", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
j := res.(map[string]interface{})["json"].(map[string]interface{})
zc := j["zcode"].(map[string]interface{})
if zc["ok"] != true {
t.Fatalf("zcode 适配器未装载: %v", zc)
}
})
}
// ★ 基座(anthropic.lua)读不到时必须**响亮失败**,不得静默退化。
//
// 反例危害:若"尽力而为"(只注入身份块、不作协议转换),会产出一个身份块
// 看着正确的请求,而 tools/图像/流式结构全未转换 —— 上游可能 200,但模型
// 拿不到工具定义,症状是"模型变笨了",与准入毫不相干,排查会完全跑偏。
//
// 判据看的是**适配器的真实产出**:把适配器目录指到空目录(基座不可得),
// transform_request 必须返回错误而不是原样透传。
func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) {
// 隔离目录:插件目录下没有兄弟 adapters/ 目录 ⇒ 基座读不到
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
c := kernelTestCore(t)
ps := c.Plugins()
src, err := readBundledChannels(t)
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", src); err != nil {
t.Fatal(err)
}
vm := c.VM()
if vm == nil {
t.Fatal("VM 不可用")
}
// 插件自装时若基座读不到,适配器仍应装载(身份块是静态的),
// 但调用必须报错
out, err := vm.Transform("channels-zcode", "transform_request",
`{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
if err == nil {
t.Fatalf("基座不可得时必须报错,却成功产出了:%s", out)
}
if !strings.Contains(err.Error(), "基座") {
t.Fatalf("错误信息应点明基座问题(可定位),实得 %v", err)
}
t.Logf("✓ 基座缺失时响亮失败: %v", err)
}
// ★ 日期块必须**幂等**:已以 <system-reminder> 开头则不重复插。
//
// 不判的后果:同一请求重试/多次 transform 时日期块叠成多层,改变 content 的
// 前缀结构(上游检查的正是结构)。参考 withContextPrefix 有 startsWithSystemReminder。
func TestZcodeContextPrefixIsIdempotent(t *testing.T) {
h := newZcodeIdentityHarness(t)
// 已是插入后的形态(首条 user 的 content[0] 就是 system-reminder)
in := `{"model":"m","messages":[{"role":"user","content":[` +
`{"type":"text","text":"<system-reminder>\nOLD\n</system-reminder>"},` +
`{"type":"text","text":"hi"}]}]}`
out, err := h.transformRequest(in)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatal(err)
}
msgs := req["messages"].([]interface{})
content := msgs[0].(map[string]interface{})["content"].([]interface{})
n := 0
for _, c := range content {
if b, ok := c.(map[string]interface{}); ok {
if s, _ := b["text"].(string); strings.Contains(s, "<system-reminder>") {
n++
}
}
}
if n != 1 {
t.Fatalf("日期块必须幂等(已存在则不重复插),实得 %d 个 system-reminder 块", n)
}
}
// ★ 日期块只处理**第一条**消息,且它必须 role == "user"。
//
// 反例(我第一版就是这样):往后扫第一个 user 消息 —— 若首条是 assistant,
// 官方行为是**不动**,而不是去找后面那条 user。判据钉住这个差别。
func TestZcodeContextPrefixOnlyTouchesFirstMessage(t *testing.T) {
h := newZcodeIdentityHarness(t)
t.Run("首条不是 user 时不动", func(t *testing.T) {
in := `{"model":"m","messages":[` +
`{"role":"assistant","content":"opening"},` +
`{"role":"user","content":"hi"}]}`
out, err := h.transformRequest(in)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatal(err)
}
msgs := req["messages"].([]interface{})
// ⚠ 注意:文本会被 anthropic 基座转成**块数组**(协议转换的正常行为),
// 所以这里不能断言 content 仍是字符串 —— 我第一版就这么断言,把正确的
// 实现判成了错。判据要验的是「**日期块**有没有被插进来」这个事实。
noReminder := func(content interface{}) bool {
switch c := content.(type) {
case string:
return !strings.Contains(c, "<system-reminder>")
case []interface{}:
for _, e := range c {
if b, ok := e.(map[string]interface{}); ok {
if t, _ := b["text"].(string); strings.Contains(t, "<system-reminder>") {
return false
}
}
}
}
return true
}
// 首条(assistant)不该被插日期块
if !noReminder(msgs[0].(map[string]interface{})["content"]) {
t.Fatal("首条是 assistant 时不该插日期块(官方只看第一条且要求 role=user)")
}
// 后面的 user 也**不该**被插(官方不往后找)
if !noReminder(msgs[1].(map[string]interface{})["content"]) {
t.Fatal("日期块被插到了后面的 user 消息上 —— 官方只看第一条")
}
// 且原文本内容必须保留(转换不能丢内容)
b, _ := json.Marshal(msgs[0])
if !strings.Contains(string(b), "opening") {
t.Fatal("首条 assistant 的内容丢了")
}
})
t.Run("首条是 user 时插入", func(t *testing.T) {
in := `{"model":"m","messages":[{"role":"user","content":"hi"}]}`
out, err := h.transformRequest(in)
if err != nil {
t.Fatal(err)
}
var req map[string]interface{}
if err := json.Unmarshal([]byte(out), &req); err != nil {
t.Fatal(err)
}
msgs := req["messages"].([]interface{})
content, ok := msgs[0].(map[string]interface{})["content"].([]interface{})
if !ok {
t.Fatal("首条 user 的 content 应升级为块数组")
}
b0 := content[0].(map[string]interface{})
if s, _ := b0["text"].(string); !strings.Contains(s, "<system-reminder>") {
t.Fatalf("首条 user 的 content[0] 必须是日期块,实得 %q", s)
}
})
}

View File

@ -33,13 +33,13 @@ func TestDeployPruneAdapterBackups(t *testing.T) {
t.Fatalf("read deploy.sh: %v", err)
}
src := string(fn)
i := strings.Index(src, "prune_adapter_backups()")
i := strings.Index(src, "prune_backups()")
if i < 0 {
t.Fatal("prune_adapter_backups not found in deploy.sh")
t.Fatal("prune_backups not found in deploy.sh")
}
j := strings.Index(src[i:], "\n}\n")
if j < 0 {
t.Fatal("prune_adapter_backups has no closing brace")
t.Fatal("prune_backups has no closing brace")
}
body := src[i : i+j+3]
@ -47,20 +47,20 @@ func TestDeployPruneAdapterBackups(t *testing.T) {
body = strings.Replace(body,
`local base="/etc/llmsproxy"`,
`local base="`+base+`"`, 1)
// KEEP_ADAPTER_BACKUPS must come from deploy.sh itself, NOT from a literal
// KEEP_BACKUPS must come from deploy.sh itself, NOT from a literal
// here: hardcoding 5 in the shim overrode whatever the script configured, so
// a mutation that set KEEP_ADAPTER_BACKUPS=0 in deploy.sh still passed. The
// a mutation that set KEEP_BACKUPS=0 in deploy.sh still passed. The
// value is part of what is under test.
var keepRe = regexp.MustCompile(`(?m)^KEEP_ADAPTER_BACKUPS=(\d+)`)
var keepRe = regexp.MustCompile(`(?m)^KEEP_BACKUPS=(\d+)`)
keepM := keepRe.FindStringSubmatch(src)
if keepM == nil {
t.Fatal("deploy.sh no longer sets KEEP_ADAPTER_BACKUPS; the pruning " +
t.Fatal("deploy.sh no longer sets KEEP_BACKUPS; the pruning " +
"policy would be undefined")
}
keep := keepM[1]
script := "warn() { :; }\nlog() { :; }\nKEEP_ADAPTER_BACKUPS=" + keep + "\n" +
body + "\nprune_adapter_backups\n"
script := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" + keep + "\n" +
body + "\nprune_backups adapters\n"
scriptPath := filepath.Join(base, "fn.sh")
if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil {
t.Fatalf("write script: %v", err)
@ -88,7 +88,7 @@ func TestDeployPruneAdapterBackups(t *testing.T) {
}
if out, err := exec.Command("bash", scriptPath).CombinedOutput(); err != nil {
t.Fatalf("prune_adapter_backups failed: %v\n%s", err, out)
t.Fatalf("prune_backups failed: %v\n%s", err, out)
}
entries, err := os.ReadDir(base)
@ -102,11 +102,11 @@ func TestDeployPruneAdapterBackups(t *testing.T) {
}
}
// 12 well-formed backups with KEEP_ADAPTER_BACKUPS=5 → the newest FIVE
// 12 well-formed backups with KEEP_BACKUPS=5 → the newest FIVE
// survive (…08 …09 …10 …11 …12) and the older seven are deleted. Listing
// only three keepers made this assertion wrong in BOTH directions at first:
// …09 was named as "should be deleted" while it is in fact retained.
// KEEP_ADAPTER_BACKUPS=5 is a cap on the TOTAL number of backups, and
// KEEP_BACKUPS=5 is a cap on the TOTAL number of backups, and
// irregular names are never deleted — so they consume slots rather than
// being ignored. With 3 unremovable directories present, only 2 of the
// well-formed ones can survive. That is the function's actual (and safe)
@ -202,3 +202,123 @@ func TestDeployBacksUpTheLiveFileBeforeOverwriting(t *testing.T) {
"captures the new file and rollback restores nothing")
}
}
// ★ 备份清理必须覆盖 plugins 前缀(真实缺陷)。
//
// 事实:backup 清理最初只处理 `adapters.bak.*`,而 `sync_plugins` 同样每次部署
// 建一个 `plugins.bak.<时间戳>` —— 于是 adapters 的堆积被修好,plugins 继续涨。
// 实测线上 plugins.bak 积累到 **36 个**、/etc/llmsproxy 涨到 192M。
//
// 这是「两处只修一处」的老形状。判据钉住:prune_all_backups 必须同时裁两个前缀,
// 且新增任何 .bak.<时间戳> 前缀时都要在这里补上(否则又是一个泄漏点)。
func TestDeployPruneCoversBothBackupPrefixes(t *testing.T) {
if _, err := os.Stat("../../deploy.sh"); err != nil {
t.Skip("deploy.sh not present (packaging-only checkout)")
}
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not available")
}
fn, err := os.ReadFile("../../deploy.sh")
if err != nil {
t.Fatal(err)
}
src := string(fn)
// ① prune_all_backups 必须存在且两个前缀都裁
i := strings.Index(src, "prune_all_backups()")
if i < 0 {
t.Fatal("prune_all_backups 不存在 —— 调用点若仍只裁 adapters," +
"plugins.bak 会继续无上限堆积(实测 36 个 / 192M)")
}
j := strings.Index(src[i:], "\n}\n")
if j < 0 {
t.Fatal("prune_all_backups 没有收尾大括号")
}
body := src[i : i+j+3]
for _, want := range []string{"prune_backups adapters", "prune_backups plugins"} {
if !strings.Contains(body, want) {
t.Fatalf("prune_all_backups 缺 %q —— plugins.bak 会无上限堆积", want)
}
}
// ② ★ 覆盖完整性:脚本里出现的每个 `.bak.<时间戳>` 前缀都必须在裁剪范围内。
// 这条比逐字检查两个名字更强 —— 将来有人新增第三种备份目录(比如
// runtime.bak.*),这里会立刻报红,而不是等它涨到几百兆。
prefixRe := regexp.MustCompile(`([a-z_]+)\.bak\.\$\(date`)
found := map[string]bool{}
for _, m := range prefixRe.FindAllStringSubmatch(src, -1) {
found[m[1]] = true
}
if len(found) == 0 {
t.Fatal("deploy.sh 里没找到任何 .bak.$(date …) 备份目录创建点(判据需更新)")
}
for prefix := range found {
if !strings.Contains(body, "prune_backups "+prefix) {
t.Errorf("deploy.sh 会创建 %s.bak.<时间戳> 目录,但 prune_all_backups 不裁它 "+
"—— 它就是一个无上限增长的备份泄漏点(实测 plugins.bak 涨到 36 个)", prefix)
}
}
// ③ 行为验证:用真函数裁 plugins 前缀,最新 5 份必须留下、更旧的删掉
base := t.TempDir()
k := strings.Index(src, "prune_backups()")
kj := strings.Index(src[k:], "\n}\n")
fnBody := src[k : k+kj+3]
fnBody = strings.Replace(fnBody, `local base="/etc/llmsproxy"`, `local base="`+base+`"`, 1)
keepRe := regexp.MustCompile(`(?m)^KEEP_BACKUPS=(\d+)`)
km := keepRe.FindStringSubmatch(src)
if km == nil {
t.Fatal("deploy.sh 不再设置 KEEP_BACKUPS")
}
script := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" +
km[1] + "\n" + fnBody + "\nprune_backups plugins\n"
sp := filepath.Join(base, "fn.sh")
if err := os.WriteFile(sp, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
for _, ts := range []string{
"20260101120000", "20260201120000", "20260301120000", "20260401120000",
"20260501120000", "20260601120000", "20260701120000", "20260801120000",
} {
if err := os.MkdirAll(filepath.Join(base, "plugins.bak."+ts), 0o755); err != nil {
t.Fatal(err)
}
}
// adapters 前缀**不该被动**(防止误删别的前缀)
if err := os.MkdirAll(filepath.Join(base, "adapters.bak.20260101120000"), 0o755); err != nil {
t.Fatal(err)
}
if out, err := exec.Command("bash", sp).CombinedOutput(); err != nil {
t.Fatalf("prune_backups plugins failed: %v\n%s", err, out)
}
entries, err := os.ReadDir(base)
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, e := range entries {
if e.IsDir() {
got[e.Name()] = true
}
}
if !got["plugins.bak.20260801120000"] {
t.Error("最新的 plugins 备份被删了;裁剪必须从最旧的一端开始")
}
if got["plugins.bak.20260101120000"] {
t.Error("最旧的 plugins 备份存活;裁剪没生效")
}
if !got["adapters.bak.20260101120000"] {
t.Error("裁 plugins 时误删了 adapters 备份 —— 前缀参数必须严格限定范围")
}
// ④ 未知前缀必须响亮失败(防误用:传空串会删掉整个目录)
bad := "warn() { :; }\nlog() { :; }\nfail() { echo \"$*\" >&2; exit 1; }\nKEEP_BACKUPS=" +
km[1] + "\n" + fnBody + "\nprune_backups \"\"\n"
bp := filepath.Join(base, "bad.sh")
if err := os.WriteFile(bp, []byte(bad), 0o600); err != nil {
t.Fatal(err)
}
if out, err := exec.Command("bash", bp).CombinedOutput(); err == nil {
t.Fatalf("prune_backups 接受空前缀(危险:会删整个目录),输出: %s", out)
}
}

View File

@ -143,6 +143,14 @@ type PluginRuntime struct {
ps *Plugins
spec scheduleSpec
// stopOnce makes Stop idempotent. Stop closes a channel and waits on done;
// without this, a second Stop panics ("close of closed channel"). That is
// reachable in practice now that BOTH Unload and Close stop schedulers:
// unloading a plugin then closing the plugin set would otherwise abort the
// process during shutdown — the worst possible moment (a panic here masks
// whatever the operator was actually shutting down for).
stopOnce sync.Once
mu sync.Mutex
// nextDue is when each task should next fire.
nextDue map[string]time.Time
@ -457,8 +465,10 @@ func (rt *PluginRuntime) Stop() {
if rt == nil {
return
}
close(rt.stop)
<-rt.done
rt.stopOnce.Do(func() {
close(rt.stop)
<-rt.done
})
}
// callFnOn invokes a plugin function on an already-locked state.

View File

@ -0,0 +1,139 @@
package lua
import (
"os"
"path/filepath"
"runtime"
"testing"
"time"
)
// ★ Close() 必须停掉每个插件的**调度循环**。
//
// 真实缺陷(与 on_unload 同一形状的第 3 个实例):`Runtime.Stop()` 此前
// **只在单插件 Unload 里调**,Close() 不调 —— 于是「进程关闭/测试结束」
// 这条路径上,每个插件的 PluginRuntime.loop 永不停止。
//
// 实测:全量回归在 internal/lua 出现 **69 个 goroutine 卡在 saver/runtime
// 循环里**,最终 600s 超时。
//
// 判据用的是**进程里真实存在的 goroutine 数**:每 LoadSource 一个插件就
// 起一个调度循环,Close 必须让它们全部退出 —— 否则 goroutine 只增不减。
// 这比"任务被调用过"更贴近缺陷本身(缺陷就是循环不停)。
func TestPluginsCloseStopsSchedulerGoroutines(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
// 24 个带调度表的插件:每个起一个 PluginRuntime.loop
const n = 24
for i := 0; i < n; i++ {
src := `
local plugin = { name = "s` + string(rune('a'+i)) + `", version = "1.0.0", exports = {} }
function plugin.noop_task() end
plugin.schedule = { { fn = "noop_task", every = 3600 } }
return plugin
`
name := filepath.Join(pluginDir, "s"+string(rune('a'+i))+".lua")
if err := os.WriteFile(name, []byte(src), 0644); err != nil {
t.Fatal(err)
}
}
before := runtime.NumGoroutine()
ps := NewPlugins(nil, pluginDir)
if err := ps.LoadDir(); err != nil {
t.Fatalf("LoadDir: %v", err)
}
loaded := runtime.NumGoroutine()
if loaded <= before {
t.Fatalf("加载 %d 个带调度的插件后 goroutine 没涨(%d → %d)—— 夹具失效",
n, before, loaded)
}
ps.Close()
// ★ Close 之后循环必须**全部**退出,即回落到基线附近。
//
// ⚠️ 这里不能用「比 loaded 小就算过」:那只要**一个** goroutine 退出就成立
// (27 → 26 也满足),而 24 个调度循环可以全部还在跑 —— 我第一版就是
// 这么写的,被变异测试当场揭穿(去掉 stopSchedulersAll 后仍然绿)。
// 判据必须锚定**基线**,容差只留给无关的运行时 goroutine。
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() <= before+2 {
break
}
time.Sleep(50 * time.Millisecond)
}
after := runtime.NumGoroutine()
if after > before+2 {
t.Fatalf("Close() 之后仍有调度循环在跑:goroutine 基线 %d、加载后 %d、Close 后 %d(加载了 %d 个插件)—— "+
"它们会一直堆积(实测全量回归堆到 69 个卡死并 600s 超时)", before, loaded, after, n)
}
t.Logf("✓ Close() 停掉了全部调度循环(goroutine 基线 %d → 加载后 %d → Close 后 %d,%d 个插件)",
before, loaded, after, n)
}
// ★ Stop() 必须幂等:Unload 与 Close 都会停调度器,重复调不得 panic。
//
// 反例:Stop 里裸 `close(rt.stop)` —— 第二次调用 `panic: close of closed channel`。
// 关闭路径上的 panic 最糟糕:它掩盖掉操作者本来在做的事(关服务/结束测试)。
func TestPluginRuntimeStopIsIdempotent(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
src := `
local plugin = { name = "sched2", version = "1.0.0", exports = {} }
function plugin.noop_task() end
plugin.schedule = { { fn = "noop_task", every = 30 } }
return plugin
`
if err := os.WriteFile(filepath.Join(pluginDir, "sched2.lua"), []byte(src), 0644); err != nil {
t.Fatal(err)
}
ps := NewPlugins(nil, pluginDir)
if err := ps.LoadDir(); err != nil {
t.Fatalf("LoadDir: %v", err)
}
defer ps.Close()
ps.mu.RLock()
var rt *PluginRuntime
for _, p := range ps.plugins {
if p.Info.Name == "sched2" {
rt = p.Runtime
}
}
ps.mu.RUnlock()
if rt == nil {
t.Fatal("插件未拿到 Runtime(夹具失效)")
}
// 连调三次:第二次若 panic,本测试就会崩掉(正是要防的行为)
rt.Stop()
rt.Stop()
rt.Stop()
}
func waitForFile(t *testing.T, path string, d time.Duration) {
t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if _, err := os.Stat(path); err == nil {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("等待 %s 出现超时 —— 调度任务从未运行(夹具失效)", filepath.Base(path))
}
func fileSize(t *testing.T, path string) int64 {
t.Helper()
st, err := os.Stat(path)
if err != nil {
t.Fatalf("stat %s: %v", path, err)
}
return st.Size()
}

View File

@ -0,0 +1,77 @@
package lua
import (
"os"
"path/filepath"
"testing"
)
// ★ 关闭 Plugins 时必须给插件一次 on_unload 机会。
//
// 真实缺陷:Close() 此前只关 shard 与 saver,**从不调用 on_unload** —— 而
// on_unload 只在单插件 Unload 时调。于是「进程关闭/测试结束」这条路径上,
// 起了 OS 进程的插件(回环回调接收器、浏览器栈)永远收不了尾,留下孤儿进程。
// 实测本机积累到 995 个 python 监听器(828 个超 24 小时),全是测试残留。
//
// 这个判据看的是**真实外部效应**(插件在 on_unload 里改磁盘上的文件),
// 而不是「钩子被调用过」这类内部信号 —— 后者在钩子写错名字时也会假绿。
func TestPluginsCloseCallsOnUnload(t *testing.T) {
dir := t.TempDir()
marker := filepath.Join(dir, "unloaded.marker")
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
src := `
local plugin = { name = "leaky", version = "1.0.0", exports = {} }
plugin.ui = nil
function plugin.on_unload()
local f = io.open("` + marker + `", "w")
if f then f:write("reaped"); f:close() end
end
return plugin
`
if err := os.WriteFile(filepath.Join(pluginDir, "leaky.lua"), []byte(src), 0644); err != nil {
t.Fatal(err)
}
ps := NewPlugins(nil, pluginDir)
if err := ps.LoadDir(); err != nil {
t.Fatalf("LoadDir: %v", err)
}
// 关之前不该有标记
if _, err := os.Stat(marker); err == nil {
t.Fatal("on_unload 在 Close 之前就被调了?")
}
ps.Close()
// ★ 真判据:文件必须被写出来(= on_unload 真的跑过)
if _, err := os.Stat(marker); err != nil {
t.Fatalf("Close() 没有调用 on_unload —— 起了 OS 进程的插件会留下孤儿进程"+
"(真实缺陷:995 个 python 监听器残留)。marker 不存在: %v", err)
}
t.Log("✓ Close() 触发了 on_unload(孤儿进程不会再堆积)")
}
// on_unload 抛错不得让 Close 崩掉(best-effort 契约)。
func TestPluginsCloseSurvivesFailingOnUnload(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatal(err)
}
src := `
local plugin = { name = "boom", version = "1.0.0", exports = {} }
function plugin.on_unload() error("故意炸") end
return plugin
`
if err := os.WriteFile(filepath.Join(pluginDir, "boom.lua"), []byte(src), 0644); err != nil {
t.Fatal(err)
}
ps := NewPlugins(nil, pluginDir)
if err := ps.LoadDir(); err != nil {
t.Fatalf("LoadDir: %v", err)
}
ps.Close() // 不得 panic
}

View File

@ -572,10 +572,101 @@ func (ps *Plugins) Close() {
if ps == nil {
return
}
// ★ 顺序至关重要:**先停调度,再落盘,最后给插件收尾**。
//
// saver 的最终写入要读每个插件的 Lua 表;on_unload 之后 state 已关,
// 读到的就是空 —— 最后一个区间的累积静默丢失(persist_test 的
// TestCloseFlushesTail 正是为守住这一点而存在)。
//
// 所以不能把 callOnUnloadAll 挪到前面,也不能把 save.Close 挪到后面。
//
// ★ 而调度器必须停在**落盘之前**:它每 tick 可能调 Lua、改状态,
// 落在 save.Close 之后就会写进一个已经收尾的 state;停在最前面也顺带
// 保证了「收尾期间不会再有新任务被派发」。
//
// 真实缺陷(与 on_unload 同一形状的第 3 个实例):Runtime.Stop() 此前
// **只在单插件 Unload 里调**,Close() 不调 —— 于是每个插件的调度循环
// (PluginRuntime.loop)在「进程关闭/测试结束」这条路径上永不停止。
// 实测全量回归在 internal/lua 出现 69 个 goroutine 卡在 saver/runtime
// 循环里并超时 600s。
ps.stopSchedulersAll()
// Shard VMs are separate golua states; leaving them open leaks a VM per
// shard on every reload.
ps.shards.Shutdown()
ps.save.Close()
ps.callOnUnloadAll()
}
// stopSchedulersAll 停掉每个插件的调度循环(best-effort,幂等)。
//
// 与 Unload 里的 Runtime.Stop() 是同一契约:Close 是它在进程退出路径上的
// 等价点,不能只有 Unload 有 —— 那正是 on_unload 缺陷的形状(“两处只修一处”)。
func (ps *Plugins) stopSchedulersAll() {
ps.mu.Lock()
plugins := make([]*Plugin, len(ps.plugins))
copy(plugins, ps.plugins)
ps.mu.Unlock()
for _, p := range plugins {
if p == nil || p.Runtime == nil {
continue
}
p.Runtime.Stop()
p.Runtime = nil
}
}
// callOnUnloadAll 依次给每个插件调 on_unload(best-effort)。
//
// 与 Unload 里的调用共用同一实现:两处若各写一遍,将来必然漂移成一个修了
// 一个没修 —— 这正是本次缺陷的形状(Unload 有、Close 没有)。
func (ps *Plugins) callOnUnloadAll() {
ps.mu.Lock()
plugins := make([]*Plugin, len(ps.plugins))
copy(plugins, ps.plugins)
ps.mu.Unlock()
for _, p := range plugins {
ps.callOnUnload(p)
}
}
// callOnUnload 调单个插件的 on_unload(存在才调),并关掉它的 Lua state。
//
// 为什么关 state 也在这里:on_unload 之后 state 就没用了,而 state 一旦关掉,
// 钩子再也调不了 —— 调用与关闭必须紧邻,否则又会漏。Unload 与 Close 各自持有
// 的 state 生命周期不同(Unload 还要 rebuild,Close 之后整体销毁),所以这里
// 只做「调用 + 置空」,由调用方决定其余步骤。
func (ps *Plugins) callOnUnload(p *Plugin) {
if p == nil {
return
}
if err := ps.invokeOnUnload(p, p.Info.Name); err != nil {
log.Printf("[plugins] %s: on_unload: %v", p.Info.Name, err)
}
}
// invokeOnUnload 真正调钩子。抽出来是为了让 Unload 与 Close 共用一份实现
// (含 state 判空与错误返回语义),避免两处契约不一致。
func (ps *Plugins) invokeOnUnload(p *Plugin, name string) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.state == nil || p.state.L == nil {
return nil
}
L := p.state.L
L.GetGlobal(pluginGlobal)
if L.Type(-1) != golua.LUA_TTABLE {
L.Pop(1)
return nil
}
L.GetField(-1, "on_unload")
var err error
if L.Type(-1) == golua.LUA_TFUNCTION {
err = callFnOn(L, "on_unload")
}
L.Pop(2) // plugin table + field remnant
p.state.L.Close()
p.state = nil
return err
}
// LoadDir loads every .lua file in dir as a plugin. Files are loaded in
@ -1941,24 +2032,10 @@ func (ps *Plugins) Unload(name string) error {
// only guaranteed point where it still can. Best-effort by design — a
// hook that errors must not block an unload the operator explicitly asked
// for (a wedged plugin has its own path above).
p.mu.Lock()
if p.state != nil && p.state.L != nil {
p.state.L.GetGlobal(pluginGlobal)
if L := p.state.L; L.Type(-1) == golua.LUA_TTABLE {
L.GetField(-1, "on_unload")
if L.Type(-1) == golua.LUA_TFUNCTION {
if err := callFnOn(L, "on_unload"); err != nil {
log.Printf("[plugins] %s: on_unload: %v", name, err)
}
}
L.Pop(2) // adapter/plugin table + field remnant
} else {
L.Pop(1)
}
p.state.L.Close()
p.state = nil
}
p.mu.Unlock()
//
// 与 Close 共用 invokeOnUnload:两处各写一遍必然漂移 —— 本次缺陷正是
// 「Unload 有、Close 没有」。
_ = ps.invokeOnUnload(p, name)
ps.rebuild()
return nil
}

File diff suppressed because it is too large Load Diff