234 Commits

Author SHA1 Message Date
84a9fccd9a 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:09 +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
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
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
9016eda905 feat(lua): 卡死任务改为「抛弃整个 VM」—— 不中断,直接换一代
用户给的架构决策:与其想尽办法中断卡死的插件任务,不如直接抛弃它所在的
VM,让任务的 goroutine 跟 VM 一起被处置。这比中断干净 —— VM 重建只要
~0.1ms,抛弃比修复便宜。

## 为什么必须这样做(三次实测)

本 golua + LuaJIT 构建**无法中断**运行中的 Lua 调用:
  1. SetExecutionLimit(200000) 对紧密循环      10s 未返回 —— 无效
  2. 计数 hook(2000) + RaiseError 对紧密循环   fired=0   —— hook 未触发
  3. 计数 hook(1000) 对「函数调用+表写」循环   fired=20971 触发,
     但 RaiseError 后仍未返回 —— RaiseError 走 L.StackTrace(),
     正是 vm.go 已记录的 SIGSEGV / 无法跨 C 栈路径

原因:LuaJIT 把紧密循环编译成机器码,指令计数 hook 只在解释器路径触发;
而唯一能在 C 栈上中断的 golua 入口恰好依赖 StackTrace()。

## 模型:generation 可抛弃

PluginRuntime 持有一个 runtimeGen(私有 VM + 单状态池)。任务超过
wedgedAfter(10min,因为正常情况就是慢网络调用)仍不返回时:

- 抛弃该 generation,由 ensureGeneration 在下一 tick 起一个干净的
- 卡死的 goroutine 留在旧 VM 上,永远碰不到新一代的状态
- 在飞任务被 carry 到新 generation 并重新排期(否则 abandon 计数永不前进,
  熔断也就永远不触发)

## 熔断(必须有)

每次抛弃都留下一个永不返回的 goroutine + Lua 状态。没有上限的话,一个
必然卡死的任务会让运行时每 10 分钟建一个新 VM —— 由插件自身 bug 驱动的
无界泄漏。maxAbandons=5 后熔断停止重生,需人工 reload(同 systemd 单元
反复启动失败后的姿态)。

## 开发中修掉的四个缺陷

1. rt.gen 未赋值:bootGeneration 的结果只赋给局部变量,导致 checkWedged/
   runDue/dropGeneration 全部读到 nil,运行时会「活着但一个任务都不跑」。
2. checkWedged 无条件抛弃:即使没有任何任务判定为 wedged 也 drop,
   让健康插件不断换代。
3. startedAt 由 runDue 设置而非 runOne:任何其它 dispatch 路径的任务
   都不会被追踪,checkWedged 无法区分「在跑」与「从未启动」。
4. runOne 收到 nil gen 会 panic(抛弃与派发竞争)—— 改为安全返回并清标志。

测试:13 个(无触发自动执行/真正执行/零开销/at 解析/条目校验/失败隔离/
卸载停 goroutine/压力/抛弃换代/熔断/健康任务不受影响)。
2026-10-11 10:40:58 +08:00
4d1407bbb4 feat(lua): 插件长期运行环境(R4-b)—— 内核驱动调度 + 独立 VM
用户需求:插件需要长期运行环境(独立 LuaVM 线程化),以支持自动签到、
token 轮换这类"没有浏览器、没有人操作时也要发生"的能力。

旧模型做不到:插件的周期工作只能靠前端 pluginAPI.call 驱动,页面没开就
一次都不跑。这正是自动签到的死穴。

新增 internal/lua/plugin_runtime.go:插件声明,内核驱动。

    PLUGIN.schedule = {
      { fn = "refresh_token", every = 3600 },
      { fn = "daily_checkin",  at = "03:30" },
      { fn = "poll_quota",     every = 300, jitter = 30 },
    }

- 每个声明了 schedule 的插件获得**私有 VM**(不是共享 plugin.state 的那个:
  钩子要累加,任务要长跑且网络阻塞,两者对隔离的要求相反)
- 内核单个 ticker 驱动所有任务;每个任务**独立 goroutine**,一个长任务
  不推迟另一个的 due
- `at="HH:MM"` 每日固定时刻;`jitter` 打散首触发,避免整点齐射
- 任务失败计数进入 hook_errors 面(静默坏任务必须可见)
- Unload 先停调度再关状态(顺序反了会让在跑的任务写进已关闭的 Lua 状态)

明确边界:**适配器不参与调度**。适配器仍是无状态请求级池;token 轮换靠
插件(这里)按周期调用 Phase 3 的 UpdateAdapter。

修掉两个把进程直接搞崩的缺陷:
1. 私有 VM 的池我给 sync.Cond 传了新 mutex 而非池自己的 mu →
   "unlock of unlocked mutex",fatal error 而非可恢复错误。
2. Transform 硬编码 adapterGlobal,而插件模式的池把表存在 pluginGlobal →
   健康的状态也报 "has no table"。加 poolGlobal(p) 按模式选择。

测试 8 个:无任何触发自动执行(核心)/真正执行了(计数读回)/无声明则零开销/
at 解析与畸形退化/不完整条目被忽略/失败隔离/卸载停 goroutine/6 插件×3 任务压力。
2026-10-11 10:26:39 +08:00
5f6939b590 revert(gateway): 还原 discover.go 的空白重排(非本次改动)
我此前对 internal/gateway/ 跑了整目录 gofmt -w,它把 discover.go 里
注释对齐重排了(upstreamModel 的 struct tag 列、copyFields 的行内注释)。
那不是本次任务的改动,且会破坏变异测试 harness 的文本锚点 ——
discover_mutate_test.go 按精确文本做变异注入,锚点对不上就直接判定
"这个变异会是 no-op" 并中止整套 harness。

教训:整目录 gofmt 会在你没打算改的文件上产生 diff。先还原。
2026-10-11 10:16:58 +08:00
a0ab9ddda5 feat(lua): 插件适配器管理桥接(R4-a)—— 安装/改写/删除,支撑 token 轮换
用户需求:插件可安装、运行时修改适配器以支持复杂认证与 token 轮换;
适配器本身不需要持续运行(它是无状态请求级弹性池)。

  插件(长期运行)─定期/事件─> install/update/remove_adapter
                                      │
                                      ▼
                      内核重建该适配器的池(新池先发布,旧池后回收)
                                      │
                                      ▼
                          后续请求自然使用新逻辑

三条安全规则(与 R6 同源):
1. 路径安全:ValidateAdapterName(无穿越/绝对路径/空白)
2. 不得影子内置:插件不能注册与内置同名适配器(运维仍可自行覆盖)
3. 归属可查:owner 表记录每个适配器由谁安装,于是"插件只能改自己装的"
   是可判定的,而非靠约定。没有归属信息时,插件 A 能劫持插件 B 的认证逻辑
   而无法被察觉。

修掉两个开发中暴露的真实缺陷:
1. 池替换窗口:LoadAdapterSource 原先在 v.mu 内先 shutdown 旧池再装新池,
   窗口期内请求拿到 "pool closed"。改为**新池先发布、旧池后回收**。
2. 并发轮换仍失败:多个 goroutine 同时轮换时,单次重试会连续落在刚被下个
   轮换关闭的池上。acquireAcrossSwap 改为按**墙钟**(2s 上限)+1ms 退避
   重试新池 —— 轮换是运维的意图,落在微秒窗口里的请求不该因此失败。
   上限保证适配器真消失时仍会及时报错而非空转。
3. 声明名 vs 注册键:脚本可声明与注册键不同的 name(插件按自己控制的键注册,
   但适配器声明源真正引用的名字)。归属表同时记两个键,删除时一并清理;
   EffectiveAdapterName 在声明名下也能查到池。

测试:11 个(安装/轮换/影子内置/跨插件窃取/碰运维适配器/路径穿越/删除/
归属列举/并发 8×20 压力/语法错误安装即拒/声明名上报)。

注:internal/gateway 的变异测试 harness 拒绝在脏工作区运行(设计如此),
故本提交后它才可运行。
2026-10-11 10:03:43 +08:00
e4111337cd feat(gateway): /v1/messages 端到端 —— 内核不含任何 Anthropic 出站字段名
Phase 2 收尾。证明「对外协议由适配器决定、内核只调度」不是纸面主张:
Anthropic 客户端打 /v1/messages,拿到的是 Anthropic 形状。

- internal/gateway/messages.go:入向把 Anthropic 请求解析进统一格式
  (content blocks 原样保留——只有适配器知道自己的上游接受哪种块类型;
  system 支持字符串与 [{type:text,text}] 两种写法)。
  调度/配额/AUTO 链/冷却/审计/插件 hook 全部复用 /v1/chat/completions
  的同一条路径(serveUnified),差别只有两端线格式。
- internal/lua/adapters/anthropic.lua:新增 serialize_response /
  serialize_open_preamble / serialize_chunk / serialize_close,
  把统一格式译回 Anthropic Messages 形状(含 content 分块、
  stop_reason 反向映射、input/output_tokens 改名)。
- 流式是 message_start → content_block_delta → message_delta →
  message_stop;DoneMarker 为空所以内核**不会**擅自加 [DONE]。

内核出向零 Anthropic 字段名:解析是表驱动的,序列化属于适配器。

测试:
- messages_test.go 5 个端到端用例(真实 core+gateway + 只懂 Anthropic 的
  假上游):形状/stop_reason/块顺序/usage 命名、流式事件序列、入向校验、
  鉴权、以及「未声明钩子时回落 OpenAI 兜底」。
- 修正两个 serializer 测试的前提断言(anthropic 现在确实声明了钩子,
  这正是 Phase 2 的意图),并发测试改为按适配器各自校验形状。

golden 字节级未漂移;go test -tags luajit ./... 全绿。
2026-10-11 09:51:27 +08:00
93eeba3762 feat(types): 统一格式落地为内核实际使用的类型 —— 私有字段泄漏归零
Phase 2 收尾(入向)。ChatRequest 从"客户端发什么就是什么"改为
UnifiedRequest(最大集合)的别名,内核真正用上了那个超集类型。

改掉的具体缺陷:

1. 私有字段泄漏归零(v1 的核心问题)
   v1 的 disable_thinking 是内核私有开关,却被序列化进交给适配器的 body,
   迫使 10 个适配器各自写 req.disable_thinking = nil 来清理。
   现在它是中立的 thinking 字段({enabled, budget_tokens}),客户端与
   适配器都认这个词:
   - gateway/chat.go:thinkingFromClient() 把 disable_thinking 翻译成
     thinking.enabled=false;冲突时中立字段优先(它能带预算,布尔不能)
   - deepseek.lua:thinking.enabled=false → extra_body.thinking.type=disabled
   - anthropic.lua:thinking.enabled=true + budget → 原生 {type,budget_tokens}
     (enabled=false 时不传该字段——Anthropic 没有 type=disabled 这个写法)
   - 其余 7 个适配器:把"清理内核私有字段"改成"清理中立字段"

2. Tools/ToolChoice 改 json.RawMessage
   原为 []interface{}(OpenAI 原始形状 = 内核在假设某个协议)。
   内核从不解释工具 schema——它原样交给适配器,所以原始形状不损失任何
   内核侧统一性。ToolDef 结构化类型保留在包内供新适配器选用。

3. 删除 MarshalJSON 后门
   v1 手工 splice ExtraBody 进 OpenAI 形状的 body;现在 ExtraBody 是
   UnifiedRequest 上一个正正经经声明的字段。

渐进迁移:ChatRequest 保留为别名(不是新类型),49 处引用零改动编译,
语义变更不被机械改名淹没。

测试:新增 thinking_test.go(翻译链 4 例 + 泄漏回归 + tools 透传);
vm_test.go 两处迁移到中立语义。golden 字节级未漂移。
2026-10-11 09:35:32 +08:00
958f1f48b5 feat(lua): 适配器导入路径冲突校验(R6)——防静默覆盖与路径逃逸
用户要求:导入适配器时必须检查路径冲突,避免静默错误。

之前 UploadAdapter(name, code) 直接拼 adapter_dir/<name>.lua:
- name 含 "../" 或绝对路径会写到目录外
- name 与内置同名会静默覆盖,加载失败只报 "adapter X missing",
  现场与病因分离

实现(internal/lua/adapter_validate.go):
- ValidateAdapterName:字符集 ^[a-zA-Z0-9_-]+$,拒绝 /、\、..、绝对路径、空白
- ValidateAdapterPath:文件必须在 adapter_dir 内
- VM.InstallPluginAdapter:插件装内置名 → ErrAdapterNameReserved 拒
- Core.UploadAdapter:在 WriteFile 之前校验(防路径逃逸)
- Core.RemoveAdapter:也校验名字(防 os.Remove 越界)

设计取舍:保留名禁止只对插件生效,不对运维生效。
- operator 放 openai.lua 覆盖内置 = 文档化的热覆盖机制,放行
- 第三方插件静默换掉内核自带的协议翻译 = 篡改发行物,拒绝
- 先误做成了全拒,Start() 加载内置适配器直接崩,测试抓住后改对
  (TestLoadBundledAdapters 等锁住回归)

测试 12 个:路径穿越/绝对路径/保留段/空白/内置覆盖(允许 vs 插件拒绝)/
目录外加载/错误点名/热重载不回归。

验证:go test -tags luajit ./... 全绿。
2026-10-11 09:23:34 +08:00
9d6653a573 feat(gateway): 对外序列化移交适配器,OpenAI 格式降级为最后容错
内核不再硬编码对外 wire。writeChatCompletion / pumpStream 里的 OpenAI
帧拼装抽到 Serializer 接口,适配器可声明钩子完全接管。

优先级契约(用户明确要求:冲突时兜底直接让步):

  适配器声明 serialize_*  ->  适配器完全接管
  未声明                 ->  兜底 openAISerializer

「让步」的含义:适配器接管后内核绝不往其输出补任何 OpenAI 字段。混合体
(半个 Anthropic + 半个 OpenAI)比任何一个单独结果都更难排查——客户端无法
解析,而排查时会指向错误的层。

接口把「流如何结束」也纳入:OpenAI 需 usage 帧 + [DONE],Anthropic 用
message_stop,Gemini 用空 candidates 帧。DoneMarker 让适配器声明自己的
终止标记,避免擅自给严格客户端加 [DONE]。

新增能力:
- VM.HasFn(name, fn):不占 worker 探测适配器是否声明某函数
- VM.AdapterRevision(name):适配器源码每次替换 +1(接着旧值递增)

修掉两个开发中引入的真实缺陷:
1. 锁序倒置自死锁:LoadAdapterSource 里在已持有 v.mu.Lock() 时嵌套
   v.mu.RLock() 取 revision,RWMutex 不可重入 -> 整个测试套件静默挂起。
   症状是不报错也不超时,只是永不返回;靠 go test -timeout 的 goroutine
   栈定位。
2. 缓存键漏掉 VM 维度:serializerCache 原按适配器名做键,导致同进程内两个
   VM 共享判定,适配器重写后新钩子永不生效且不报错。键改为
   (VM, revision, adapter),回归测试 TestSerializerCacheIsPerVM。

测试三件套:
- 功能:适配器接管 / 兜底(无适配器)/ 钩子失败退回兜底 / 缓存随改写失效
- 压力:TestSerializerForConcurrent(16 worker × 40 次)
- 回归:golden 字节级未漂移,Phase 0 安全网生效

验证:go test -tags luajit ./... 全绿(gateway 200s / lua 56s)。
2026-10-11 08:57:51 +08:00
1c6a2821e4 feat(provider): 端点机制去厂商化 —— 适配器声明 endpoint_stream
内核不再知道 Gemini 的流式动词。之前 provider.ChatURL 对
":generateContent" 做字符串替换(注释自认 "Gemini is the only such
adapter"),把厂商协议知识放进了调度器。

现在适配器自己声明:

    adapter.endpoint_stream = "/v1beta/models/{model}:streamGenerateContent"

内核只比较两个不透明字符串,四级优先级:

    source.endpoint_stream > source.endpoint > adapter.endpoint_stream
                                          > adapter.endpoint

- staticInfo.endpointStream + VM.StreamEndpoint() 编译期提取
- config.Source.StreamEndpoint(yaml: endpoint_stream)
- gemini.lua 声明 endpoint_stream
- 内核代码 generateContent 计数归零(仅注释/适配器持有)

首版实现踩了一个回归并当场修掉:源自定义 endpoint 时适配器的 stream
endpoint 会错误覆盖用户的显式路径。TestEndpointPrecedence 把四级优先级
钉死,防止再犯。

验证:go test -tags luajit ./... 全绿;golden 字节级未漂移。
2026-10-11 08:30:39 +08:00
3ad46ea23e feat(types): 统一线格式 v2 最大集合 + 对外输出 golden 基线
Phase 1:内核定义市面所有主流协议字段的**并集**作为唯一内部格式,
适配器按需取用;内核不再携带任何协议形状。

- internal/types/maxwire.go:UnifiedRequest(21 字段,v1 的 10)、
  UnifiedResult(11,v1 的 8)、UnifiedDelta。覆盖 openai/anthropic/
  gemini/ollama/deepseek 各自必需字段(system 顶层、top_k、stop_sequences、
  thinking 预算、parallel_tool_calls、response_format、safety/citations…)。
- 只带**语义**不带**容器**:generationConfig/systemInstruction/options/
  num_predict/maxOutputTokens 等协议专属形状一律不进统一格式,由适配器
  自行装填。测试 TestNoProtocolContainerLeak 钉死这条。
- 修掉 v1 的私有字段泄漏:disable_thinking/extra_body 曾是内核私有字段,
  却被序列化进交给适配器的 body,导致 10 个适配器各自写
  `req.disable_thinking = nil` 清理。v2 用 thinking 语义字段替代,
  TestNoKernelPrivateFieldLeak 为回归。

Phase 0:建立对外输出的**字节级** golden 基线(.snapshots/),
因为现有 e2e 只有 strings.Contains 宽松断言,无法证明 Phase 2 把
OpenAI wire 移交给适配器 serializer 后输出字节未变。

- e2e/wire_golden_test.go:非流式/流式/生图三个 golden,
  比对前 scrub 掉 id 与 created(每请求变化),其余逐字节比对。
- 反向验证:注入 `chat.completion.chunk.DRIFTED` 漂移后 golden 确实失败
  (证明它不是永远通过的空壳),随后完整恢复。
- 稳定性:连跑 3 次全绿。

验证:go test -tags luajit ./internal/types/ ./e2e/ -run 'TestMax|TestNo|TestWireGolden' 全绿。
2026-10-11 08:24:58 +08:00
6113924655 chore(adapters): 移除非官方调用方式的适配器,保留官方适配器与模板
移除 4 个靠伪造客户端身份/绕过 WAF 才能工作的适配器,内核不再发行任何
非官方调用方式:

- opencodezen / opencode / opencodego:靠 x-opencode-* 客户端指纹冒充官方
  客户端;zen 免费池明确要求「free tier can only be used in OpenCode」
- agentrouter:伪造 Origin: qwen.ai + Referer + X-Requested-With 绕过阿里云 WAF

保留 12 个官方适配器(openai/deepseek/anthropic/gemini/github/groq/mistral/
ollama/kimicode/qwen38/sensenova/trae),其中 sensenova/qwen38 实测零伪装特征。

本地保留两处:adapters/_local_archive/(gitignored 非递归子目录,不加载)
与线上 /etc/llmsproxy/adapters/(adapter_dir 权威副本,cp -f 不删文件)。

测试同步:删除仅服务于被移除适配器的 11 个测试函数与 usage_cost_test.go;
改写 2 处借 opencode 之名的 provider 测试为用 openai 的 HTML 错误页压缩能力
验证同一契约(适配器钩子拥有错误格式、原文不外泄)。

验证:go test -tags luajit ./... 全绿;二进制内 x-opencode-client /
QwenCode/0.2.0 / qwen.ai 等指纹字符串计数归零。
2026-10-11 08:17:56 +08:00
d3203d88fc feat(plugins): usage 插件进仓,随包播种
之前只通过 POST /api/plugins 装在生产上,源码仓里没有 —— 于是
core-dist.sh 的 deb/rpm/tar.gz 与 GUI 的 electron-builder 产物都不带它,
/v1/usage 在任何拿到安装包的人那里都是 404,页面显示「后台未开放消费接口」,
看起来像坏了而不像少了个可选功能。billing.lua / quota.lua 一直都是 embed 的,
usage 与它们同级。

四个测试(internal/lua/usage_plugin_test.go)盯的是三件容易在改 Lua 时被
无声破坏的事:

1. 遮罩算法必须与 Go 的 keyID() 一致。/api/stats 的 map 以掩码(***f9a9ed)
   为键,handler 收到的是完整 key;传完整 key 不报错,只是静默返回 0。实测:
     ?key=***f9a9ed        → reqs=33847
     ?key=sk-gw-7ffe…9ed   → reqs=0
   端点于是对一个有几十万次请求的 key 报「0 次」。
2. 不得读 PLUGIN.state / prices / 任何凭据:它报的是**调用者**的用量,
   读配置里的 key 会把一个租户的花费报给所有租户。
3. 取不到数据必须报「usage unavailable」,不能返回 0:0 会被渲染成
   「已用 0,0 次」,那是「这个 key 从没被用过」这个不同的、且错误的断言。
4. period 默认 all:日维度看起来像每天清零。

反向验证过 roles 声明:撤掉后 TestUsagePluginShipsAndGuardsItsOwnKey 变红。

(cherry picked from commit ae2b121959)
2026-10-07 17:37:22 +08:00
7836b95a84 fix(gateway): ?key= 传完整 key 会让 /api/stats panic
给 usage 插件探路时撞出来的,与插件无关。

Snapshot 的统计 map 以**掩码** key id 为键(keyID(req.Key))——by_key 返回什么、
面板就传什么。而 exportKey 对 admin 允许 ?key= 任意值,于是:

    byKey = map[string]*Stat{key: s.byKey[key]}   // 传完整 key → 零值 nil *Stat
    ...
    rows() 里 Stat: *v                          // 解引用 nil → panic

实测(改动前,线上):

    /api/stats?period=day&limit=1&key=sk-gw-7ffe12c5cbacec40bd39a7291df9a9ed
    → curl: (52) Empty reply from server
    → 日志: http: panic serving …: invalid memory address or nil pointer dereference
        llmsproxy/internal/gateway.rows  stats.go:771

net/http 会 recover 单个连接所以进程不死,但客户端拿到空响应,日志里每次一条
panic 栈。任何「这个 key 用了多少」的问法(key 已配但当天无流量、period 把它
的历史滤掉了)都会命中 —— 而零正是那个问题的答案,不是错误。

三处防御,因为同一个错误形状有四个入口:
- rows() 跳过 nil 而不是解引用(调用方不止 Snapshot)
- byStatus 的两处遍历同理
- PeriodSnapshot 的 buckets[k] 同理

by_key 仍保留该 key 的一行零值:面板按行存在与否渲染,缺行会让 key 整个消失。

判据:internal/gateway/stats_unknown_key_test.go(5 个测试)。反向验证过——
把 Snapshot 与 rows 的修复撤掉,TestSnapshotUnknownKeyDoesNotPanic 立刻 panic。

(cherry picked from commit af977efbf7)
2026-10-07 17:37:22 +08:00
b6d0358079 test(harness): 变异测试拒绝在脏工作区上运行
两个变异 harness(Go 版 discover、Python 版 quota)都是「把变异写进源码
文件、跑测试、再还原」。这个形状有两个陷阱,上一轮都真踩到了:

一、脏工作区上的 baseline 是错的

变异被覆盖写入实现文件。如果文件本来就有未提交改动(上一轮被 background
task 超时杀掉、Ctrl-C 中断、编辑器里改了一半),那么:

  - 捕获的「原始内容」不是已提交的源码,baseline 跑的是别的东西
  - 留下的那个变异会被下一轮当成 baseline

两种情况下所有裁决都毫无意义,而 harness 仍然报出干净的分数 —— 正是
「判据失效」最坏的一种:它不报错。

改为直接拒绝运行(Go 版 t.Fatalf,Python 版退出码 2),并且不自动 checkout:
脚本分不清「残留变异」和「人正在改」,静默回退真实改动比停下来更糟。

二、注入必须原子,还原必须覆盖所有退出路径

Python 版原来用 open(path,"w"),就地截断。与它并发的构建(background
go test、编辑器 gofmt、编译器自己读文件)可能看到写了一半的文件,报
syntax error,然后被当成 COMPILE 静默豁免 —— 一个真实的变异就这样被放过。
改为 tempfile + os.replace。还原登记到 atexit 与 SIGINT/SIGTERM。

三、路径必须相对当前目录

Go 版最初用 cwd=包目录 + 仓根相对路径,git 匹配不到任何东西,守卫永远不
触发。两个 harness 现在都用 ./name,各自在自己目录里跑 git。

两个方向都验证过:干净时全杀(discover 11/11、quota 12/12),脏时拒绝运行。

(cherry picked from commit d01712a697)
2026-10-07 17:37:22 +08:00
0858a747ca fix(lua): 导出函数调用不标脏,插件状态从不落盘
quota 声明 hooks=[],全部工作都在导出函数里。markDirty 只从 hook 与
SetState 触发,Call 两边都不占 —— 于是探测改的内存没人写盘,状态文件要等
下一次无关的配置写入才顺带刷新。

现象是「插件看起来没写」:负 memo 要等到下一次改配置才出现。用隔离实例
逐步观察时,一度把这误读成 memo 状态机的三个缺陷,逐个查清后只有这一条
是真的:

  - 「未就绪轮次累计 streak」 —— 不成立。写侧本来就对临时条目用 streak=1,
    65ed1b 版本已是这个行为。
  - 「读侧用旧快照,streak 永远停在 1」 —— 是退避的设计行为。单进程内第二
    次探测被退避挡在写侧之前,streak 自然不再增长;真实的多轮计数在跨进程
    (trace_streak.py)下是 1/2/3/4。
  - 「Call 不标脏」 —— 真缺陷,已验证:去掉 markDirtyLocked,
    新增的 TestOnDemandCallIsPersisted 立刻变红。

顺带保留两处无行为差异的可读性收紧:provisionalEntry 统一按「集合非空」
判断临时条目(与 copySet 空集返回 nil 的写法对齐)。

判据:TestOnDemandCallIsPersisted 用 hookless 插件走导出函数累加,
反向证明(去掉 markDirtyLocked)会红。

(cherry picked from commit 3444d2d903)
2026-10-07 17:37:22 +08:00
7934507179 fix(quota): 未就绪适配器污染负 memo;被拒凭据被后续 404 掩盖
端到端验证时撞出两个真缺陷,都是单元测试全绿、隔离实例打真上游才暴露。

一、负 memo 语义混用

负 memo 说的是「这台 HOST 没有余额端点」,且无条件在每次探测前生效。
openDesign 缺 control_key 说的是「这台部署还没配」—— 两件事共用一个条目:

  1. 未配 control_key → 探测 → 负 memo
  2. 操作员写入 key   → 探测被退避拦下,adapter 根本没跑
  3. 结果:修复看起来无效,源持续沉默(退避窗口可达数小时)

不能靠「skip 就不记 memo」解决:真实 vela 主机上另外三个 adapter 都返回
404(关于主机的持久结论,正是 memo 存在的理由),verdict 一定是普通 skip。
做法是 memo 条目带上「哪些 vendor 因自身未配置而无法判断」:
  - 未就绪的 adapter 直接不跑(跑了只能得到 401 或伪造的 skip)
  - 条目带 unready 集合;该 vendor 配置好后条目作废重算
  - 仍缺配置时条目依然写入(否则无看板的主机回到每次开页 60 秒超时),
    但不退避 —— 退避会拒绝那个本来能成功的探测

二、401 被下一个 adapter 的 404 掩盖

adapter 循环不停在 error,而「最后一个胜」不精确:openDesign 走厂商的整份
主机列表,是最可能看到「key rejected」的那个,但它也可能排在最前(memo 记
得它应答过,下次就优先它)—— 于是它的 401 被下一个 adapter 的
404「no dashboard here」分类成「这台主机没有余额端点」。两种情况的修复动作
完全相反,输出却一样。规则改为:被拒凭据优先,且不被覆盖。

判据:12/12 变异全杀。三条曾经存活或误杀,逐条查清:
  - rejected-as-skip 存活是因为测试没有复现生产调用顺序(memo 空 → openDesign
    排最后 → 旧规则也能过),测试已改为预置 memo 并让另三个 adapter 走同一个
    stub(它们读 base 而非 api_base,否则会全部传输失败)
  - no-cred-guard 并非存活,只是杀它的不是我以为的那个测试(门禁后面还有
    一道检查),预期已改正
  - unready-memo-not-dropped 是真等价变异(known=false 与 nil 只差一次廉价
    探活),已从清单移除

(cherry picked from commit 83f1ec0807)
2026-10-07 17:37:22 +08:00
f042bbf4af fix(plugins): 分片 VM 拿不到插件配置
页面探测走的是 fanout(分片)路径,结果写回走共享 VM。分片 VM 从同一份
源码启动,但没有 state 文件、没有插件身份,所以 PLUGIN.prices(配置,不是
累计状态)在分片里是空的。

对只读累计状态的插件无所谓;对按配置分支的插件就是致命的。OpenDesign
适配器读 prices.control_key,未播种时它对每个主机都得出「未配置」并报
skip,而同一个调用走共享 VM 是好的。也就是说:生产上死、所有调共享 VM 的
测试全绿。

pool 在每次取用时推送配置(不只是首次启动时),并把配置变化也带给池中
复用的 VM;推送是拷贝,分片拿不到共享表的引用。

这条不是在写代码时想到的,是端到端验证时撞出来的:单元测试全过,隔离实例
打真上游时 vela 报「未配置」,而 control_key 明明写进去了。

(cherry picked from commit 65075b6d44)
2026-10-07 17:37:22 +08:00
97a6cf9224 feat(quota): OpenDesign 出站主机加入白名单
插件出站白名单 = 已配置源的 host。这条规则对每个 adapter 都成立,直到
OpenDesign —— 它把推理和计费拆在两个域上:推理 amr-link,计费 amr-api。
adapter 从推理主机推导计费主机,于是它请求的 host 从来不是操作员配过的
那个,请求在离开进程前就被拒,报"不是允许的出站目标",读起来像插件 bug。

core.sourceHosts 现在同时加入派生主机,且仅当源本身就在 amr-link 上
(没有 OpenDesign 源的部署不会凭空获得这条授权)。

派生规则前后端各存一份,因为 Go 建白名单、Lua 建 URL,两边不一致的表现是
「请求被拒」而不是「命名不匹配的错误」。规则改成了完整主机名匹配:
`amr-link.` 前缀写法会把 amr-link.example.com 也改写成 OpenDesign 地址。

顺带修掉一个真缺陷:Lua 侧先 `gsub("/+$")` 再匹配主机名,而 base_url 是
`https://amr-link.open-design.ai/v1` —— 带路径时整串拿去匹配 `^...$`
永远不命中,改写静默失效,探测打到推理主机。改成先切路径。

这个缺陷是「行为断言」抓到的:上一版判据 grep 源码里的 gsub 模式,而
gsub 模式在改前改后都"看起来对"。8/8 变异全杀。

(cherry picked from commit 4a63b9dc0b)
2026-10-07 17:37:22 +08:00
d92db3a9fd feat(quota): 接入 OpenDesign/Vela 余额探测(controlKey)
vela 一直显示 skipped,因为计费端点需要第二个凭据:推理走源自己的
runtimeKey,但 amr-api.open-design.ai 的 wallet/balance 和
billing/summary 只认桌面端登录时下发的 controlKey(runtimeKey 返回
401)。全量后拿到了 controlKey,本机 /root/.amr/config.json。

测了实测行为(两个端点各 200,约 1.3s):
  wallet/balance   → balanceUsd / expiresAt / workspaceId
  billing/summary  → creditsPerUsd / membershipTier /
                     subscriptionCreditsUsd / totalConsumedUsd 等

实现要点:
- controlKey 走插件自己的 prices 表(插件系统本就按插件持久化一张
  prices,与 state 分离),用 PUT /api/plugins/quota/state 设置;
  base_url 保持推理主机,adapter 内部改写 amr-link→amr-api。
- 本账户 billingState=free,没有 codingPlan.windows,所以不设
  rep.windows:页面显示"无周期数据",而不是编一个 100%。
- creditsPerUsd 缺失时置 credits_rate_missing 并跳过积分换算,不默认
  10000;*Usd 字段是权威值,直接采用。
- 401 归为 error(凭据要重登),不是 skip(无计费端点),两者修复动作
  不同。
- skip 时把原因带上:区分「未配置凭据」和「上游无该端点」。

新增 7 条判据 + 7 个变异全部杀死(含一条"skip 丢原因")。变异 harness
第一轮 6/6 时 no-cred-guard 存活,原因是该测试没设 api_base,探测被
出站白名单拦掉、与缺少凭据无法区分 —— 判据失效而非代码问题,已修。

(cherry picked from commit 75a0d247a4)
2026-10-07 17:37:22 +08:00
0e747b0891 fix(ui): 生图与自动探测按钮把对象塞给了 fetch
`api()` 是 `(p, o) => fetch(p, o)`,第二个参数是原生 fetch options,
所以 `body` 必须是字符串。两处都传了对象字面量:

- 生图页 `body: { model, prompt, n, size }`
- 源编辑框自动探测 `body: { base_url, api_key }`

fetch 把对象转成 `"[object Object]"`,网关 json.Unmarshal 失败返回
400,请求根本没到上游。生图页的现象是 lat_ms: 0。

两处都改成 JSON.stringify + 显式 Content-Type。

判据在源码层扫描全文件的每个 `method: "POST"`,而不是断言 DOM ——
wire format 在 DOM 里不可见,这也正是为什么之前测过 curl 端点却漏掉
了按钮。这条全文件扫描当场抓到了第二个同源缺陷(自动探测按钮)。

(cherry picked from commit 2fdb82b119)
2026-10-07 17:37:22 +08:00
438629256d feat(ui): 生图二级页面 + 图像模型不再混进对话页
## ① 图像模型曾与对话模型不可区分

根因是结构性的,不是漏了个过滤:

    Provider.Models()   → []string   ← 只有 ID,Kind 被丢掉
    SourceStatus.Models → []string   ← 扁平列表

于是 /api/status 给前端的 34 个模型(29 对话 + 5 图像)长得一模一样。选了
gpt-image-2 就 POST 到 /api/chat —— 那个端点服务不了图像模型,必然失败,
而且报错完全不提「这是图像模型」。

修法:新增 ModelKinds []ModelKindInfo([{id,kind}]),纯增量、omitempty,
老 UI 不受影响;前端按 optgroup 分组,且这些 option 是 **disabled** 的。

分组还不够 —— 分组只是视觉分隔,operator 照样能选,选了照样失败。
disabled 的 option 会自解释(「请到生图页」),可选的只会失败。

★ 写这段时踩了一次 String.replace 的坑:`imgOpts.join("").replace(...)`
只替换**第一个**匹配,于是 6 个图像模型里只有第 1 个被禁用。浏览器实测
逐条读 e.disabled 才抓到(siliconflow 禁用了、vela 的 5 个没有)。
判据只看 value 里有没有 "image" 的话,第一次跑就会永远报警——那是判据
自己错了,比没判据更糟。

## ② 生图二级页面

侧栏新增 tab,与「对话」并列。网格展示 + 提示词输入 + 数量/尺寸,
调用 /v1/images/generations(不是 /api/chat)。

按钮显示已用秒数:实测该上游 15.7s~112.5s,波动 7.6 倍,不显示进度
看起来就像卡死了。

浏览器实测(几何测量,非仅 DOM 断言):
    tab-image 容器   986×523  visible
    img-grid         840×374  visible
    提示词输入框      634×64   visible
    生图页 6 个模型,全部 kind=image,全部可选
    对话页 6 个图像模型,全部 disabled;97 个对话模型全部可选

## ③ base_url 引导不足

文案直接给出截断位置和具体后果,而不是泛泛地说「填 URL」:

    填主机地址,通常不带 /v1 —— 网关会自动拼接路径。但各家约定不同:
    若填 https://x.com/v1,对话路径会正确,而图像生成会变成 /v1/v1/… 而 404。
    不确定就用上方「自动探测模型」。

/v1/v1 不是假设:ImageURL 是 BaseURL + ImageEndpoint(),而图像默认路径
是**绝对**的 /v1/images/generations,chat 的默认是相对的 /chat/completions。
两者对「版本段归谁管」的假设相反。今天在 vela 上为此排查了两次。

## 判据

新增 6 个契约测试:
- 对话页必须读 model_kinds(不读就会退回扁平列表)
- 禁用图像模型不能用 joined-HTML 的 String.replace(只改第一个)
- 生图页存在且注册进 TABS(漏了则 pane 永不可见,症状是「页面空白」)
- sendImage 必须打 /v1/images/generations
- urlHint 必须同时提到 /v1 和 404
- TestImageEndpointComposesUnderRealConditions 用真实形态验证:
  相对路径拼出 /v1/images/generations 得 200,默认路径拼出 /v1/v1/… 得 404
2026-10-06 15:02:42 +08:00
a873fe4724 test(discovery): 真实变异注入 11/11,并给 harness 加三道防线
前几版 harness 先后报过「4 个变异存活」「11 个一个没跑」「9 个存活」,
查清后**全部是判据或变异本身失效,没有一个是代码问题**。这一版把三类
失效都变成显式失败,而不是静默给出错误结论。

**防线一:先证明 filter 选中了东西**
`go test -run <完全不存在>` **不打印** `[no tests to run]`(那句话只在
「包内有其他匹配项但目标没跑」时出现)。我原先据此写的检查方向是反的,
harness 第一步就自我否决。现在改成对比数字:空 filter 必须选中 0 个测试,
真实 filter 必须选中 >= 25 个。本次运行:32 / 0。

**防线二:编译失败 ≠ 变异被杀**
嵌套调用漏 `-tags luajit` ⇒ 纯 Lua 5.1 ⇒ billing.lua 的 goto 编译失败
⇒ 整包 build failed ⇒ **每个变异都「被杀」,给出满分**。这比假绿更隐蔽。
现在 [build failed] 直接 t.Fatalf。

**防线三:变异没改变行为 ≠ 判据漏放**
两次「存活」查清后都是**恒等变换**:
  `&& out.BaseURL != ""`  — 判据用零值结构体,BaseURL 为空,条件恒假
  `&& len(out.BaseURL) >= 0` — len 恒 >= 0,条件恒真
正确写法是把赋值换成 `_ =`:读了变量让编译器满意,行为真的变了。
检测方式:变异后若结果行(剥掉测试名、排序后)与 baseline 完全一致,
报 no-op 而不是记成存活。
no-op 检测本身也修过:原来保留 === RUN 行(含测试名),两次运行测试顺序
不同 ⇒ 每个变异都「看起来变了行为」,检测形同虚设。

11 个变异覆盖:kind 判定、metadata 透传、非目录拒绝、URL 回退、
版本段搬移(chat 与 image 两条)、版本段识别、nil 切片、鉴权分类、
候选去重。

★ 判据的期望值不写死数字:候选去重修好后 len(cands) 从 3 变 2,
连改两次判据都是在追当前列表。改成断言「该覆盖的 URL 都在」。
2026-10-06 15:02:42 +08:00
31bc3968c5 fix(discovery): 裸 host 也必须有回退候选
补 image_endpoint 那一轮的测试暴露了一个覆盖缺口:候选构造是

    if !hasVersion { add(raw, "/v1/models", 0) }
    else           { add(raw, "/models",   0) }

裸 host(https://api.vendor.com)因此只生成**一个**候选,没有任何回退。
catalog 挂在裸 /models 上的厂商会被报成不可达。

为什么一直没发现:所有测试用的 base 都以 /v1 结尾。生产里唯一已配
kind:image 的 siliconflow(base_url https://api.siliconflow.cn)它的
/v1/models 恰好能应答——**一个能工作的巧合把这个洞盖住了**。

现在两个 catalog 路径无条件都试(偏好只决定「多个都成功时报告哪个」,
不决定「试哪些」)。同时修正优先级:base 已带版本段时第一候选必须是
相对的 /models,否则会先试 …/v1/v1/models——一个必然 404 的 URL,白花
一个来回,还让错误信息以无意义的 URL 开头。

判据同步改了:原本断言 len(cands)==2,那是**在测量当前的列表而不是
覆盖性质**,去重修一次漂一次。现在断言「该覆盖的 URL 都在」
(base+"/v1/models" 与 base+"/models"),实现怎么变都不会漂。

新增 discover_endpoint_test.go:
- 裸 base 不带版本段时 image_endpoint 必须留空,否则会写坏今天正常工作的
  siliconflow 配置
- 裸 host + catalog 在 /v1 响应时 endpoint 必须是 /v1/chat/completions
  (这正是我第一次把测试期望写反的地方:catalog 路径是上游要不要 /v1 的
  唯一证据)
- 两种 base 形态下最终拼出的 chat/image URL 逐字断言,且都不含 /v1/v1
2026-10-06 15:02:42 +08:00
c40b5b5981 fix(discovery): 错误分类 + 去掉重复请求 URL
演练错误路径时发现两个真问题。

**① 密钥错误被报成 502(上游故障)。** 这是最常见的失败,却和「厂商挂了」
给出完全相同的错误。两者需要的动作正好相反:重新输 key vs 去查厂商状态页。
操作员只能猜。现在 401/403 会得到 401 并在信息里点名 api key;
DNS 不解析、TLS 失败、连接被拒也各自带一句可执行的说明。

**② 错误信息里的 tried 列表有重复。** base "https://h/v1" + path "/models"
和 base "https://h" + path "/v1/models" 请求的是同一个地址,但候选去重只按
(base, path) 对,于是同一条 URL 在错误信息里出现两次——读起来像「试了两个
地方」,其实只试了一个。改成按最终 URL 去重。

去重后候选从 3 个变成 2 个,两个判据的期望值需要跟着改:
TestDiscoveryCandidates_DedupesAndPrefersTyped 和变异测试的
「fallback search」原本断言 >= 3,那是在测量 bug 而不是覆盖度。覆盖没有丢失:
剩下的两条是 https://h/v1/models 和 https://h/models,两个不同地址。

新增判据:
  TestDiscoverModels_WrongKeyIsUnauthorizedNotBadGateway
  TestDiscoverModels_UnreachableStaysBadGateway(防止过度纠正)
  TestDiscoveryCandidates_NoDuplicateRequests
  TestDiscoverModels_FailureListsEachURLOnce(对操作员真正读到的文本断言)
2026-10-06 15:02:42 +08:00
7ce0378e8b docs(discovery): 用实测数据替换注释里的猜测
composeImageEndpoint 原注释写着「两种形式都 404,无法区分」,那是我写代码时
的推测,不是观测。后来真实打了一次上游就分清了:

  /v1/images/generations    → 200(图像生成本身要几分钟,是上游耗时)
  /v1/v1/images/generations → 404
  /images/generations      → 404

注释里的假话比没有注释更糟——下一个人会拿它当「已知的模糊地带」而不敢
收紧判据。
2026-10-06 15:02:42 +08:00
ae9f56ddb3 fix(gateway): 探测同时算出 image_endpoint,修 /v1/v1 404
上一版只算了 endpoint,图像生成仍然 404。根因是网关两条拼接路径的默认值
规则相反:

    ChatURL  = BaseURL + Endpoint()       默认 "/chat/completions"      相对
    ImageURL = BaseURL + ImageEndpoint()  默认 "/v1/images/generations" 绝对

base_url 带 /v1 时 chat 拼成 /v1/chat/completions 正常,image 拼成
/v1/v1/images/generations 就 404。之前归一化 base_url 时修正了版本段的位置,
但没意识到图像这条路径的默认值自带 /v1,它跟 base 是各管一段。

探测现在额外返回 image_endpoint:base 末尾有版本段时给
/images/generations,否则留空用默认值。判据是 TestComposeImageEndpoint ——
它断言 base+结果绝不含 /v1/v1,这是那个 404 的字面形态。

apply 的继承逻辑相应改了:image_endpoint 不再从旧源继承。旧值是对旧
base_url 正确的,而 base_url 恰恰是这次可能变的东西,沿用它会留下一个对着
新 base 404 的陈旧值。前端 srcDiscover() 同步写入该字段。

端到端(隔离实例 18099,真实 amr-link 上游):
  探测     http 200,1.4s,29 chat + 5 image
  apply    http 200,落库 34 个模型
  文本     http 200,2.0s
  视觉     http 200,1.5s
  图像     路径修正为 /v1/images/generations(不再 404;上游本身 120s 超时,
           属其自身耗时,非路由问题)

探测对已存在的源返回 409 要求显式 replace_models=true,这个保护在验证中
确实生效了。
2026-10-06 15:02:42 +08:00