Commit Graph

338 Commits

Author SHA1 Message Date
33bb7a98b2 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:24 +08:00
1e6b4864ec 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:35 +08:00
89d759ece7 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:58 +08:00
8d23005f7d 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:28 +08:00
ddbafe822c chore(main): 回流 —— opencode Zen 免费层可用 + noVNC path 相对名修复
内容与 release/v2.0.x 完全一致(git checkout release/v2.0.x -- .):
- feat(opencode): Zen 免费层门禁四要素 + 两个 id 公式(详见 release 提交)
- fix(vnc): noVNC 的 path 必须用相对名,否则 WebSocket 路径重复拼接
2026-10-15 20:31:45 +08:00
482a65958b docs(plugins): 补「前缀代理」章节 —— 插件自起服务挂到网关同端口 2026-10-15 15:02:04 +08:00
796b30f5b0 feat(插件代理): 同源反向代理 —— VNC 不再暴露额外端口
问题:登录返回的 view_url 是 http://127.0.0.1:60100/vnc.html,WebUI 直接把它
嵌进 iframe。远程运维打开必白屏 —— 127.0.0.1 在客户端指向客户端自己,且
websockify/x11vnc 只绑回环,换服务器 IP 也连不上(实测 LAN IP 访问 000)。

★ 教训:只验证"服务端截图有画面"就宣布 VNC 可用是不充分的。四层链路里
①X 渲染 ②noVNC 客户端渲染 一直是好的,白屏真因在第④层「客户端如何拿到地址」。

内核新增通用「插件前缀代理」能力(不是为 channels 写的专用反向代理):
- plugin.http 支持 prefix = true:声明前缀路由,承接所有子路径
- handler 返回 { proxy = "http://127.0.0.1:PORT" },内核每次请求都问一遍
  ⇒ 会话停止/过期后插件不再给目标,旧地址自然失效(撤销点唯一)
- 鉴权与角色门禁复用现有链路,无旁路
- statusRecorder 增加 Unwrap(),让 Hijack 穿透访问日志中间件(否则 WebSocket 升级失败)
- 目标只允许带显式端口的**字面回环 IP**(用户拍板方案 A):拒绝 https、localhost
  主机名、无端口、带 userinfo/path/query;理由是主机名可能被 hosts/DNS 改写
- 拒绝跨站 Origin(WebSocket 带环境 Cookie,否则第三方网页可借管理员会话开隧道)
- 不向插件后端转发 Authorization/Cookie/api_key

channels 接入:
- 新增 channels/browser/proxy/:session(prefix=true)
- 会话代号 = seq-随机8位,防猜到活跃会话
- view_url 改为网关同源路径,noVNC 的 WebSocket 走同前缀 /websockify
- 会话过期即回收浏览器栈并返回 404

判据(每条咬真实外部效应):
- 静态资源转发
- 真实 HTTP 101 升级 + 双向数据回传(只断言静态转发不够 —— 升级是白屏真考点)
- 网关凭据不泄漏(在服务端断言请求头)
- 非法目标全拒(公网/localhost/https/无端口/带 path、query、userinfo)
- 跨站 Origin 403
- 未鉴权 401、过期会话 404、非子路径 404

回归:go test -tags luajit ./... 9 包全绿
2026-10-15 15:02:04 +08:00
25a525be84 避免部署构建占满临时文件系统 2026-10-15 13:01:58 +08:00
ea8a9ac4c6 修复用量总计与渠道账本统计 2026-10-15 12:52:03 +08:00
8f73f77ec2 chore(main): 回流 —— VNC 兜底登录真正可用(放开 browser-stack 不需要的加固 + 修 3 个插件缺陷)
按纪律先在 release/v2.0.x 提交,再内容对齐回 main。
校验:git diff release/v2.0.x = 0 行。
2026-10-15 08:23:25 +08:00
c36a794aa8 chore(main): 回流 —— 第三批逐模块对照文档
按纪律先在 release 提交再内容对齐。校验:diff vs release = 0 行。
2026-10-14 20:15:08 +08:00
0f263f895a chore(main): 回流 —— 账号池落盘原子性判据
按纪律:先在 release/v2.0.x 提交,再内容对齐回 main。
校验:git diff release/v2.0.x = 0 行。
2026-10-14 20:12:10 +08:00
044ff1129b chore(main): 回流 release/v2.0.x —— 插件自装适配器三连(qoder/cline)+ 自动领取 covers_today + isFree 权威来源
按发布纪律回流。这四笔原本**误提交在 main** 上(我的操作失误):
纪律要求修复先在 release/v2.0.x 提交、再回流 main。已迁移:
① release 上 cherry-pick 这四笔(干净落地);
② main reset 回 9bec158(那四笔未推送,无远端影响);
③ 内容对齐回 main —— 等价于只捡拾修复、不带版本号。

内容:
- channels-qoder 适配器:10605(排队) 与 110(额度耗尽) 分开说,厂商码留在插件域
- channels-cline 适配器:解析英文句子里的等待时长(内核按额度耗尽冷却而非 30s 猛敲)
- auto_claim:按 covers_today 记账(Qoder 10:00 刷新前不得算已处理)+ 间隔 2 小时
- isFree:改为按平台声明权威来源(cline 用远端 free[]/命名约定,opencode 用 cost)

校验:git diff release/v2.0.x = 0 行;go build 通过。
2026-10-14 20:01:24 +08:00
9bec158d2e chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容
按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据:

1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。
   本项目版本号**只由 `git describe --tags` 推导**(Makefile VERSION),
   仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。
2. 逐项核对确认 **main 是 release 的内容严格子集**:
   - `main` 独有的**文件**:0 个;
   - `internal/lua/plugins.go` / `internal/core/kernel_actions.go` 等内核文件
     main 独有行数:0;
   - `deploy.sh` 那 14 行只是 emoji 清理(release 刻意去掉了 emoji);
   - `channels.lua` 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。
   - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在**
     (`git cherry` 逐条列出均为 `-`,即已合入)。
3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号",
   且不会丢任何 main 独有的修复。

本次带回 v2.0.12 的全部内容(37 个提交):
- 源串行 + 按模型节流(照 model-gate)
- 已失效模型存储 + 远端目录闸门
- 余额分档选号
- 模型能力元数据(models.dev,isFree 权威判定)
- **凭据续期修复**(此前恒报 ok:true 却一个请求都不发)
- 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正
- 内核级:出站授权持久化重启后不生效、`__llmsproxy_plugin_dir` ground truth

校验:`git diff release/v2.0.x` = 0 行;`go build -tags luajit ./...` 通过。
2026-10-14 16:52:58 +08:00
0004545190 fix(channels): Loomy 真实微信 appId/uuid 正则修正 —— 之前是我编的值
## 用真上游实测逼出来的两处错误
批准出站主机后逐个平台打真上游,Loomy 报「授权页未包含二维码 uuid」。
核对参考常量后发现:

1. **appId 是我编的**:我写 `wx16e5c2b8e0c0e1f2`,参考是
   `wx18d60be432287cf8`。上游不认这个 appid,返回的页面里自然没有二维码。
   同时补上微信要求的固定片段 `#wechat_redirect`。
2. **uuid 正则不是参考的那条**:我写的是 `uuid="..."` 形式,而真实微信页把
   uuid 内嵌在 `<img class="js_qrcode_img" src="/connect/qrcode/<uuid>"/>`,
   兜底路径是 `l/qrconnect?uuid=<uuid>`。照参考 extractLoomyWechatUuid 的两条
   正则改写(它还带一个 `^[A-Za-z0-9_\-=+/]{6,64}$` 的字符集校验)。
3. UA 逐字统一为参考的 WECHAT_UA(`Chrome/120.0.0.0`)。

## 教训(值得记)
「照参考逐行移植」这一条,我在这两处做成了「凭印象写个看起来像的」。
**判据是端到端打真上游**,不是测试通过 —— 单测用的是我自己编的夹具,
夹具与生产同源出错时测试照样绿。这次是真上游把它逼出来的。

## 判据
- 测试夹具改为**真实微信页面形态**(`<img ... src="/connect/qrcode/<uuid>">`):
  用别的形态(如 `var uuid = "..."`)测出来的正则不是生产要用的那条。
- 生产实测:Loomy 从真微信拿到二维码 uuid(`open.weixin.qq.com/connect/qrcode/<uuid>`)。
- 四个新平台真上游全部 pending 且返回有效授权入口:
  qoder→qoder.com(真实 PKCE challenge)、zcode→bigmodel.cn、
  loomy→open.weixin.qq.com(真实 uuid)、raccoon→xiaohuanxiong.com(本地 code)
- 全量 core 回归通过;内核对照 v2.0.2 diff 为空。

## 出站主机授权
channels 的 27 个声明主机已全部在设置页批准(8 个新增 + 陈旧条目自动清理),
落盘到 .kernel-perms.json,重启后保留。
2026-10-13 10:17:50 +08:00
1dda9e4477 feat(channels): Raccoon 扫码登录实现 —— 12 个平台零 manual
第四处也是最后一处「把没实现说成做不到」:此前标 manual,理由"要扫图片二维码"。

参考事实(raccoon-oauth.ts / raccoon-login-page.ts 注释):
  · `qrcode_code` 由**宿主侧本地随机生成**(16 字节 hex),服务端接受任意
    自造 code 并进入 pending —— 完全绕开收不到的自定义协议回调
  · 二维码只是一张公开页面 {apiBase}/login/mp?code=...&appname=...
  · 轮询 POST /api/web/auth/v1/login_with_qrcode_code {qrcode_code}
    → data.status ∈ pending / logging / canceled / success
  · **success 但缺 token 视为未完成**(否则产出空凭据并让流程卡死)
  · 参考明确:任何异常都降级为 pending(网络抖动/畸形响应/未知 status)
真正需要人工的只有短信路径(阿里云滑块 captcha_param + 手机号 AES 加密)——
那一条不做,但扫码这条完全自动化。

## 同时按参考修正的两处错误
- `base_url` 写成 raccoon.sensetime.com,实际是 **xiaohuanxiong.com**;
  推理路径是 `/api/web/llm/v2`(标准 OpenAI 兼容)
- 余额路径缺 `/api/web` 前缀,且未带 `X-Org-Code` / `X-Raccoon-Language` 头
  (照参考 raccoonHeaders)

## 判据
TestRaccoonQrPollBehavior(5 项):本地生成 32 位 hex code + 二维码 URL;
logging 保持 pending;success 且带 token 才完成;canceled 是终态失败;
**缺 token 的 success 必须 pending**(防空凭据卡死)。
全量 core 回归通过;内核对照 v2.0.2 diff 为空。

## 十二个平台现状(零 manual)
trae/minimax/cline/buddy/gemini/lobsterai/opencode/codearts 原有流程 +
qoder(device-poll) / zcode(init-poll) / loomy(wechat-poll) / raccoon(qr-poll)
2026-10-13 10:08:59 +08:00
04f12fae71 feat(channels): Qoder / ZCode / Loomy 从 manual 变为真正可用的自动化登录
审计 12 个平台时发现:四个标 flow="manual" 的平台里,**三个的登录协议完全可以在
Lua 里实现** —— 我把"没实现"写成了"做不到",理由全是错的。

## Qoder:WASM 是聊天加密,不是登录
错误理由:「登录要 WASM 签名头」。实际:WASM 用于 **/algo/ 加密推理端点**,
登录本身是标准 PKCE 设备码轮询(qoder-oauth.ts / qoder.ts):
  授权页 = {authBase}/device/selectAccounts?challenge&challenge_method=S256
           &nonce&machine_id&client_id(本地拼装,不打网络)
  轮询   = GET {openApiBase}/api/v1/deviceToken/poll?nonce&verifier&challenge_method=S256
三个必须照抄的细节(参考点名):
  · client_id 用 **prod 的**(非 prod 会在授权回调阶段被拒)
  · poll 挂 openApiBase(openapi.qoder.sh);authBase 返回 401
  · 404 = 尚无会话(**继续等**,判失败会让登录永远失败)

实现:新增 judge="pending404" 判据 + 既有的 device-poll 机制支持
`GET_QUERY`/`auth_query`(PKCE 授权页形态,无 device_code)。
顺带修 poll_login 的条件(原要求 `s.device_code`,PKCE 形态没有它 → 永远不推进)。

## ZCode:授权流不涉及 captcha
错误理由:「登录要解 captcha」。实际:captcha 属于**另一条已废弃的旁路**
(读本机官方客户端数据);本流注释明确写着「普通进程就能走完」:
  ① POST {origin}/api/v1/oauth/cli/init  Bearer <本地造的 flow_secret>
     → {flow_id, authorize_url, poll_interval_sec}
  ② 用户浏览器打开 authorize_url
  ③ GET {origin}/api/v1/oauth/cli/poll/{flow_id} → {code:0, data:{status,...}}
判据(参考点名):4xx(**除 408/429**)才是终态失败;code!=0 或 data 缺失 → pending;
token(zcode JWT) 与 user.user_id 必需,第三方 token 取 bigmodel/zai 二者之一。
另按参考修正 base_url/adapter(走 Anthropic 协议的 zcode.z.ai 免费额度通道)
并补余额端点。

## Loomy:扫码在手机上,协议在 HTTP 里
错误理由:「扫码在微信里完成,Lua 做不了」。实际:扫码动作确实在用户手机上,
但协议全是纯 HTTP:
  ① 取 uuid(授权页 HTML 里抽)② 二维码图 URL(前端渲染)
  ③ 长轮询 long.open.weixin.qq.com/connect/l/qrconnect
  ④ wx_code → 讯飞账号端点换凭据(HMAC-SHA1 签名)
判据(参考点名,早期版本**读反了**导致"扫码后卡在已扫码"):
  408=等待;404=已扫码待确认(**继续轮询**);405=**已确认**(wx_code 在此帧);
  403=取消;402=失效
签名:内核只有 sha256/hmac_sha256,没有 sha1/md5 —— 插件用 openssl 算
(HMAC-SHA1 与 Python 实测一致),**不改内核**。账号头前缀是 `account ak:sig`。

## 顺带修的真 bug
1. `loomyAuthHeaders` 调 `newToken()`,而它定义在文件后段 → Lua 词法作用域下是
   nil,调用即崩。改用内核全局 sha256_hex 造 nonce。
2. `declaredHosts()` 只看 hosts/base_url/checkin → 各 flow 的端点域名
   (open.weixin.qq.com / account.xfyun.cn / openapi.qoder.sh / zcode.z.ai …)
   不在出站白名单里,授权页与长轮询会被拦。已按 flow 补全推导。
3. `set_endpoints` 不支持 wechat —— 自动化测试无法把 Loomy 端点指到假上游
   (端点覆盖是参考明确的产品配置能力,也是测试缝)。已补 wechat/initpoll。
4. 两处 Lua 字符串引号错误(`"$(cat ...)"` 与 `"([^"]+)"` 提前结束字符串,
   插件编译失败)。插件加载测试抓到的。

## 判据
- TestQoderDevicePollBehavior(5 项):授权页含 PKCE 四要素与 prod client_id;
  404 保持 pending;轮询带 verifier/nonce;拿 token 即完成;池路径凭据落账号 ref
- TestZcodeInitPollBehavior(4 项):init 带会话密钥与 provider;pending 保持;
  ready 完成且凭据含 zcode_jwt + 第三方 token;池路径
- TestLoomyWechatPollBehavior(4 项):抽出 uuid 并给二维码;**408/404 都是
  pending**(404 非完成);405 才完成且账号端点用 account ak:sig 签名;403/402 失败
- 全量 core 回归通过;确认测试不写生产插件目录、无孤儿进程
- 内核对照 v2.0.2:diff 为空

## 剩余
Raccoon 仍为 manual(本地承载页 + SMS,含人工输入验证码),另做。
2026-10-13 10:02:54 +08:00
cf53d72af4 fix: 回退两处内核改动,channels 改为插件自持持久化(照参考实现)
用户指出「你又tm改内核?」—— 核对后确认两处本可避免:
  1. internal/lua/vm.go  +32 行  新增 base64_decode 全局函数
  2. internal/lua/plugin_http_handler.go +16 行  InvokeHandler 补 markDirty
工作区已还原到 v2.0.2(git diff v2.0.2 = 0 行)。

参考实现(dsh-our-free-model/vendor/channel-pack)对同一问题的做法:
- 解 JWT:用**语言自带**的 `Buffer.from(..., 'base64url')`,从不为此改宿主。
  → 我改成了插件的纯 Lua b64url_decode(约 20 行),实测解出真 JWT 的
    nickname/sub/exp 全对。
- 状态持久化:插件**自己 writeFileSync+renameSync** 写
  `channel-pack/state.json`,从不依赖宿主 settings。
  → 参考还记下了同构教训(Gitee IKI7WT):DSH 0.1.7 移除 settings.register()
    后账号列表退化为纯内存;正解是**插件探测宿主能力、自持文档**,不是要求
    宿主改。我的 markDirty 修法方向相反:把插件的数据安全外包给宿主某条
    分发路径的实现细节。

- `.channels-store.json`(tmp + rename 原子替换),只放**索引与配置**
  (账号条目含 credentialRef 而非凭据本身、模型黑名单、设置、事件日志)。
  凭据仍在 .secrets/(0600),两者分离:即便文档泄露也不含 token。
- 变更点同步落盘(不用「置标志+定时 flush」):本插件无 hooks、45 个端点
  各自变更状态,还有大量内部互调;挂在某个边界就必须保证**每条**路径都
  经过它,漏一条就是「操作成功但重启丢失」——正是本次要修的缺陷形态。
  状态实测 ~1.7KB,写入只发生在运维动作上,不在请求热路径。
- 黑名单写入收口到 setModelDisabled(写点曾有 5+ 处,散着写必漏)。
- 存量迁移:读内核遗留 `.channels.state.json`(v2.0.4 内核写的)→ 写自有
  文档 → **写成功才删**遗留(写失败还删=用一次迁移丢数据);自有存储已存在
  时也删遗留,否则内核 restore() 会在启动时用它覆盖。

1. **测试污染生产目录**(严重):插件 storeDir() 读 /etc/llmsproxy/config.yaml
   决定落盘位置,而既有测试都没隔离 → 一次全量测试把 26 个测试账号写进了
   **生产** .channels-store.json(含 fixture-a/example 等夹具 id)。
   prod 文件已删除(真实账号另有来源);kernelTestCore 现在隔离插件目录,
   且尊重调用方已设的值(多次「重启」需共用同一目录)。
2. model_set_disabled 改了写入口后仍引用已删除的局部变量(nil 索引崩溃)。

- TestChannelsStoreIsPluginOwned:状态写进**插件自己的**文件、不含凭据字段、
  新实例读回。变异(markDirty 空操作)→ FAIL。
- TestChannelsMigrationKeepsLegacyOnWriteFailure:**写失败必须保留遗留文件**。
  变异(无条件删)→ FAIL。(这条单独测:迁移成功后的删除有两条路径互相掩盖,
  只测「最终文件没了」区分不出「写失败也删了」。)
- TestChannelsMigratesLegacyKernelState:遗留账号迁入内存 + 自有存储写出 +
  遗留文件删除。
- 内核对照 v2.0.2:diff 为空。

go test ./internal/core/(78s)与 ./internal/lua/(63s)全绿;
全量测试后确认生产插件目录未被写入。
2026-10-13 09:34:13 +08:00
03430bcef5 fix(deploy): 内置插件的修复无法下发 + buddy 余额解析口径
## deploy.sh:内置插件修复本该覆盖
sync_plugins 原为「只补缺,已存在一律不动」。看着安全,实际造成功能性缺口:
**内置插件自身的修复永远下发不了**。v2.0.3/v2.0.4/v2.0.5 三个渠道修复就这样
全部卡在仓库里 —— 二进制换了、/etc/llmsproxy/plugins/channels.lua 还是旧版
(实测 sha256 不同、headers_fn 计数 0),生产行为毫无变化。我自己的验证还被
这个"静默不生效"骗过一轮:测试全绿、部署成功,线上仍是旧逻辑。

修法:与 sync_adapters 同纪律 —— **内置同名文件覆盖(带备份)**,
运行时新增/运维自加的非内置名字一律不动。内置源码是发行物的一部分,
部署必须与二进制内容对齐;"只补缺"想保护的是非内置文件,两者不是一回事。

判据:部署日志出现 `~ 更新内置插件: channels.lua`;部署后生产文件 sha256
与仓库一致。

## buddy 余额:双层嵌套 + 只累加有效包
余额端点响应是 **data.Response.Data.Accounts[]**(与签到端点的单层 data
不同),原实现按顶层找 totalCredit → 永远解析不出、回落 0 或报错。
且刻意**不用**服务端的 TotalDosage:那是终身口径且取整(参考实测同一响应
TotalDosage=655 而 IDE 显示 155.67)。正确算法是遍历 Accounts 现算:
只累加有效包的 CycleCapacityRemain(失效包服务端仍返回,并入会虚高),
按两位小数规整;双层缺失时如实报错,不把「查不到」当「余额为 0」。

判据 TestBuddyBalanceParseDoubleNested:100.5+55.17=155.67(失效的 500
不计入)+ 单层 data 判为失败。变异:改用 TotalDosage → 立即 FAIL(已实测)。

go test ./internal/core/ 与 ./internal/lua/ 全绿。
2026-10-13 08:05:24 +08:00
192e545d40 fix(channels): buddy 计费请求缺身份头 → 上游 401(逐行移植参考 checkinHeaders)
修好凭据读取后,余额端点仍报 HTTP 401。逐行核对参考 checkinHeaders
(credits.ts) 后发现我们只发了 Authorization,而 buddy 要求身份标识头齐全:

  X-Domain: copilot.tencent.com   (以产品配置为准,不用凭据里的 domain 快照)
  X-Product: SaaS                 (部署类型)
  X-Product-Code: codebuddy       (产品码)
  User-Agent: CodeBuddyIDE/1.106.1
  X-User-Id / X-Enterprise-Id / X-Tenant-Id(凭据携带时)

参考的注释特别警告 X-Domain 的方向:凭据里的 domain 是「登录时那个域名」的
快照,跨产品迁移后会指错区域,所以必须以产品端点为准。

## 实现
- 平台级声明 headers_fn(accessToken, cred) → 返回请求头表;buddy 据此逐行移植。
- fetchAccountJSON 增加头钩子位:spec 自己的 headers_fn 优先,否则回退平台级。
  未声明的平台仍走通用默认头(Bearer + JSON),不改变既有行为。
- set_endpoints 补齐 checkin_status / balance 端点覆盖(此前只覆盖旧的
  plat.checkin,buddy 这类平台无从改指,自动化测试也指不到假上游)。
  端点覆盖是参考明确的产品配置能力,也是测试缝。

## 判据
TestBuddyBillingHeadersMatchReference:把 buddy 余额端点指到本地假上游,
断言四个必备身份头逐字段正确。变异:改名 headers_fn → 立即 FAIL(已实测)。

go test ./internal/core/ 与 ./internal/lua/ 全绿。
2026-10-13 07:57:56 +08:00
de9d7c8008 fix(lua): InvokeHandler 从不 markDirty —— 无 hooks 的插件状态永不落盘
## 现象
channels 插件重启后账号池静默清空:status 返回 accounts={}、pool=null、log=0 条,
磁盘上 `.channels.state.json` 从未存在过(billing/quota 的 .state.json 都在)。
用户侧表现为「登录了,但重启一次账号就没了」。

## 根因
插件状态持久化有三条分发路径,其中两条会 markDirty:
  - hook 路径(invoke)        → markDirtyLocked ✓
  - 导出函数路径(Call)       → markDirtyLocked ✓
  - HTTP handler 路径(InvokeHandler) → **从不 markDirty** ✗

channels 声明 `hooks = {}`,它的一切状态变更(账号入池、凭据引用、模型黑名单、
事件日志)都只经过自己的 HTTP 路由。于是状态从未被标记为脏,
`.channels.state.json` 从未写出,重启即全部丢失。

billing/quota 侥幸没踩到:billing 有 hooks,quota 走 Call。

## 修复
InvokeHandler 在成功返回前 markDirtyLocked(p),并在 handler 抛错的早退分支
也标记(抛错前可能已改过状态,那次改动仍然是真实的)。与 Call / hook 路径同模式,
只碰 stateSaver 自己的锁,不重入 p.mu(调用方已持锁)。

## 判据
internal/lua/persist_test.go::TestInvokeHandlerIsPersisted
用「无 hooks、只走 HTTP handler」的插件复现 channels 形态:两次 handler 调用后
Close()(等最后一次 flush),断言 .state.json 存在且含 2 个账号。
变异:撤掉 markDirtyLocked → 测试立即 FAIL(已实测)。

go test ./internal/lua/ 与 ./internal/core/ 全绿。
2026-10-13 07:47:55 +08:00
8a3059740f fix(channels): 登录后账号拿不到凭据 —— 完成路径不传 account_id + 只存裸 token
用户报障:「登录了,但插件这边无法获取账号信息」。实证:
  account_list → has_credential=false,credits/balances → 「无凭据」,
  而 UI 上账号显示已登录。

## 根因一:完成路径没把凭据写进池账号的键
UI 的真实流程是「account/add 建占位账号(credentialRef=channels:<plat>:<aid>)
→ login 带 account_id → 轮询 login/status」。但 poll_login 的两条完成分支
(device-poll / state-poll)调用 complete_login 时**没有传 account_id**,
于是走了兼容旧路径,凭据落到 `channels:<plat>` 这个与占位账号无关的键;
池账号的 credentialRef 指向 `channels:<plat>:<aid>`,从未写入
→ account_list 的 has_credential 恒为 false。

修:两条分支都传 `account_id = s.account_id`(对齐参考 BuddyAuth.login 的
`flowOptions.accountId` 语义:多账号场景下凭据按账号 ref 写入)。

## 根因二:只存裸 access_token,丢掉 refresh_token 与过期信息
state-poll 分支只取 `data.accessToken` 当凭据存,而参考
`runBuddyLoginFlow` 存的是 `buildCredential(token, account)` 的**完整 JSON**
(access_token + refresh_token + expires_at + refresh_expires_at + token_type
+ scope + domain + user_id + nickname + enterprise_id + account_type)。
只存裸 token 的后果:refreshable 恒为 false → 无静默续期;expires_at 未知。

修:新增 buildCredential(逐字段对齐参考 buddy.ts)+ jwtClaim/absExpiryMs
(参照实现:token 端点只回 expiresIn 不回 expiresAt,昵称只在 JWT 声明里)
+ credentialExpiresAtMs。state-poll 完成时存完整凭据 JSON 并回报
expires_at/refreshable。

## 配套
- 新增 credentialOf(ref):凭据取值助手,**裸 token 与完整 JSON 两种形态都认**
  (历史数据/device-poll/paste 是裸 token,buddy 是 JSON)。所有消费点
  (上游源 api_key、Bearer 头、HMAC 签名、备份)改走它,无须关心存储形态。
- complete_login 从凭据自动补齐 nickname/expiresAt/refreshable,调用方漏传也不丢。
- 内核新增 base64_decode(标准/base64url/无填充都认):插件读 JWT payload
  必需,否则要在 Lua 里重写 base64。通用能力,无厂商知识。

## 判据
TestBuddyStatePollBehavior 行2/行4:
- 行2 断言凭据是完整 JSON(含 refresh_token + 昵称),非裸 token
- 行4 回归用户报障:走 account/add → 带 account_id 登录,断言
  ① 凭据落在 channels:buddy:<aid> ② account_list 报 has_credential=true
  ③ refreshable=true
变异验证:去掉 account_id → 行4 立即 FAIL(已实测)。

go test ./internal/core/ 与 ./internal/lua/ 全绿。
2026-10-13 07:41:56 +08:00
028e42b036 fix(plugins): 出站主机授权两处真缺陷 —— 已启动 worker 看不到新授权、授权不落盘
生产实证:channels 插件的 auth/state 在主机已授权后仍被拒
(auth/state 失败: host "copilot.tencent.com" is not an allowed outbound destination),
而 /api/plugins/channels/kernel 明明显示 hosts_granted=26。

缺陷一(授权对已启动 worker 无效):
  httpAllowlist 是不可变快照;bootPluginState 在 worker 启动时把指针绑进 Lua
  闭包,闭包与 worker 同寿。GrantExtraHosts 之后 ps.allowHTTP 被**整个换新**
  (newHTTPAllowlist 返回新实例),已启动的 worker 仍持旧指针 → 授权接口返回
  ok、设置页显示已批,请求却继续被拒。分片 VM(plugin_shard.go)同样中招。
  修法:allowlist 改为带 RWMutex 的**可变共享对象**,GrantExtraHosts 与
  rebuildOutboundAllowlistLocked 原地 add —— 所有闭包立即看到新授权,worker
  无需重建。rebuild 从"替换实例"改为"原地折叠",并在注释里写明为什么。

缺陷二(授权不持久化):
  .kernel-perms.json 只写 actions;hosts 授权只存在运行态 map,重启即失。
  设置页看不出异常(actions 有持久化,读起来像"都持久化了")。
  修法:文件行改为 jsonPluginPolicy{actions,all_read,hosts}(旧文件向后兼容,
  纯 actions 行照常解析);Load 恢复 extraHosts;Save 把每个插件的 host 授权
  与 actions 一起写入(支持"只批了主机、没批动作"的行)。

回归测试(internal/lua/plugin_http_test.go):
- TestGrantExtraHostsVisibleToAlreadyBootedWorker:按事故顺序构造(先启动
  worker → 再授权),断言同一批 worker 立即可用新授权 + 旧引用不再失效。
- TestHostGrantsPersistAcrossRestart:授权落盘 + 新 registry 读回 + 插件重载
  后授权保留。

go test ./internal/lua/ 全绿;build/vet 通过。
2026-10-13 06:57:28 +08:00
84c9066bed fix(plugins): 新内置插件要能落地 —— SeedBundled 改为按文件补齐,deploy.sh 补插件同步
真实事故:channels.lua(多平台账号接入插件)在仓库里、在 2.0 二进制里
(go:embed),生产却完全看不到它 —— /api/plugins 只有 billing/quota/usage。

根因是两处叠加:

1. SeedBundled 只在 plugin_dir **不存在**时才写内置插件。生产 plugin_dir 从
   首次安装起就存在,于是后来加入二进制的新插件永远不会落盘。目录存在不等于
   内容是最新的 —— 这个假设把"新增内置插件"整条路径堵死了。
   改为**按文件补齐**:只在该文件缺失时才 seed,已存在的一律不动(运维自己改
   过、或运行时新增的插件必须跨升级存活 —— 与适配器同一条规则)。

2. deploy.sh 完全不处理 plugins(只同步 adapters/)。即使内核修好了,部署脚本
   也没有把插件内容与仓库对齐的动作。新增 sync_plugins:备份、只补缺、对已有
   文件零改动、末尾逐个校验内嵌插件都在目标目录,摘要里打印插件目录。

测试(internal/lua/vm_test.go):
- TestSeedBundledFillsMissingFilesInExistingDir:构造确切事故条件(目录已存在、
  只有部分插件),断言缺失的被补齐、运维自己的 billing.lua 不被覆盖、补齐后
  channels 确实出现在插件表里。
- TestSeedBundledIdempotent:二次 seed 不覆盖已就位文件。

deploy.sh 通过 bash -n;go test ./internal/lua/ 全绿。
2026-10-12 19:07:17 +08:00
63549db832 Merge feature/unified-wire-v2: 统一线格式 v2 + 适配器接管双向 + 冲突裁决
R1–R6 全部落地:最大集合统一格式、对外序列化移交适配器(serialize_*)、
入向解析移交适配器(adapter.ingress + deserialize_request)、插件适配器桥接、
插件长期运行环境、适配器导入路径校验、插件与服务路径冲突裁决。
v2.0.0
2026-10-12 17:09:32 +08:00
d6d41209d9 test(adapters): 逐适配器两向能力断言 + AdapterIngress 访问器
用户要求:16 个适配器逐个验证两个方向(出向序列化 / 入向解析)重构后都没坏。

- 新增 VM.AdapterIngress(name):暴露适配器认领的入站路径,供运维面与测试查看。
- 新增 TestEveryBundledAdapterTwoDirections:对每个内置适配器断言
  1) 上游向 transform_request 存在且可调用;
  2) 出向 — 声明了 serialize_response 的必须可调用且产出合法 JSON/SSE,
     未声明的确认走内核 OpenAI 兜底(两种都合法);
  3) 入向 — 声明了 adapter.ingress 的必须同时实现 deserialize_request
     (否则内核会让出给空解析器),实现了钩子却没声明 ingress 的也报错
     (内核永远不会调用它)。
  12 个内置适配器全部通过。

隔离实例实测(19 源 + 16 适配器):
- 启动无解密失败/崩溃,16 适配器全加载。
- 入向让出决定性实证:装入认领 /v1/messages 的 zzclaimer 并移除 anthropic 后,
  畸形 body 被适配器解析成 model=ZZ-CLAIMER-WAS-HERE,内核据此路由得
  404 model_not_found —— 证明内核确实让出(活实例复现,非仅单测)。
- 真实请求两向贯通:deepseek-v4-flash / sensenova-6.8-flash-lite 返回 200 真实响应;
  其余源返回上游 502(配额/渠道),说明请求正确到达了各适配器的上游。
2026-10-12 16:37:23 +08:00
3c59c33c58 feat(adapters): 入向端点移交适配器 —— adapter.ingress + deserialize_request,内核主动让出
plan.md 的 R2 目标里有三行钩子契约,出向两条(serialize_response/serialize_chunk)
Phase 2 已落地,第三条 parse_request(protocol_body) -> unified 一直空着:
/v1/messages 的入向解析硬编码在内核 Go 里(anthropicRequest.toUnified),
Anthropic 改协议就得改内核。本次补齐这条对偶。

## 机制(与出向序列化对称)

- 适配器声明 adapter.ingress = "/v1/messages" 认领入站路径,并实现
  deserialize_request(raw_body) -> unified_json。
- 内核 handleMessages 经新增的 parseViaIngress 判定:
  - 有适配器认领该路径且实现了钩子 → 让出,解析由适配器完成;
  - 无适配器认领 → 内核内置解析运行(R5 兜底,未配置部署仍可服务)。
- 认领了但钩子执行失败 → 硬错误 400,不静默换用另一种协议解析
  (客户端说的正是该适配器声称支持的协议,悄悄换解析器只会错误调度)。
- 这是内核里唯一的新判定点,与 serializerFor 对称:出向选序列化器,入向选解析器。

## 内核改动

- internal/lua/vm.go:staticInfo 增 ingress 字段;inspectScript 读取;
  新增 VM.AdapterForIngress(path)(确定性选择:同名多认领取字典序最小)。
- internal/gateway/messages.go:handleMessages 改为先读 body → parseViaIngress
  (让出)→ 未认领时回落内置 anthropicRequest.toUnified。
  内置解析与 anthropicRequest 类型保留——它们是兜底,不是死代码。

## 适配器改动

- internal/lua/adapters/anthropic.lua:声明 adapter.ingress = "/v1/messages",
  新增 deserialize_request,把内核原 Go 解析逻辑逐字段移植(system 两种拼写、
  content 原样透传、缺省 thinking={enabled:false}、缺 messages 报错)。
  内置 anthropic 默认随包播种,因此真实部署里内核总是让出给它,
  Go 侧 anthropicRequest.toUnified 退化为纯兜底。

## 测试

- gateway:TestMessagesIngressYieldsToBundledAnthropic(让出后仍 200)、
  TestAdapterForIngressClaim(声明可被找到、未声明路径为空、钩子存在)、
  TestMessagesKernelFallbackWhenNoAdapterClaims(移除认领适配器后内核兜底仍 200)。
- lua:TestAnthropicDeserializeRequestShape 逐字段核对 Lua 与 Go 产出一致
  (system 拼接、content、max_tokens/stream/temperature/top_p/top_k/stop_sequences、
  缺省 thinking),TestAnthropicDeserializeRejectsMissingMessages。
- 全量回归:gateway/lua/core/config/types/provider/scheduler/e2e 全绿。
- 文档:docs/lua-adapters.md 补「入向让出钩子」章节 + plan.md 台账更新。
2026-10-12 16:32:30 +08:00
5948ad19ca feat(plugins): 服务路径/适配器名冲突进裁决而非崩溃或硬拒绝(用户要求 A+B)
此前两类冲突各有问题:
A) 两个插件声明同一条 /api/plugins/http/ 服务路径 → 分发器按加载顺序静默
   选一个(装个新插件就可能改变既有路径归属)。
B) 插件装与内置/他插件同名的适配器 → 硬错误 ErrAdapterNameReserved / owned by。

用户要求:都改成「报冲突、列出候选、运维选一个保留、选择持久化」,绝不崩溃
或拒绝启动。

## A 类:插件 HTTP 服务路径冲突
- dispatch(plugin_http_routes.go)同路径多认领时不再 load-order 选边:未裁决
  返回 409 并点名候选;已裁决按赢家分发。
- route_conflicts.go:RouteConflicts/RouteWinner/ResolveRouteConflict,只统计
  loaded 且未禁用的插件;赢家插件若不再声明该路径,裁决作废回未裁决态。

## B 类:适配器名冲突
- InstallAdapter 撞名(内置或他插件所有)不再硬拒,改挂起:现任继续服务,候选
  入 AdapterConflicts,返回 typed ErrAdapterConflictPending(区分「代码错」)。
- ResolveAdapterConflict:incumbent 保留现任 / candidate 真正装上插件版(绕过
  reserved 门禁——这是运维显式授权覆盖内置的唯一入口)并转移归属。
- 现任内置永不被静默覆盖,守住「第三方插件不得篡改内核发行物」信任边界。

## 持久化 + WebAPI + WebUI
- 两张裁决表落 .route-conflicts.json(routes + adapters),LoadDir 时读回;
  重启后插件再装同名适配器自动套用上次裁决。
- WebAPI:GET /api/plugins/conflicts 列冲突;POST {kind,path|name,winner} 裁决。
- WebUI:插件页顶部「冲突裁决」卡片,逐条列路径/适配器冲突 + 候选 + 裁决按钮。

## 测试
- 单测 conflict_arbitration_test.go 6 项全过:路径冲突检测/裁决/持久化/重启、
  赢家消失回退、适配器撞名挂起/incumbent/candidate/归属转移、非法 winner 拒绝、
  自身更新不受影响(token 轮换不走冲突路径)。
- e2e conflict_arbitration_e2e_test.go 2 项全过(真二进制):两插件抢路径→409
  点名→裁决→200 赢家应答→/v1/models 无回归;插件装 shadow openai→挂起→现任
  保留→裁决 candidate→插件版生效→冲突清空。
- 全量 build + vet 通过,既有 lua/gateway/core 回归无破坏。

修正过程中发现并修了一个真 bug:LoadAdapterSource 对 key≠declared 的适配器
重装会建第二个池、留旧池继续服务(v2 装了 v1 还在跑)——改成统一按注册键
归档,裁决 candidate 后新版本才真正生效。
2026-10-12 12:32:10 +08:00
6b6f9526ff feat(channels): 对外功能面全量对齐参考 channel-pack RPC + 逐平台余额/签到状态端点
此前只实现了参考 RPC 面的一部分(账号池/模型开关/供应商/备份)。本次按
用户要求把对外呈现的功能补齐到与参考 channel-pack-rpc.ts 一致,并逐平台
移植真实余额/签到状态端点(不再'选一部分')。

## 新增 RPC(对齐 channel-pack-rpc.ts 的 case 清单)

credits.status   逐账号签到状态(平台声明 checkin_status 时查,status_fn 解析;
                 无端点平台如实 null,与参考对 CodeArts/LobsterAI 处理一致)
credits.balances 逐账号余额(平台声明 balance 时查,total_fn 解析;无端点/无凭据
                 返回 {balance=null,error=...} 不编造数字 —— 对齐参考'不伪装余额')
credits.permanentLock  永久积分锁定读写(buddy/loomy 等)
account.reset/retest   清限流标记 / 重测(支持 __all__ 批量)
model.setDisabledMany  批量模型开关
model.clearDead        清黑名单里已下线的死模型(只清不在 models 表的 id)
provider.getOrder/setOrder  供应商显示顺序(bogus id 过滤)
gateway.getEnabled/setEnabled  网关整体开关(复用 provider_set_enabled 语义)
backup.status          备份健康度(账号数/无过期时间账号数)
usage.tokenLedger      本网关 token 用量快照(数据源 /api/stats,与 usage 插件同思路)
usage.badge            渠道徽标(credits.balances 汇总)
usage.badgePreference  徽标偏好读写(非法值拒绝不回落,对齐参考注释)
onboarding.status/claim 新人任务(raccoon 桌面登录奖励 3000 分实测端点)

## 逐平台真实余额/签到端点(照各 *-credits.ts 逐字移植)

minimax  signin/status 预检 + credit/details 余额(total=Σ remaining_amount,
         避开参考踩过的 total_count 误读坑)
trae     checkin_credits/status + ide_user_ent_usage(remain=Σ(limit-amount))
buddy    checkin-activity-status(不是 checkin-status 占位坑)+ get-user-resource
cline    usage-limits(dollars,取剩余百分比最小的限额)
lobsterai profile-summary;qoder me/usage(userQuota+addOnQuota);raccoon points/v1/balance
每平台 CHANNEL_CREDITS 能力表声明 unit/claim(对齐参考同名表)。

## WebUI 补齐

账号行展示逐账号余额(credits.balances);详情动作区加「重置限流」「清死模型」;
顶栏加「Token 用量」弹窗(usage.tokenLedger)。node --check 过。

## 测试

TestChannelsExtendedRPCBehavior 10 子项全过:balances 无凭据报错不编造、status
无端点 null、reset、setDisabledMany、clearDead 只清死模型、provider order 过滤、
backup.status、badgePreference 非法拒绝、onboarding supported=false、permanentLock 读写。
全量 8 包回归通过。

已知未移植(如实说明,均需本机无法提供的运行时):captcha.*(zcode 验证码载体,
需浏览器端求解器)、login.sendSms/submitSms(loomy 短信,备用路径)、
cline.requestLog/usage.tokenLedgerHistory(历史聚合,内核未落盘日维度表)。
2026-10-12 08:45:49 +08:00
4d0efab86b feat(channels): 渠道控制台对齐参考 ChannelCard —— 账号池/模型开关/供应商治理/备份 + 全量 UI
用户要求与示例插件 dsh-our-free-model 的 WebUI 全面对齐。参考前端的
核心是 ChannelCard(client.js 3627 行起):一个渠道可挂**多账号池**,
账号可逐个启用/停用/测试/删除/排序,模型可逐个开关,供应商整体开关,
账号备份导出/导入,自动签到开关。此前是单凭据 + 平表登录,差距大。

## 后端(channels.lua 新增 19 个 RPC,对齐参考 channel-pack 契约)

账号池(对齐参考 account.*):
  account_add/list/update/delete/test/refresh/reorder —— 一平台多账号,
  每账号一条上游源 channels-<plat>-<aid>,凭据走保险库(0600 加密落盘)。
  complete_login 带 account_id 时走池路径回填凭据。
模型开关(对齐 model.*):
  model_list/set_disabled/set_all_disabled —— 黑名单存 state,开关即重同步
  该平台所有账号源(关掉模型=该账号源里去掉该模型)。
供应商治理(对齐 provider.*):
  provider_status(账号数/模型数/closed 态)+ provider_set_enabled
  (关闭=全关模型+停用全部账号;打开=清黑名单+启用全部账号)。
一键领取(对齐 credits.claimAll):
  credits_claim_all —— 逐账号 claim_daily 收集结果,返回 {claimed, results}。
备份(对齐 backup.*):
  backup_export/import —— 账号池+凭据+模型黑名单+自动签到开关打包,导入
  恢复凭据(走保险库)+重建全部账号源。
自动签到开关(对齐 usage.autoCheckin):
  auto_checkin —— 读写 state.settings.auto_checkin,关闭时 auto_claim 跳过。
auto_claim 升级为账号池驱动(逐账号 claim + 尊重 CHANNEL_CREDITS 能力表)。

**修一个真 bug**:__llmsproxy_secrets.set 返回 (true, nil) 成功 / (nil, err) 失败,
我多值赋给单变量拿到第一个返回值,误判为失败 → 改为 local _, serr = set(...)。

## WebUI(整个重写,对齐 ChannelCard 交互)

渠道网格(搜索/筛选全部·已接入·待接入/汇总条已接入N·账号总数)
→ 渠道详情(账号池列表:昵称/启用态/上次领取摘要 + 测试/刷新/停用/优先/删除;
模型折叠:逐个开关 + 全部启用/关闭 + 计数;添加账号=登录(占位账号先入池,
login 完成回填凭据,两步式对齐参考 account.create);一键领取额度(结果
摘要展示成功数+失败原因);供应商整体开关;自动签到开关;导出/导入备份)。
登录区仍覆盖 device-poll/callback/paste/manual/vnc 全部流程。
node --check 过内联 JS;凭据只经 complete_login 落保险库,不回显。

## 行为测试(channels_pool_test.go)

TestChannelsAccountPoolBehavior 5 个子测试全过:
  账号增删改(含返回 account_id、改名、删空)
  模型开关同步上游源(3模型→关1→2→全关→closed=true→全开恢复)
  enabled开关同步上游源(停用删源、启用重建源)
  供应商整体开关(关→无源+closed;开→源重建)
  备份导出含凭据明文

## 已知边界(如实说明,未假装对齐)

- 余额逐账号展示:参考有 credits.balances(真实查上游余额),本环境各平台余额
  端点形状差异大且多数需真实凭据,未编造假余额;账号行只显示上次领取摘要。
- usage.tokenLedger / cline.requestLog(用量账本/请求日志页):参考有,本插件
  内核侧已有 usage 记录但未暴露给插件,暂缺这两页(其余控制台能力已对齐)。

回归:全量 8 包(core/lua/gateway/config/types/scheduler/provider/e2e)通过。
2026-10-12 08:18:33 +08:00
b6e687b567 test(e2e): 补齐 Cline/paste/manual 三条流程真二进制端到端
TestChannelsFlowsE2E 用真二进制 + 假平台(loopback 白名单天然放行)跑通:

  ① Cline(WorkOS device-poll 两步)
     假上游:device code → authenticate(首拍 authorization_pending,次拍
     access/refresh) → register 换 CLINE-E2E-AT。
     断言:首拍会话仍 pending;次拍 completed 且 account = register
     userInfo.email(dev@example.com);register 恰好调用一次(防重复换证);
     /v1/models 出现 stealth/space-bunny-alpha(凭据已是 register 的最终
     token,不是 WorkOS token —— 这是 deviceRegister 第二跳的价值)。

  ② paste(opencode)
     start_login 会话 flow=paste → complete_login 贴 OC-E2E-KEY →
     /v1/models 出现 big-pickle。验证 WebUI 粘贴路径(chComplete)能落保险库。

  ③ manual(qoder)
     start_login 返回 manual_hint 含 'WASM'(明确说明不可自动而非假装
     成功)→ complete_login 贴 QD-E2E-KEY → /v1/models 出现 qoder-default。

三条流程统一终局标准:/v1/models 出现该平台模型 = 接入源成功且可路由。
e2e 全量 10 项 PASS。
2026-10-12 07:20:48 +08:00
2353d43c16 feat(channels): 全量厂商接入 —— Cline/Buddy/Gemini/LobsterAI/OpenCode/CodeArts/Qoder/ZCode/Loomy/Raccoon + 新流程 + WebUI 补齐
新增 10 个真实厂商平台(端点/模型/流程逐字照抄参考 channel-pack):

device-poll(已有执行器):
  minimax  保留(MiniMax 形态:看 status 字段,slow_down +5s)
  cline    WorkOS 设备码(判据看 error 字段、HTTP 可非 2xx;slow_down +1s;
           access_denied/invalid_grant 同组失败;成功后 register 第二跳
           /api/v1/auth/register 换 Cline token)。新增 deviceRegister。

state-poll(新流程):
  buddy    CodeBuddy(腾讯)。POST /v2/plugin/auth/state → authUrl+state;
           轮询 GET /v2/plugin/auth/token?state=,业务码 11217 = 未就绪继续等
           (对齐参考 loopGetToken:网络错误同样继续不中断);token 就绪后
           拿账号(失败不阻塞)。

callback:
  gemini   标准 OAuth code 交换(oauth2.googleapis.com/token,六 scope 照抄);
           oauth_callback 增 code→token 交换分支(Gemini/LobsterAI 复用)。
  lobsterai 本地回调 + authCode 换 token(lobsterai-server.youdao.com)。
  trae     保留(token 直传 + ExchangeToken,已 8938574)。

paste:
  opencode 直接贴 API key(无登录流)。
  codearts 企业凭据 + SDK-HMAC-SHA256 签到(claim_fn 钩子,canonical request
           + 派生密钥链 kDate→kNonce→kSigning→signature)。

manual(后端给 manual_hint,明确说明而非假装成功):
  qoder    WASM 签名头(纯 Lua 无法生成)
  zcode    captcha 求解
  loomy    微信扫码
  raccoon  图片二维码

WebUI(之前只覆盖 vnc/url+轮询/device-poll):
  chLogin 增 manual / paste 两个分支:manual_hint 展示 + 凭据粘贴框 +
           提交按钮。新增 chComplete 调 channels/login/complete 落保险库,
           凭据不回显、失败给可读错误不重载页面。node --check 过。

set_endpoints 增 register/state 覆盖(运维能力 + 测试缝,与 device/checkin 同语义)。

行为测试(照参考逐行):
  TestClineWorkOSBehavior  5 行:非200 authorization_pending 继续等 /
    slow_down 退避 1s(interval=2)/ access_denied 失败 / 成功 register 凭据
    是 CLINE-AT-1(不是 WorkOS token)/ 非200无 error 失败。
  TestBuddyStatePollBehavior 3 行:state 申请给 authUrl / 首拍 11217 pending
    次拍 token completed / account 查询失败不阻塞回退默认名。

变异测试修复(deviceOneStep 拆 workos/minimax 两分支后幸存体暴露):
  锚点 +step / authorization_pending 现两处同形,strings.Replace(,,1) 只改
  workos 分支、minimax 决策表没杀到 → 改 ReplaceAll 全改;删冗余第 6 变异体。
  5 变异体全杀。回归:channels/device/cline/buddy/trae/legacy/保险库/e2e 全绿。
2026-10-12 00:28:26 +08:00
893857464d feat(channels): TRAE callback 真实移植 —— token 直传 + ExchangeToken(照参考逐行)
此前 oauth_callback 是通用 OAuth 形态(直接拿 code 当凭据),与 TRAE 真实
行为不符。参考实现 trae-oauth.ts 的回调是 **token 直传**,本次照其行为移植:

parseTraeCallbackDetailed(决策表 5 行):
  行1 refreshToken 直传(或 userJwt.RefreshToken 回退)→ ExchangeToken 换新
      access(请求体 ClientID/RefreshToken/ClientSecret:'-'/UserID 照抄,
      响应取 Token)→ 凭据 = 新 access
  行2 无 refreshToken 但有 userJwt.Token → 直接兜底,**不打** ExchangeToken
  行3 只有 code/authCodeInfo → PKCE 流,明确报"暂不支持"而非含糊失败
      (参考注释强调:判为无效会让用户往错误方向排查)
  行4 全空 → 报"回调未携带 refreshToken / userJwt.Token / code"
  行5 ExchangeToken HTTP 失败 → 错误指明 ExchangeToken 阶段

配套:
- PLATFORMS.trae 增加 callback = {mode="trae", exchange_url, exchange_body};
- oauth_callback 按平台 callback.mode 分派(nil→code 旧路径不变,googleish 不受影响);
- traeCallback/jsonField 新函数;set_endpoints 支持 callback.exchange_url 覆盖
  (既是运维能力也是测试缝,与 device 端点同语义);
- TestTraeCallbackBehavior:5 行决策表 + 行1 断言凭据来自假上游新 Token
  (防"把回调原值当凭据")、行2 断言 ExchangeToken 零调用(防多余上游请求)。

回归:channels/trae/device 变异/压力/legacy/保险库/e2e 全绿。
2026-10-11 22:35:18 +08:00
2f5cde7fea test(e2e): channels device-poll 真二进制端到端 + 修正插件 HTTP 前缀
TestChannelsDevicePollE2E 用真二进制跑通完整链路(对齐参考插件形态的终局验收):

  假平台上游(设备码/token/模型/对话端点)
  → 真网关进程 + plugin_dir(SeedBundled 落地 channels)
  → PUT /api/plugins/channels/kernel 授权(运维批准动作面)
  → call/set_endpoints 把 minimax 端点指到假上游(自建代理能力复用)
  → POST /api/plugins/http/channels/login 发起 device-poll
  → GET .../login/status 轮询:先 pending(authorization_pending),
    后 completed(token 下发)且 account 取 nickname
  → 终局证据:/v1/models 出现 channels-minimax 的模型 MiniMax-M3

调试中抓到并修复:channels.lua 三处旧挂载前缀 /api/plugins/channels/http/
(正确为 /api/plugins/http/,spawnBrowser 回调路径、callback redirect_uri、
UI fetch 基址)—— UI 上点登录/回调一直会 404。

顺带:e2e 就绪探针复用 startGateway(无源时 /v1/models 空列表也 200)。
2026-10-11 21:44:40 +08:00
da0282a3db test: 四类验收 —— 加密解密 / 变异 / 压力 / 旧通路
用户要求的四类测试全部落地,且每类都抓到了真问题:

## 0) 测试暴露的真实缺陷(先修后测)

**保险库明文落盘**(加密解密测试的目标缺陷):.secrets/<plugin>.json 此前
是明文 JSON。接入 SecretBox(与 config.yaml 同一把 master key、同 enc:v1
形态):SetSecretBox 注入(secretSealer 接口,测试可桩);写盘逐值加密、
读盘逐值解密;无前缀旧值原样通过 —— 旧明文库可读、下次写整体重封。
core 注入点在 store 创建之后(NewPlugins 时 box 还不存在 —— 顺序 load-bearing,
修掉了一个 nil-deref)。

## 1) 加密解密(internal/lua/secrets_crypto_test.go)
- EncryptedAtRest:磁盘字节不含明文 token、带 enc:v1、文件 0600/目录 0700
- PlaintextFallbackAndUpgrade:旧明文库可读 + 任意一次写整体重封为密文
- WrongMasterKey:换 key 后读库必须报错(静默空会让插件重登覆盖凭据)
- ConcurrentPlugins:8 插件×2 goroutine 并发读写,无串台/丢写/文件数正确

## 2) 变异(internal/core/device_mutate_test.go + device_harness_test.go)
- 决策表抽成 deviceHarness.runDecisionTable(行为测试与变异测试共用,
  单一事实源)
- 5 个语义变异逐个杀:slow_down 不加 interval / denied 不识别 /
  expired_token 不识别 / authorization_pending 当失败 / 成功分支禁用
- 行3/行4 断言 message 区分(用户拒绝/过期)—— 初版只断 state=failed 时
  两个变异体幸存(落进通用 failed 分支),加 message 后全杀。
  这正是变异测试的价值:暴露断言太弱。
- 基准(未变异)必须全过 —— 防止测试自身失效的假阳性

## 3) 压力(internal/core/channels_stress_test.go)
- ConcurrentLogins:12 轮并发 device-poll,会话互不串台、全部终态
- ClaimStress:30 并发领签到,上游收到恰 30 次(无登录态丢失)

## 4) 旧通路(channels_stress_test.go 后半 + 复用 lua 包测试)
- LegacyVaultPlaintextLoad:老版本明文库 → 新版本可读 → 下次写重封
- LegacySourceSealedConfig:明文 api_key 的旧 config.yaml → 运行期解密、
  落盘 seal、master.key 0600 生成

全量 lua/core/gateway/types/scheduler/provider/config 测试通过;build+vet 绿。
2026-10-11 20:50:52 +08:00
6f2b6b8d3d feat(channels): device-poll 通用执行器 + 行为对齐测试(照参考实现逐行移植)
按用户要求:不是从头造,而是移植参考实现(minimax-oauth.ts)的行为,
并照对方代码的行为形成测试项。

新增(channels.lua):
- b64url/hexToBin/devicePkce:PKCE S256(对齐 createMinimaxPkce)
- deviceOneStep:轮询决策单步执行器,分支顺序逐行对齐 pollMinimaxDeviceToken:
    200+status=pending        → 等
    200+status=slow_down      → interval+5s,等
    200+status=denied/access_denied → failed(用户拒绝)
    200+status=expired/expired_token → failed(码过期)
    非200+error=authorization_pending → 等(OAuth 标准形态)
    非200+error=slow_down     → interval+5s,等
    200+access_token          → 成功
    其它                      → failed(带 error 文本)
- start_login device-poll 分支:申请设备码(PKCE)、user_code/授权URL透传、
  verifier 只存会话不回显
- poll_login:device-poll 会话每次推进一步(页面驱动,不阻塞 —— 插件单VM单锁,
  阻塞循环会卡死本插件全部钩子);成功即落凭据+接入源
- set_endpoints 导出:运维把渠道端点指向自建代理/镜像;也供测试打假上游
- plugin.exports = { set_endpoints }

测试(channels_device_test.go):假上游逐次响应,8 行决策表逐行断言
(pending 等待 / slow_down interval+5 / denied→failed / expired→failed /
authorization_pending 等 / slow_down(4xx) 等 / 成功→completed+account 取
nickname / error→failed 带文本)。全部通过。

教训(词法作用域):devicePkce 原引用文件后段才定义的 newToken,Lua 下那是
nil 全局调用 —— 已改为自含熵源。
2026-10-11 20:08:00 +08:00
96d97b0e0e feat(channels): MiniMax 渠道 —— PKCE 设备码流(RFC 8628)
第二个真实厂商,采用纯 HTTP 的设备码授权(无需本地服务器):

- 申请: POST account.minimax.cn/oauth2/device/code
  body: client_id=mcode-public&scope=agent.default&audience=agent-backend
        &code_challenge={S256(verifier)}&code_challenge_method=S256
- 轮询: POST account.minimax.cn/oauth2/token
  body: grant_type=device_code&device_code=..&client_id=mcode-public
        &code_verifier=..
- 签到: POST agent.minimax.cn/minimax-cloud/api/v1/signin/claim body {}
- 模型 fallback: MiniMax-M3.1-Flash-Preview / M3 / M2.7-highspeed
  (对齐参考仓库 minimax-product.ts)

device-poll 流程的三步状态机在 start_login/poll_login 中实现:
begin 时生成 PKCE verifier/challenge 并申请设备码,把 verifier 存进
会话(只在换 token 时用一次,不回显),页面轮询 token 端点直到
authorization_pending 结束。
2026-10-11 19:29:58 +08:00
6de9f28269 feat(channels): 逐厂商支持启动 —— TRAE 真实渠道接入 + checkin 表驱动
用户确认:仿照示例插件逐个支持厂商,自用、风险已知悉。

PLATFORMS 表升级为完整平台描述(对齐 dsh-our-free-model/channel-pack):
- adapter 字段:接入内核用哪个协议适配器
- checkin 字段:每日签到 {url, method, body, headers}(可缺省=不支持)
- hosts 字段:登录域与上游域(供 plugin.hosts 推导)
- claim_fn/claim_result_fn 钩子位:特殊签名/响应解析的厂商挂点

TRAE(字节)为第一个真实渠道,作为后续厂商的模板:
- 登录:callback 流(www.trae.cn/authorization,token 直传)
- 模型 fallback:DeepSeek-V4-Flash/Pro、Kimi K3、GLM-5 系、Doubao-Seed-2.1
- 签到:POST https://api.trae.cn/trae/api/v2/ug/checkin_credits/claim body {}
  (端点逐字对齐参考仓库 trae-credits.ts)

plugin.hosts 不再手写:declaredHosts() 从平台表推导(hosts+base_url+checkin.url),
避免"两份名单漂移"(参考仓库同类真实缺陷的教训)。

内核零改动。UI 登录框补回 ch-vnc-wrap 内嵌容器(上次重构时遗漏,VNC 兜底
流程会引用不到)。
2026-10-11 19:26:21 +08:00
50997a4e3a fix(ui): 设置页适配额外出站主机授权 —— hosts_declared/hosts_granted
上一提交把 auth_hosts 语义泛化为 plugin.hosts(额外出站主机授权),但设置页
仍读旧字段名 auth_hosts_declared/auth_hosts_granted、提交 auth_hosts 键 ——
导致内核权限面板的主机授权区永远显示"未声明",勾选保存也不生效。

- 读取: auth_hosts_declared/granted → hosts_declared/granted
- 提交: { auth_hosts } → { hosts }
- 文案: "登录主机" → "额外出站主机"(授权与登录无关,登录是插件自己的事)

内联 JS node --check 全部通过;UI 契约测试通过。
2026-10-11 16:47:24 +08:00
74ec1e7874 refactor(lua): 内核回归六项职责 —— 登录/浏览器全部移入插件
用户钉死了内核的职责边界,本提交把越界部分全部拆除:

  内核只做:加载适配器 · auto 调度 · 加载插件 · 调度插件 · 维护源与 key · 签发密钥

## 架构纠正(用户指出的根本错误)

我此前把信任模型弄反了。实测:插件 VM 的 os.execute/io.popen/package 全部可达
(true,true,true) —— 插件从来不是 OS 级沙箱。正确的边界是:

  插件   可信(运维安装),非沙箱,有完整 OS 能力 → 平台知识/登录/浏览器归它
  适配器 不可信(可被动态安装),请求级 → 沙箱归它(12 个内置适配器实测均不碰
         os/io/package,后续沙箱化零风险,属独立一步)

## 删除(内核越界部分)

- plugin_browser.go        内核浏览器管理器(Xvfb/chromium/x11vnc/websockify
                           进程栈、回环监听器)—— 388 行全删,浏览器由插件用
                           os.execute 自己拉起(实测 spawn+reap 闭环可用)
- plugin_login.go          登录会话状态机 + LoginFlow 枚举 + VNC 全局门禁
                           —— 457 行全删。"登录"从内核概念中消失
- gateway/browser_proxy.go viewer 反代
- handlePluginLogin API    登录会话列表端点
- vnc_enabled 全局开关     改为 per-plugin 授权(见下)

## 新增(内核职责内的通用机制,无任何登录/浏览器语义)

- plugin_outbound.go       通用额外出站主机授权:插件声明 plugin.hosts(请求)
                           → 运维在设置页逐个批准(授权)。与 kernel actions
                           同一"声明≠授权"模型。内核只知道白名单加减,不知道
                           host 被用来干什么
- on_unload 生命周期钩子   卸载是"调度插件"的一部分:内核在关闭插件状态前调用
                           插件导出的 on_unload,让它自清理外部资源。内核不知道
                           资源是什么(进程/句柄/远端会话都行)。带进程回收回归
                           测试 TestOnUnloadReapsPluginProcesses

## channels.lua 全量接管

- 登录会话自持:plugin.state.sessions(nonce 只在 begin 响应出现一次,
  惰性过期清理)
- 服务端浏览器栈自持:spawnBrowser 用 io.popen 拉起 Xvfb/chromium/x11vnc/
  websockify 并记 pid,reapBrowser 回收;on_unload 兜底
- 优先级阶梯(用户指定):① 本机浏览器(给 URL)→ ② 自动化(轮询/nonce 回调)
  → ③ VNC 无头浏览器兜底(无浏览器系统/自动化无法完成的场景,如回环重定向
  必须落在服务端)
- plugin.auth_hosts → plugin.hosts(语义泛化:出站主机授权与登录无关)

## 验收

- 全仓旧符号零残留(grep 权威确认,代码+注释+lua+测试)
- 内核非测试代码零厂商名
- build + vet 全绿;lua/core/gateway/types/scheduler/provider 全量测试通过
- channels 端到端(登录→凭据→add_source→模型可见)继续通过
2026-10-11 16:24:55 +08:00
fa50ec8bc7 feat(lua): 服务端浏览器登录(VNC)+ 内嵌页签 —— 回环回调的唯一自动化路径
用户指明的场景修正了我的一个实质错误。我之前写"VNC 不解决取凭据,凭据还在
浏览器 profile 里"——这对 cookie 类成立,但对**回环重定向**不成立:

  浏览器在用户机器 → redirect 到 127.0.0.1:P → 打到**用户的**回环 → 服务端收不到 code
  浏览器在服务端   → redirect 到 127.0.0.1:P → 打到**服务端的**回环 → 插件直接收到 code

后者不需要"提取凭据",code 自己流到监听方。所以对回环类登录,服务端浏览器
不是权宜之计而是**唯一自动化路径**。已实测验证(浏览器在服务端时 loopback
回调的 code 落到了服务端监听器)。

## 实现

plugin_browser.go:Xvfb(:N) ← chromium → x11vnc → websockify(noVNC) + 回环回调监听。
五个进程一起起停,因为失败模式是顺序相关的(崩掉的会话留下的 Xvfb 会让下一个
chromium 以看起来像浏览器 bug 的方式失败)。

**默认关闭,且不是"开了 VNC 就能登全部"**:每个平台单独走 vnc 流程才启动。
理由是这套东西风险最高——在服务端跑真实浏览器登录用户的第三方账号。

**边界**:插件拿到的是 view_url,**不是浏览器控制句柄**。CDP 端口不进 Lua。
否则一个插件就能读所有标签页的 cookie,而那个浏览器登录的是运维的账号。

**teardown 必须删 profile**:里面是活的第三方 session cookie,删它是安全契约
的一部分,不是收尾工作。

## viewer 反代(browser_proxy.go)

/browser/<id>/... → 该会话的 websockify。为什么不直连端口:
1. 鉴权——直连等于把已登录的浏览器暴露给任何能访问该端口的人(实测未鉴权
   访问得 302 → /login,带 admin key 得 3912 字节 noVNC 页面)
2. WebSocket——noVNC 核心是 WS 升级,httputil.ReverseProxy 原生处理
3. 同源——WS 路径不需要跨域配置

## 内嵌在插件页签(用户要求)

viewer 用 iframe 内嵌进 channels 插件的登录区,而不是让人跳出去开新窗口:
- 插件 UI 拿到 view_url 就塞进 iframe src,登录完成/失效时清空 src 并隐藏
- 插件只对 `flow = "vnc"` 的平台起浏览器;其他流程继续用用户自己的浏览器
  (等于白开一个高风险的浏览器是浪费)

## 插件本体:channels(同类插件)

单插件多平台,4 个平台示范四种流程(link-poll / callback / paste / vnc)。
登录 → 凭据进保险库(不进 state)→ add_source 接入内核 → 定时领额度。
声明式:plugin.kernel 申请内核动作、plugin.auth_hosts 申请登录主机,
设置页显示"申请中"由运维批准。平台表加一条就多一个平台,内核零改动。

开发中被测试抓到的三个真实缺陷(都在我自己的代码里):
1. handler 返回 `{url=…}` 没有 status/json → 内核**静默丢弃**,200 空响应、
   无任何错误。已把所有 handler 统一为 {status,json},并加契约测试锁死。
2. 插件把 req.body 当表用 → 实际是 JSON **字符串**,所有平台参数都读不到。
   加 bodyOf() 统一解码。
3. 顺带发现工作树里 discover.go 被改成 `return "image"`(非本次改动,像是早先
   变异测试留下的),会改变模型类型推断;已还原。

测试:服务端浏览器会话(进程栈/默认拒绝/回调捕获/teardown 清理 profile)、
viewer 反代鉴权、插件 VNC 端到端、channels 插件端到端(含未授权时如实报告
source_error 而非假装成功)、handler 契约。
2026-10-11 14:26:28 +08:00
c3b858d5ba feat(lua): 多流程登录会话抽象(R4-e)+ 内核权限设置页
用户决策:**多流程抽象**(VNC 作逃生舱)+ **单插件多平台**。

## 为什么不是 VNC 为主路径(实测参考插件后的结论)

grep 参考插件(dsh-our-free-model,13 个账号渠道)的源码:

  puppeteer / playwright / chromium / headless / vnc / selenium  →  0 处

它的登录全部是**把 URL 交给用户自己的浏览器**,插件轮询凭据是否就绪:
loginUrl 113 次 · cookie 133 次 · oauth 43 次 · deviceCode 8 次 · qrcode 7 次。

不以 VNC 为主路径的四条具体理由:
1. Passkey/WebAuthn 基本不可用:凭据绑在用户设备,VNC 里是另一台机器。
2. 无头浏览器更容易被判为机器人 —— 登录场景最不能踩这个。
3. 安全面:llmsproxy 以 root 运行(master.key 0600),systemd 已 ProtectHome=yes。
   从 root 服务开 VNC 通道到"即将登录用户各平台账号"的浏览器 = 提权面绑高价值目标。
4. 最关键:VNC 不解决取凭据。登录后凭据活在浏览器 profile 的 cookie 里,
   还是要提取 profile —— 那正是最脆弱的一环。轮询流程直接产出 token。

VNC 唯一不可替代的场景(服务端与用户不同网 + 平台要求登录 IP 与调用 IP 一致)
使它作为逃生舱存在:**默认关闭**,需运维显式开启。

## 登录会话抽象(plugin_login.go)

四种流程:link-poll(首选)· callback(复用 Phase 6 自定义挂载)· paste(兜住
cookie 类渠道)· vnc(逃生舱)。内核只管会话生命周期,不含任何平台知识。

- 会话只存**状态**不存凭据:凭据由插件写进自己的保险库,会话里放一个
  UI 可见的记录会让 token 有机会被列表接口带出去
- nonce 只在 begin 时给(后续读取剥离),空 nonce 不匹配任何会话
- 读取时判定过期,无后台定时器(没有登录请求时零开销)
- 跨插件读不可行,且与"不存在"不可区分(否则会确认 id 存在)

## 关键阻碍与修法:登录主机不在出站白名单里

**实测确认**:`auth.huaweicloud.com` 被白名单拒绝
("not an allowed outbound destination (allowed: the hosts of configured sources)"),
整个登录流程无法工作 —— 因为登录主机不是上游源。

修法与内核权限同一姿态:插件在源码 `plugin.auth_hosts` **声明**,运维在设置页
**批准**后才放行。只能批准插件声明过的 host(否则声明只是装饰),逐个批准
不连带(否则一个"登录"理由就能打开整张名单)。

## 设置页

插件表格新增"内核动作"列 + 点击展开的权限面板:动作清单按危险级排序、勾选即
授权、**申请中但未授权会标红**(那通常意味着插件某功能静默失效)。
另加登录主机批准面板(已批准/待批准)。

后端 /api/plugins/{name}/kernel(GET 看 declared vs granted,PUT 改)
与 /api/plugins/{name}/login(列在飞会话,供页面显示"等待你完成登录"+ URL)。

开发中自己引入并修掉一个缺陷:renderPlugins → refreshKernelSummary →
renderPlugins 无限递归;改为只原地 patch 受影响的单元格。

测试 8 个:会话生命周期/跨插件隔离/callback nonce/登录主机门控/只能批准已声明/
过期判定/VNC 需显式开启/未知流程名被拒。node --check 验证内联 JS 可解析。
2026-10-11 13:56:35 +08:00
a59295e826 feat(lua): 插件可调内核动作面(R4-c)+ 凭据保险库(R4-d)
用户指出「接口不全」——此前插件只有 __llmsproxy_adapters 三个手写闭包,
够不到内核。对照参考插件(dsh-our-free-model,13 个账号渠道)的真实能力,
缺的正是这两块:

  插件自己驱动链接认证 → 存凭据 → 定时领额度 → 把源接进内核

## R4-c 内核动作面:__llmsproxy_kernel

18 个动作,全部映射到既有 Core 方法(不是新造能力):

  只读 6:status / list_sources / list_models / list_adapters /
          list_auto_rules / adapter_owner
  写   8:reload / reset_health / probe_all / save_auto_rules /
          save_key_auto / add_source / create_key / update_key
  破坏性 4:remove_source / delete_key / remove_adapter / set_plugin_enabled

**add_source 是「插件注册自己的上游」的落点**——插件拿到凭据后可以自己把
端点接进网关,无需运维改 config.yaml。已端到端验证:插件调它 → 内核源列表
出现 → 模型进入可路由列表。

依赖方向:lua 不能 import core,所以接口定义在 lua、实现放 core
(kernel_actions.go),走 SetKernelAPI 注入——与 SetOutboundHosts 同一先例。

**凭据边界**:动作面**有意排除** list_keys/find_key。Core.ListKeys() 返回
明文网关密钥,交给第三方 Lua 等于交出整个部署的凭据面。有回归测试锁死
这条(TestKernelActionSurfaceExcludesPlaintextKeys)。list_sources 返回的
api_key 也强制掩码(只给 ***+后4位)。

**权限**(用户选「全开+设置页可配」):18 个动作全部可达,但**默认拒绝**,
授权落盘 .kernel-perms.json(独立文件,不并进 .access.json——后者是
map[string]string,塞进去会破坏旧二进制能读的格式)。danger 级不接受通配符。
插件可在源码 plugin.kernel={...} **申请**,设置页显示申请 vs 已授予。

**Go 侧 deadline**(用户选定):每个动作有超时预算,超时把 (nil,err) 还给
Lua。这可行的原因:Lua 只是等返回值,不占 Lua 栈执行权——与「Lua 死循环
不可中断」是两件事。

## R4-d 凭据保险库:__llmsproxy_secrets

此前插件存 token 只能写 plugin.state:0755 目录、明文、与计费累计量同文件、
会被 PUT /state 整体替换、会进 WebUI。现在独立文件:

  <plugin_dir>/.secrets/<plugin>.json   文件 0600,目录 0700

临时文件**先 chmod 再 rename**,全程不存在宽权限窗口。读取身份在插件 boot
时绑定(不从 Lua 状态现读)⇒ 跨插件读不可行是机制而非约定。插件删除时清空
(DeleteAllSecrets + 启动 PurgeOrphanVaults)。

## 开发中被测试抓出的两个真实缺陷(我自己引入的)

写了 TestNoKernelActionDeadlocksFromLua 对**每个**动作断言「在插件 Lua 调用
(此时持有 p.mu)内不得死锁」,抓出:

1. **自死锁**:set_plugin_enabled → SetEnabled → p.mu.Lock(),而死锁发生在
   调用者已持有的同一把锁上。
2. **更隐蔽的迟到副作用**:超时把 error 返回给插件后,动作仍在幕后执行完成——
   插件以为失败,实际已生效。

修法:标注 Deferred 的动作在本次调用返回后异步执行,如实返回 accepted:true
而不是谎报成功。18 个动作全部复验无死锁,另有单独测试确认副作用真的落地
(只测「不死锁」的话,一个全空转的 dispatch 也能通过)。

测试 11 个(core 8 + lua 3):动作可用/密钥掩码/默认拒绝/授权计数/
插件注册源并变为可路由/授权落盘重启保留/未知动作名被拒/凭据面排除明文/
全动作无死锁/破坏性动作生效/Lua 侧桥接可用。
2026-10-11 13:52:29 +08:00
dafc3b3fc3 feat(lua): 插件 Lua 侧适配器桥接(R4 Lua 绑定)+ 真二进制适配器接手 e2e
补上两个此前报告为"缺口"的东西。其中第一个的旧结论是**错的**,这里更正。

## R4 Lua 绑定:__llmsproxy_adapters

用户问的"为什么 Lua 绑不上"—— 结论是**能绑,之前的归因错了**。

旧结论是「NewTable + PushGoClosure + SetField 是 golua 雷区,会 SIGSEGV」。
反证:内核自己的 buildJSONTable 用的就是这同一个模式,长期稳定。真正的变量
是当时 Phase 4 的**双 VM 半成品**(钩子一个 VM、定时任务一个私有 VM),
bootPluginState 签名与调用点不一致,我改到一半编译不过就仓促回滚,留下的
中间态让 billing 一跑就崩。把双 VM 改回单 VM 之后,同一模式一次就通。

顺带回答一个备选方案(用户提出:Lua 只投递请求、Go 串行执行):不需要。
p.mu 已经天然串行化同一插件的所有入口;再加队列只会引入"谁排空/超时后积压
怎么算"的新问题。而且桥接调用本身是微秒级的(交给 Go 重建一个小池),真正
长耗时的是插件自己去取 token 的 HTTP 请求,那跟桥接无关。

能力(Go 侧强制,非约定):
- install(name, source) → (生效名, nil) | (nil, err)
- update(name, source) → (true, nil) | (nil, err)  只限自己装的
- remove(name)         → (true, nil) | (nil, err)  只限自己装的
- 调用身份是插件**启动时的固定名**,不从 Lua 状态现读 ⇒ 无法冒充别的插件
- 不能影子内置(Lua 侧同样被拒,不是绕过 Go 校验的后门)
- 出错返回 (nil, err) 而非抛异常,与钩子/HTTP 处理器同一套约定

**分片 VM 故意不给**:分片是文档化的「只读、可丢弃」工作单元,而适配器
install 是**全局**副作用(改共享注册表)。给分片等于让"只读"的按需调用悄悄
改写所有请求共用的适配器 —— 正是分片契约要防的不可见变更。

测试 6 个:install/rotate/remove 从 Lua 调用、影子内置被拒、**定时任务里
轮换 token 且请求钩子立即可见**(R4 闭环)、分片无桥接。

## 真二进制 e2e:适配器接手对外服务

用户问到的第二个点。此前 /v1/messages 的 Anthropic 接管只在**进程内**
gateway 层测过(internal/gateway/messages_test.go),绕过了只会在真实进程里
发生的事:适配器磁盘 seed、配置加载、真 HTTP 栈。

新增 e2e/adapter_takeover_test.go,打**真编译的二进制** + 只说 Anthropic 的
假上游(该上游对不符形状的请求主动报错,不做宽容接受):
- 非流式:出向必须是 Anthropic message 形状,不能有 OpenAI choices 外壳
- 流式:必须是 message_start…message_stop 序列,**不得**出现 [DONE]
- 压力:96 并发(48 流式)混合打真二进制,全部 200,无 5xx,无形状串味

压力测试初版把并发上限设成 4 于是大量 429,我把断言改正为:429 是内核**正确**
的容量背压,不算失败;失败的定义是 5xx 或 200 但内容/形状错。现在 96/96 通过。
2026-10-11 13:25:11 +08:00
66786bb0e3 docs: 契约 v2 文档收尾(Phase 7)
用户要求的收尾。三份文档与代码对齐。

## docs/lua-adapters.md

- 新增 §4「出向序列化钩子(契约 v2)」:serialize_response /
  serialize_open_preamble / serialize_chunk / serialize_close /
  serialize_done_marker。核心语义:**声明即完全取代**内核的 OpenAI 兜底,
  不是叠加 —— 适配器输出里不会有内核补的 OpenAI 字段(R2/R5)。
- §8 标题与内容从 disable_thinking 改为中立的 thinking 预算:
  `{enabled, budget_tokens}`,由适配器映射到目标协议。v1 的
  disable_thinking/extra_body 是内核细节泄漏进适配器请求体的典型
  (迫使 10 个适配器各写一行清理),已记明已被取代。
- §7 最小适配器示例同步改成映射中立 thinking 字段。

## docs/plugins.md

- §5.4 定时任务(PLUGIN.schedule):every/at/jitter/timeout 字段表 +
  **内核 ticker 驱动、浏览器关着也跑** + 任务与钩子同一个 VM(写
  plugin.state 立即可见于请求)+ 卡死则停用该插件(不能中断 Lua 的
  能力边界)。
- §5.5 自定义对外挂载点:plugin.http 声明同时可在 /api/plugins/http/、
  /plugins/、/v1/plugins/ 三处访问;命名空间约束与内核路由冲突拒绝。
- §5.6 运行时安装/更新适配器(token 轮换)+ 三条安全规则。
- §6.1 更正:某个卡住的钩子/任务只卡**它自己**那个插件的钩子(跨插件
  并行),原文写的是"卡住所有插件"。
- §9 对比表补周期任务/对外端点两行,钩子列表补 serialize_*。

## README

- disable_thinking 章节 → thinking 预算(中立字段 + 各适配器映射)。
- 适配器协议示例补 serialize_* 出向钩子。
- 特性列表与测试覆盖描述同步。
2026-10-11 13:07:50 +08:00
f4d5e779a5 chore(lua): 移除误入的临时复现脚本 zz_repro_test.go
上一条 commit 用 git add -A 时把工作树里一个未跟踪的临时复现脚本
(zz_repro_test.go,注释自称「临时复现」)一并扫了进去。它不属本次范围:
不对任何东西做断言(只有 t.Logf),且它探查的 anthropic 空数组问题
在 main 上同样存在,是既有行为,不由本分支引入。

顺带确认它想复现的那个形状:唯一命中的是
`"messages":{}` —— 空 Lua 数组经 luaTableToGoValue 变成 JSON 对象而非
空数组。这确实是个真问题(Anthropic 会拒),但它在 main 上就存在,属
独立缺陷,不在线格式 v2 分支的处理范围。已记入 plan.md 已知遗留。
2026-10-11 13:00:46 +08:00
61f402beef fix(lua): 定时任务改跑在插件自己的 VM 上 —— 纠正 Phase 4 的双 VM 错误
用户指出的设计错误:**一个插件 = 一个 Lua VM = 一个调度器**。而 Phase 4
(4d1407b)给定时任务开了一个**私有 VM**,于是:

    定时任务 refresh_token 写 plugin.state.token  →  写进私有 VM 的副本
    请求钩子           读 plugin.state.token      →  读主 VM 的值

轮换"成功"了,对实际流量完全无效;而且私有 VM 绕过状态持久化,token 连
磁盘都到不了。这直接违背这个功能存在的理由。

## 修正

PluginRuntime 不再持有 Lua 状态。任务通过 p.state + p.mu 运行 —— 与请求
钩子**同一个 VM、同一把锁**:

- 任务写入立即可见于钩子(TestTaskWriteIsVisibleToHooks 锁死这一点)
- 任务写入经 markDirtyLocked 走同一套持久化
- 跨插件仍并行(每插件各自 VM、各自锁)

## 由此产生的行为(有意为之,非缺陷)

- 某插件的长任务会排在**它自己**的钩子前面(同一 Lua 栈,一次一个调用者)
- 卡死处置随之改变:不再"抛弃 VM 重建"(那会丢掉累计状态——计费总额、
  签到历史,正是插件存在的意义),而是**停用该插件**:
  - checkWedged 超过 wedgedAfter(10min) 置 disarmed
  - Fire 对 disarmed 插件快速失败并记 hook_errors(否则请求会排在永不释放
    的锁后面永久挂起——比停用更糟)
  - Unload 检测 InFlight:有任务在跑时不取 p.mu(会永久挂起)、不 Close
    状态(Lua C 层 use-after-free 直接 abort)。泄漏这一个状态是有界的,
    比崩溃好。
  - maxAbandons/抛弃计数因此删除:没有"重生"了,熔断由 disarmed 承担

## 测试(10 个)

主断言从"任务跑过"改为"**任务的写入对请求路径可见**"—— 旧实现会在这里
失败。另含:无触发自执行/无 schedule 无运行时/解析/at 下次触发/错误隔离/
卸载停 goroutine/卡死停用+钩子快失败/健康任务不误判/8 插件并发各自调度。
2026-10-11 12:59:36 +08:00
57fada3b0d docs(lua): 更正 ScheduledTask.Timeout 注释 —— 超时由抛弃 generation 实现,非 Lua 指令级中断 2026-10-11 12:14:49 +08:00
b6148e6f79 chore(lua): 删除调度器里两个无人调用的诊断辅助函数
sortedTaskNames / PluginTasks 是开发 Phase 4 时留下的诊断辅助,最终
没有任何调用点(WebUI 走 /api/plugins 的既有结构)。连同不再需要的
sort import 一并删除,保持包面干净。
2026-10-11 11:41:08 +08:00
24e00cdc50 feat(gateway): 插件可注册自定义对外挂载点(R3)
用户需求:插件与适配器可以注册对外服务的端口。

现状是插件端点被固定在 /api/plugins/http/ 下。那条限制有三个理由
(可 grep / 可整体撤销 / 不能影子内核路由),放开时必须逐条补回来:

- 命名空间约束:只接受 /plugins/ 与 /v1/plugins/
- 内核路由冲突检测:ValidateCustomMount + FindMountConflict(双向)
- 鉴权默认关闭:沿用 HTTPHandler 的 roles/keys/public 语义

新增 internal/gateway/mount.go:
- ValidateCustomMount:命名空间 + 冲突 + 逃逸校验,错误点名冲突对象
- FindMountConflict:**双向**检测(只查一边会让 /plugins/x 与
  /plugins/x/sub 互相静默吞掉)
- BuildRouteTable / RouteOwner:冲突提示点名持有者("plugin:other")
- handleCustomMount:**复用** handlePluginHTTP 而不是重写一遍分发 ——
  重写会造出两条必须保持一致的分发路径,而只在一处生效的安全规则
  比没有更糟

关键防线:TestKernelRoutesAreAllCoveredByReservedPrefixes 从 server.go
刮出路由 switch 的字面量,逐个核对是否被 kernelReservedPrefixes 覆盖 ——
将来新增内核路由却忘记更新保留表时,这条测试会失败。否则新路由会静默
变成可被插件覆盖的。

测试 9 个:命名空间/影子内核路由/保留表同步/双向冲突/冲突点名/挂载解析/
路由表构成/端到端(自定义挂载 200 + /v1/plugins 形式 + 未注册 404 +
无 key 401 或 302)/内核路由不可拦截。
2026-10-11 10:47:17 +08:00