|
|
3819b00ded
|
fix(lua): 持久化的出站主机授权在重启后不生效(LoadDir 缺折叠)
生产实测暴露的内核缺陷:**重启后所有已批准出站主机全被拒**。
根因链:
1. `LoadKernelPermissions` 把 `.kernel-perms.json` 里的 `jp.Hosts` 装进
`ps.extraHosts`,但它明确注释「折叠由调用方负责」——**启动路径上没人负责**。
2. `SetOutboundHosts` 早已跑过(core.New 里,在 LoadDir **之前**),
于是 allowlist 只含 source 主机,插件声明的整套 host 全被拒。
3. 症状是经典的「重启丢掉运维的决定」:运维一打开插件页重新保存就恢复。
**它还静默打坏了定时任务**:`refresh_models` 在启动后 5s 补跑
(first_due_now),正好落在这个窗口里,于是每个平台的刷新都报
「host "zcode.z.ai" is not an allowed outbound destination」——看起来像
"白名单缺 host",实际是授权根本没被折叠。我按这个假象查了一圈(去核对
.kernel-perms.json 里 zcode.z.ai 明明在),才定位到折叠这一步缺失。
修法:LoadDir 末尾补 `rebuildOutboundAllowlistLocked()`。
判据:`TestPersistedHostGrantsSurviveRestart` —— 造一份持久化授权、模拟
启动顺序(SetOutboundHosts → LoadDir),断言已批准的 host 可用、
source 主机未丢、未批准的仍被拒。
**变异验证**:撤掉修复 → 判据变红(两条"被拒");加回 → 变绿。
回归:internal/{lua,core,provider,gateway} 全绿。
|
2026-10-14 10:55:50 +08:00 |
|
|
|
10ad4421f9
|
feat(channels): 自动领取得以真正跑起来 + 自动获取可用模型
用户要求两件事:① 自动签到与领取 ② 自动获取可用模型。
两者都有调度缺口,且能力表与参考真值不符(表错了只静默少领,无任何报错)。
## ① 自动签到的能力表(三处偏差,全部修正)
| 平台 | 我们 | 参考 | 后果 / 修法 |
|---|---|---|---|
| buddy | claim=true | 无 | **自相矛盾**:表说支持、claim_daily 说不支持(既无 checkin 也无 claim_fn)→ 改为只报余额 |
| raccoon | 空表 | 有 | 白丢一个可领渠道 → 补 claimRaccoon(POST /api/web/desktop/v1/login/points/grant,幂等判据是 `granted`,重复领取同返 200 但 granted=false) |
| zcode | claim=true 无实现 | auto-exclude | 补 claimZcode(3 步:event/report → preview → claim/plan),并照参考 isAutoCheckinExcluded 标记 auto_exclude |
**同为"表说支持、函数不支持"的还有 loomy** —— 补 claimLoomy
(POST /points/first-login;语义是**触发每日额度重置**而非 +5000 积分;
幂等判据 `alreadyProcessed` → already-claimed 而非 claimed)。
一条一致性判据抓出全部四例:声称 claim=true 的平台必须真的可领
(有 checkin 表或有 claim_fn)。
## ② 两个调度任务(此前只有每日领取一个)
参考有三个调度器,我们只有一个;模型获取**根本没有调度**:
- refresh_models:每 2 小时拉远端目录(照参考 MODEL_REFRESH_INTERVAL_MS),
成功后重同步该平台所有源(模型清单写在源里)。失败**不清缓存**。
- refresh_creds:每 30 分钟检查续期(照参考 REFRESH_INTERVAL_MS)。
⚠️ **不看 enabled**(照参考铁律:停用只影响选号,不该停续期);
⚠️ **只对距过期不足 1 小时的账号发请求**(照参考 expiry-sync),
否则启动时会打出几十个无谓请求。
## ③ 调度器新增 first_due_now(内核)
照参考的事后复盘:只用 interval 时,宿主重启后短寿命凭据(cline 1h /
codearts ~2h / raccoon 3h)要等满一个周期才续期,期间一直显示「已过期」、
积分行 401。故声明式加 first_due_now —— 任务在启动后 5s 先补跑一轮。
**且必须 opt-in**:真正周期性的任务(每日签到)不该因重启就重跑。
判据同时断言"auto_claim 不得带该标记"。
## 实测(生产)
- 模型刷新:buddy 真拉到 **22 个远端模型**(含兜底表没有的 hy4-preview、
step-5-preview、codewise-* 等);zcode 2 个;其余报"无可用凭据"(登录未完成)
- 推理链路:`POST /v1/chat/completions` 返回 "OK"
## 宿主排障(顺手)
VNC 端到端测试失败,查证**不是回归**(stash 改动后同样失败):宿主有
**10 小时前的孤儿 `Xvfb :91`**(PID 1074031)占着显示号,加上 126 个孤儿
SHM 段、18 个陈旧 X socket。按纪律(先 SIGTERM 再 SIGKILL)回收后测试通过。
另记一笔:`ps | awk '/[X]vfb/'` 这种写法会匹配到**执行它的 shell 自身**
(命令行里含该字样),清理必须用 /proc/<pid>/exe 精确匹配 + 保护祖先链。
|
2026-10-14 10:46:05 +08:00 |
|
|
|
faa3be5c37
|
test+docs(channels): 三私有协议平台的源接线判据 + 自装适配器机制实录
补齐"装了适配器 ≠ 源用了它"这条判据。这是这类改动最可能的漏配,且
**不会报错** —— 只是签名/信封不生效、上游返回流内错误或 401,看起来像
"平台不稳定"。三个平台的失败模式不同、根因同一个(平台协议差异被当成
适配器名差异):
codearts → 华为云 AK/SK 签名
trae → SOLO 载荷格式
gemini → 私有 /v1internal: 信封 + 身份五头
判据同时验 adapter 名、base_url 与"源里必须有模型"。
docs/channels-parity.md 补两节:
- 三个私有协议平台已改用插件自装适配器(含 credential_shape 的引入原因:
codearts 凭据没有 access_token,credentialOf 取不到 → 静默不出源)
- 附录:机制(内核早已具备,channels 插件引用数为 0)、三条实测能力、
六个"静默失败"类踩坑、判据清单
|
2026-10-13 23:44:30 +08:00 |
|
|
|
8e2d384a7b
|
feat(channels): Gemini 用插件自装的私有协议适配器(channels-gemini)
Cloud Code 凭据**不能**打公开的 /v1beta/models/{model}:generateContent。
参考 gemini.ts 点名三件必须保留,我们此前**一件都没有**(gemini.lua 产的是
公开的 contents/parts 裸体、一个身份头都不发,端点还指着公开路径):
1. **身份五头逐字写死、不许随机**,且**不带** x-goog-api-key /
x-goog-api-client(User-Agent/x-client-name/x-client-version/
x-machine-id/x-vscode-sessionid)
2. **流式刻意不带 Accept**(抓包一致)
3. **信封逐层字母序序列化**(上游按字节比对;Go 的 encoding/json 对 map
自动字母序,而我们用 json.encode,故须自己排——实现 sorted_json)
信封形状(私有端点要的不是裸 contents):
{model, project, request:{contents, systemInstruction, tools,
generationConfig, sessionId}, requestId, userAgent}
响应也是信封(candidates 嵌在 response 里)→ 必须**剥一层**,否则下游
拿不到 candidates,表现为"模型没回话"(静默失败)。
另加前导助手 gemini_response / gemini_stream_chunk(candidates/parts/
usageMetadata → 统一信封;STOP→stop 等 finishReason 映射)。
★ 踩到的两个坑:
- **Lua 长字符串里不能有字面 NUL 字节**(Python heredoc 写的 "\0" 变成了真
字节)→ Lua 报 "unfinished long string near '<eof>'",且报在文件末尾,
完全指不到现场。
- **本 Lua 构建不支持按位异或 `~`**(unexpected symbol near '~')→ FNV-1a
的 XOR 改用算术等价形式。参考自己明言 sessionId 的哈希本体未反推出来、
数值不与原版逐字相同,所以这里要的是**确定性 + 依赖维度正确**
(吃 project 与首条文本、不吃 model),不是数值一致。
- 另给三个自装适配器统一加**本地预编译护栏**:内核装载失败只回"源码无法
编译"不给行号;先 load 一次就能拿到行号(上面两个坑都是靠它定位的)。
⛔ 仍不做:gemini-sigstore(签名持久化,268 行)——推理走无状态适配器,
插件侧缓存会成为死代码。理由已记在 docs/channels-parity.md。
判据(internal/lua/channels_gemini_adapter_test.go):走真实路径断言
身份五头逐值、**反向断言** x-goog-api-key/x-goog-api-client/Accept 不得出现、
信封五个顶层字段齐全、顶层键字母序(解析式校验,不是字符串匹配)、
响应剥信封后 content/finish_reason/token_usage 都到位。
回归:internal/{lua,core,provider,gateway} 全绿。
|
2026-10-13 23:42:04 +08:00 |
|
|
|
ff9b460eba
|
feat(channels): TRAE 用插件自装的 SOLO 适配器(channels-trae)—— 载荷真转换
TRAE **不是 OpenAI 兼容**。参考 trae.ts:30 点名:「载荷必须转换:OpenAI 的
{model,messages,tools,tool_choice,stream} 需要映射为 SOLO 格式(function、
config_name 等字段),不能透传」。上一轮我把端点改对了(/api/agent/v3/
llm_utils_chat)但**载荷仍原样直发** —— 上游会回流内 4001,而 HTTP 状态码
仍是 200,属静默失败。
本次补齐 transformToSOLOBody 的 7 条规则(逐条对齐参考):
1. messages.content 字符串 → [{type:"text",text:…}];数组透传
2. stream 强制 true(非流式由服务端聚合)
3. model → config_name + model **双字段**(支持 __dev 后缀消除)
4. function 通道名,缺省 solo_work_lite
5. tool_choice 归一化:none → 删 tool_choice **与** tools;
auto/required 保留;{type:function,function:{name}} → 字符串 name
6. assistant 的 tool_calls: function → function_call,并剔除无 name 的条目
(孤儿 tool_call 会让整条会话被拒)
7. tools[].function.parameters: object → JSON **字符串**(SOLO 要求)
★ 命名为什么是 channels-trae 而不是 trae:内置 trae.lua 服务的是**运维自建
的 trae-local-api 反代**(base_url 127.0.0.1:19900,生产 config.yaml 在用),
与本插件的直连通道是两回事。重名会被内核冲突仲裁挂起(正确的护栏)。
判据里专门断言"内置 trae 未被顶掉"。
★ 踩到的坑:`function` 是 Lua 关键字,写 `body.function = x` 是**语法错误**
('<name>' expected near 'function'),必须 `body["function"]`。它只在 load()
时才暴露 —— 插件脚本本身能编译通过,适配器源码要运行期才报错。
判据(internal/lua/channels_trae_solo_test.go):走**真实路径**
(装插件 → 调已安装的适配器),而不是在插件 VM 里 load 一份副本 ——
副本拿不到内核注入的共享前导,也发现不了"装了但没生效"。逐条断言 7 条规则
+ 三处反向断言(extra_body 必剥离、无 name 的 tool_call 必剔除、
tool_choice=none 时 tools 与 tool_choice 都必须消失)。
回归:internal/{lua,core,provider,gateway} 全绿。
|
2026-10-13 23:28:27 +08:00 |
|
|
|
1f54143956
|
feat(channels): CodeArts 用**插件自装的专用适配器**接入(华为云 AK/SK 签名)
这是"为什么参考正常、我们不正常"的正面修复。参考每个平台是继承宿主
LlmAdapter 的真类(CodeArtsAdapter 自己做 SDK-HMAC-SHA256 签名);我们
此前把平台协议差异当成 adaptter 名差异(全塞 openai),签名无从表达。
内核其实早已实现 __llmsproxy_adapters.install(含归属校验/冲突仲裁/
测试),但 channels 插件对该全局的引用数是 **0**。本提交第一次真正用它。
改动:
1. 插件在加载末尾装自己的 codearts 适配器(CODEARTS_ADAPTER_SRC):
build_headers 里做华为云 SDK-HMAC-SHA256 签名
(canonical = METHOD\\nURI\\nQUERY\\nCANONICAL_HEADERS\\n\\nSIGNED_HEADERS
\\nPAYLOAD_HASH;Access= 不是 Credential=;URI 补尾斜杠;
Agent-Type/X-Language 签名后追加不进 canonical)
2. codearts 平台 adapter=codearts,新增 credential_shape="full"
3. syncAccountSource 支持 credential_shape:签名平台要**完整凭据 JSON**
(适配器自己解 AK/SK),其余平台仍交裸 access token
★ 途中挖出的两个真实缺陷(都是"静默失败",没有任何报错):
① **凭据形状不匹配 → 不出源**。syncAccountSource 用 credentialOf 取
access_token,而 codearts 凭据是 {access_key_id, secret_access_key,
security_token} —— 没有 access_token,于是 tok 为空、函数静默 return。
加了 credential_shape="full" 解决。
② **add_source 失败是静默的**。内核拒绝(插件无权限、形状不符)时
syncAccountSource 不记日志,表现为"账号加了但不出源",排查无线索。
现补上失败日志(区分内核 err 与 json.ok==false)。
我自己就被 ① 绕了一圈:用 kernelTestCore 裸加载插件(没授 add_source
权限)时源一直不出,直到把内核原始返回值带出来才看到
"kernel action add_source is not granted to plugin channels"。
——判据里必须能看到**真实函数的返回值**,否则只能在黑盒里猜。
判据(全绿):
- TestCodeArtsAdapterInstalledByPlugin:适配器确实被装上、归属是 channels、
且**用 Go 标准库独立复算 HMAC** 验签(形态对但算法错时形态断言全绿,
复算才咬得住);另断言 canonical 不含 authorization/agent-type、
URI 有尾斜杠
- TestCodeArtsSourceUsesPluginAdapter:源**真的**引用 codearts 适配器
("装了但源还指向 openai"是这类改动最可能的漏配,且不报错)
回归:internal/{lua,core,provider,gateway} 全绿。
|
2026-10-13 23:14:04 +08:00 |
|
|
|
a7a0c6adee
|
refactor(lua): 11 个适配器改用共享前导 —— 删掉约 20KB 逐字重复
按前导抽出三块共通实现(都是实测逐字节相同的):
| 钩子 | 原本重复 | 各 2.2KB |
|---|---|---|
| transform_stream_chunk | github/groq/kimicode/mistral | 2210B × 4 |
| transform_response | openai/groq/kimicode/mistral/github | 2206B × 5 |
| transform_error | deepseek/github/groq/kimicode/openai/sensenova | 292B × 6 |
适配器总行数 2409(收工前 groq 单文件 146 行 → 46 行)。
★ 两个必须记下的教训(都在本提交里踩到并修好):
1. **抽公共代码要抽最全的实现,不是最小的**。我先抽了 transform_error 的
最小版(只认 {error:{message}}),打挂了 TestOpenAITransformErrorEnvelopes
的 6 个子用例 —— 最小版丢掉了 HTML 错误页、裸文本、扁平 {code,message}、
FastAPI {detail} 等形状。openai.lua 原有的 1580B 版本才是该进前导的那份。
抽最小实现是**静默降级**:不报错,只是少认几种错误体。
2. **抽出共性时不能丢掉"非共性但重要"的细节**。openai_response 我漏了两处:
reasoning_content 透传、以及 tool_calls 的 id 规整(safe_tool_id)。
后者尤其危险 —— 本网关是扇出路由器,一个非法 id("bash:0")会被回放到
每个 Claude 上游导致整条 AUTO 链失败;且**流式与非流式必须同规则**,
否则同一请求两条路径产出不同 id(实测报错:
流式 "bash_0_40248ca0" vs 非流式 "bash:0")。现两条路径都走
__prelude.tool_id。
判据:TestPreludeRefactorIsByteIdentical —— 固定输入下逐字节比对
(含"arguments 必须解码成表""usage-only chunk 必须是 done:false"
"arguments 不得留原样 JSON 字符串"等反向断言),并覆盖错误体 6 种形状
与"非 OpenAI 形状必须返回 ok=false"。
另修 channels_pool_test:断言改为**关系**(关一个模型源里少一个)而非写死
数量 —— 兜底目录会随对齐进度变化(minimax 3→4),写死数量会把正常的目录
扩充变成测试失败。
回归:internal/{lua,provider,core,gateway} 全绿。
|
2026-10-13 22:51:50 +08:00 |
|
|
|
741edfea98
|
feat(lua): 适配器共享前导 —— 消除 12680 字节的逐字重复
问题(实测证据,按钩子做字节级哈希分组):
transform_response github/groq/mistral 三份完全相同(2207B × 3)
transform_stream_chunk github/groq/kimicode/mistral 四份相同(2211B × 4)
transform_error deepseek/github/groq/kimicode 四份相同(293B × 4)
合计 12680 字节完全重复。openai.lua 自己的注释就写着
「(anthropic.lua carries the same helper; Lua adapters have no shared
prelude.)」—— safe_tool_id 在 openai.lua 与 anthropic.lua 各复制了一份。
参考插件有 src/openai-compat.ts(1196 行,12 个 adapter 里 8 个 import),
我们没有对应物。本提交补上这一层。
做法:**编译期把一份中性助手库拼在适配器脚本前面**,注入点在
LoadAdapterSource —— 适配器有三个编译点(worker boot / HasFn 探针 /
inspectScript),全部源自这里存进 pool 的 script 文本,拼一处即全覆盖;
且不像运行时 SetGlobal 那样会在热重载/池重建时丢失。
助手只做**协议层面的中立清理与组装**,不含任何厂商名(守住内核零厂商名
纪律):decode_body / strip_control(剥内核私有控制字段 —— 那是网关内部
约定,v2 改一次就要改 12 个文件)/ unified_skeleton / finish_reason /
usage(含 cache 细分,属协议族已知变体)/ choices / tool_id(路由器性质:
坏 id 会被回放到每个 Claude 上游,一票否决整条 AUTO 链)/ derive_key。
判据两条:
- TestAdapterPreludeInjectedAndCorrect:不只断言存在,抽查**语义值**
(end_turn→stop、tool_use→tool_calls、合法 id 原样透传、非法 id 带摘要
改写、cache_hit 从 prompt_cache_hit_tokens 取)
- TestAdapterPreludeDoesNotChangeExistingAdapters:旧适配器(自己实现全部
钩子、不引用前导)输出逐字节不变
回归:internal/lua + internal/provider 全绿(16 个适配器行为不变)。
|
2026-10-13 22:34:13 +08:00 |
|
|
|
ed1e250452
|
test(lua): 锁死插件适配器的三条能力(实测,非推断)
为协议转换类平台(trae SOLO / gemini 信封 / codearts AK/SK 签名)装专用
适配器铺路,先把能不能做实测清楚:
① 能力:适配器**能**通过 build_headers 的 meta.api_key 拿到凭据
(AK/SK 签名的前提),且**拿不到** __llmsproxy_secrets —— 该全局只注入
插件 VM(plugin_bridges.go 开篇明说),安全边界完好。
同时验 url/method/body 三个签名字符串原材料都能拿到。
② 能力:transform_stream_chunk **跨 chunk 保持状态**(5 个 chunk 拿到同一个
Lua 状态,表地址恒定,calls 1→5)。这是 trae 跨帧累积 tool_calls、
gemini 跨帧剥信封的前提。此前只是从池化 LIFO 复用**推断**的,现实测钉死。
③ 能力:适配器能用内核的 sha256_hex(用标准向量 sha256("abc") 校验),
AK/SK 签名不需要外部进程。
背景:内核早已实现 __llmsproxy_adapters.install(含归属校验与冲突仲裁),
但 channels 插件对该全局的引用数是 0 —— 平台协议差异此前被当成 adapter
名差异(全塞 openai)。
|
2026-10-13 22:28:34 +08:00 |
|
|
|
4a90099fb2
|
fix(channels): Gemini 接入改私有 /v1internal: 动词路径 + 源端点声明式覆盖
两条线一起修:
1. Gemini 接入模型服务用错协议。Cloud Code 凭据**不能**打公开的
/v1beta/models/{model}:generateContent(参考 gemini.ts:78 明确走私有
/v1internal:generateContent;流式 /v1internal:streamGenerateContent?alt=sse)。
此前源注册沿用适配器的公开路径,凭据与端点不匹配。
2. syncAccountSource 支持平台声明 endpoint / endpoint_stream / headers 覆盖。
平台的 chat 端点常不是 base_url 的简单拼接(Gemini 私有动词、zcode 只有
一条 plan 路径),用声明式覆盖表达;内核只把它们当**不透明字符串**,
不引入任何厂商知识(守住内核零厂商名纪律)。
判据:逐平台比对 base_url+endpoint 与参考真值,11/11 一致。
|
2026-10-13 17:02:58 +08:00 |
|
|
|
59229990ac
|
fix(channels): 修正 zcode 接入端点(须带 /api/v1/zcode-plan/anthropic 前缀)
参考 zcode-anthropic.ts:7 注释点名:该平台**只存在**
/api/v1/zcode-plan/anthropic/v1/messages 这一个 chat 路径,
openai 与裸 v1/chat/completions 一律 404 page not found。
此前 base_url 只填 https://zcode.z.ai,拼出的地址必然 404。
|
2026-10-13 17:01:06 +08:00 |
|
|
|
6238265502
|
fix(channels): 修正各平台接入模型服务的 chat 端点(base_url 缺路径前缀)
接入源时 base_url + adapter.endpoint 拼出的 chat URL,此前**全部缺路径前缀**,
对着参考逐个核对后发现 9 个平台里 9 个都不对(点开一看全是 /chat/completions
的裸拼接):
- trae → /api/agent/v3/llm_utils_chat(非 OpenAI 兼容,SOLO 端点)
- cline → /api/v1/chat/completions
- lobsterai → /api/proxy/v1/chat/completions
- qoder → /model/v1/chat/completions
- buddy → /v2/chat/completions
- codearts → /api/v2/chat/completions
- opencode → /zen/v1/chat/completions
- raccoon → /api/web/llm/v2/chat/completions
- loomy → /chat/completions
改法:base_url 直接含完整 chat 路径(适配器 endpoint 保持通用),
注册源时即为最终地址。判据:逐平台与参考真值字符串比对,9/9 一致。
⚠️ 已知未闭环:trae 的**载荷**仍是 OpenAI 形状直发,而参考要求转 SOLO
格式(config_name/function,trae-adapter.ts:374 有 serializeMessages)。
该转换属独立性工作,下一步单独处理。
|
2026-10-13 17:00:04 +08:00 |
|
|
|
4bdde9cd61
|
fix(channels): 逐平台对齐参考插件的模型目录(兜底表全量)
照参考 *-product.ts 的 FALLBACK_MODELS 逐项补齐,此前每个平台都只挑了
几个"看着像"的 id,属偷工减料:
- trae 7 → 28 可见 + 4 hidden(照 isHidden 语义单列 models_hidden,
不污染用户可选列表:browser_use_subagent / explore_sub_agent_v13 /
explore_sub_agent_v2 / summary 都是内部子 agent)
- lobsterai 3 → 19(远端 /api/models/available 须带 model_flag,否则少
kimi-k3 —— 参考注释点名)
- opencode 3 → 14,并带 models_free(isFree 是官方语义:7 个 free 匿名可用)
- qoder 17 → 19(补 CN 独有的 q37fmodel / gm51model,均 1M 上下文)
- minimax 3 → 4(补 MiniMax-M2.7)
model_list 相应支持 models_hidden(不进可选列表)与 models_free(isFree 字段)
—— 此前 isFree 被硬编码为 false,把官方免费模型标成了付费。
对齐后与参考逐平台零差异(比对脚本结果):trae / minimax / cline /
lobsterai / opencode / qoder / zcode / loomy / raccoon 全部一致。
|
2026-10-13 16:56:21 +08:00 |
|
|
|
70c24ba158
|
fix(channels): CodeArts 改为真实 IAM OAuth 登录(PKCE + DPoP ES256)
此前标成 flow="paste"(手填凭据)是**错的**:参考 login.ts/oauth.ts 默认走
新式 IAM OAuth —— 起本地回调服务器(端口须 ≥10000)、打开 portal 授权页、
收 code 后带 DPoP 证明向华为 STS 端点换 AK/SK/securityToken。
逐项对齐参考:
- authorize URL:code_challenge_method=SHA-256(**不是** S256,错值会让
portal 回退旧 ticket 流程)、theme=2、locale=zh-cn、plugin-name=snap_AIIDE、
plugin-version=5.2.0,且**不得**含 auth_callback_url
- 回调路径 /oauth/callback,端口 ≥10000(低于此 portal 拒绝)
- PKCE:verifier=48 随机字节 base64url,challenge=sha256(verifier) base64url
- DPoP:ES256 JWS,header{alg,typ,jwk} + payload{htm,htu,iat,jti},
签名为 **raw r||s 64 字节**(openssl 出 DER,必须转格式)
- token 请求:POST 表单 + DPoP 头;终态只认 invalid_grant /
ExpiredRefreshToken(**不认** InvalidDPoPHeader)
- 凭据含 refresh_token/code_verifier/dpop_private_key_jwk 以支持静默续期
- 旧 ticket 流程保留作回退(pollForCredential 语义)
本机实测的坑(已修):xxd 不存在(用 od);openssl ec -text 会截断 priv
(d 用 asn1parse 的 [HEX DUMP],tail -c 32 拿到的是公钥 y);min_port 参数
读到 argv[2](与 out_path 冲突)。
判据(4 条,全绿):
- TestCodeArtsDpopJwsVerifiesAsES256:用 Go crypto/ecdsa 当权威验签器,
验 JWS 是 64 字节 raw 且真实可验(只断言"能拼出字符串"防不住格式错)
- TestCodeArtsOAuthEndToEnd:假 STS 上游,检查交换请求形态(DPoP 头、
grant_type、code_verifier、redirect_uri)与凭据含续期材料
- TestCodeArtsLoopbackPortAtLeast10000:量实际端口与回调路径
- TestCodeArtsSignatureMatchesReference:既有签名判据
|
2026-10-13 16:51:15 +08:00 |
|
|
|
c1733feeaa
|
feat(channels): Qoder 每日活动领取(多活动逐个领)+ 三条误报陷阱全部钉死
## 参考依据
`qoder-credits.ts` 的 `claimQoderDailyCheckin` / `parseQoderCampaigns` /
`claimQoderCampaign` / `hasQoderCampaignRefreshedToday`。
参考用**抓包 + 用户报障**定位过三条会「误报」的陷阱,全部在实现与判据里钉死:
### ① 「无可领活动」必须是 inactive,不能报 already-claimed
旧实现 targets 为空就说"今天已领取" ⇒ 只要服务端没下发可领项(含**请求头不完整**、
未到刷新时间、本就无活动三种情形),界面就谎报已领,与官方 IDE 的"可领取"直接矛盾。
用户报障原文:「没领过却显示今日已领取,去 IDE 看还能领」。
### ② 每日 10:00(UTC+8)刷新;刷新前的 CLAIMED 属于**昨天**
此时说"今天已领取"是谎报,且不可逆(用户以为今天不必再领)。
必须用**算术平移**判 UTC+8 —— 用本机时区在出差/改时区时会判错方向
(偏东提前把当天记为已处理、真漏领;偏西一天判两次)。
### ③ 未开通要给 action_required 的可操作提示
判据是**两条同时**满足(无 CLAIM_BENEFIT + 用量响应里 `addOnQuota` **字段缺失**)。
⚠️ 用「字段是否存在」而非「remaining 是否为 0」—— 后者对"额度用光"与"从未开通"
不可区分,会把用光额度的老账号误报成未开通。文案要可操作(去官方客户端登录一次),
并作为显式字段透传给 UI,而不是让 UI 猜文案。
### 其他要点
- 一个账号可能同时有**多个** CLAIM_BENEFIT 活动 → 逐个领取(实测有每日 100 与运营活动)
- 幂等判据是响应体的 **`replayed`**,不是 HTTP 状态码:重复领取同样回 200,
但 `replayed:true` 且不含 benefit(只看状态码会把"今天已领"误报成"成功 +100")
- 请求体必须是**空串**(抓包实测 `content-length: 0`)
- 缺 `Cosy-MachineToken/MachineType` 时服务端不下发可领活动(见上一个提交)
## 判据
- `TestQoderClaimDecisionTable`:6 个用例覆盖四类结果 + claim 次数 + body 必须为空
- `TestQoderRefreshUsesUTC8NotLocalTime`:**注入 UTC 时刻**验证刷新判定
★ 修判据自身两处缺陷(首轮变异存活):
1. `TestMain` 把测试进程 TZ 钉成 **UTC** —— 本机恰是 HKT(+0800),于是
「UTC 平移」与「本机时区」结果**完全相同**,误用本机时区的实现抓不到(判据不可达)。
2. 决策表缺「无任何活动且已开通(addOnQuota 存在)」用例 →
把 inactive 误报成 already-claimed 的变异得以存活。补齐后两个变异都被抓。
## 统一前向声明(Lua 作用域,本文件第 N 次)
把 `qoderMachineIdentity / rfc3986Escape / newToken / lobsteraiClientVersion`
统一在 PLATFORMS 之前前向声明。此前逐个补、位置还放错过一次,导致运行时报
`attempt to call global 'x' (a nil value)` 而编译期静默。
## 文档
`docs/channels-parity.md` 更新:gemini-sigstore 标注为**⛔ 不适用**
(llmsproxy 的推理走无状态适配器,插件不参与推理 —— 插件侧缓存会成为死代码;
若将来要支持,位置在适配器侧,与"适配器无状态"约定冲突,须单独决策)。
|
2026-10-13 16:11:11 +08:00 |
|
|
|
5b433b3266
|
feat(channels): Qoder 设备身份(Cosy-MachineToken/Type)—— 活动可见性的前提
## 参考依据
`qoder-machine.ts`。参考用**抓包 + 消融实验**定位过一个真实缺陷:用户报
「Qoder 没领过积分却显示『今日已领取』,去 IDE 看还能领」。
根因:`/sash/` 端点不只要 `Cosy-ClientType`,还校验**设备身份**。消融表(同账号、只改头):
| 请求头 | /sash/api/v1/me/campaigns |
|---|---|
| 仅 Cosy-ClientType: 10 | `claimable:false`,1 条 VIEW_DETAILS |
| + MachineToken + MachineType | **`claimable:true`,2 条,含 CLAIM_BENEFIT/amount:100** |
| 全套 − MachineToken(或 − MachineType) | 退回 1 条(失效) |
⇒ 两值**必须成对**,`Cosy-ClientType` 是必要但不充分条件。
## 实现(三级,与参考一致)
1. **主路径**:实时调 `runtime-info`,参数顺序必须是 `[environment, '--account-stdin']`,
`environment='3'`(对应 asar 的 `'global'` 分支)。**漏环境值会拿到另一套身份**
(参考实测:漏了只回 1 条 VIEW_DETAILS)。stdin 传 `{"account":""}`,
从输出第一个 `{` 起解析 `machineToken`/`machineType`。
定位方式照参考:`<home>/.qoder[|-cn]/.bin/umid-*/runtime-info`(目录名带哈希,必须枚举)。
2. **退路**:磁盘缓存 `machine_token.json` 的 `token`/`type`(候选路径含 Linux
`~/.config/Qoder/…`,并支持 `QODER_MACHINE_TOKEN_PATH` 覆盖)。
3. **降级**:都拿不到时**照常发请求、不带这两个头**(保守降级)——
纯插件登录、未装桌面端的账号没有该文件,不能让整个积分功能报错。
注意:**不写空串**(发空头可能被按非法设备处理)。
结果缓存(拿不到也记 null),不反复读盘/spawn。
## 接线
qoder 平台加 `headers_fn`:`Cosy-ClientType: 10` + (拿到时)成对的身份头,
余额与(后续的)活动领取共用。
## 判据
`TestQoderMachineIdentityPairingAndDegradation`:有缓存 → 成对带上;无任何来源 →
降级且不报错、仍带 clienttype。变异验证:只发 token(不成对)、拿不到时写空串
—— 两个都被抓。
## Lua 前向引用(本文件第 N 次)
`qoderMachineIdentity` 定义在文件后段,而 qoder 平台表在前面 —— 前向声明必须
放在 **PLATFORMS 之前**(第一次放错位置,等于没声明)。
|
2026-10-13 15:52:26 +08:00 |
|
|
|
d1b7d94043
|
feat(channels): 有道每日活动领取(3 步流程)+ 修 claim_daily 前置条件
## 参考依据
`lobsterai-credits.ts:claimLobsteraiDailyCheckin`。此前只实现了余额查询
(profile-summary),**每日活动领取缺失** —— 用户的余额能看,活动奖励领不到。
## 实现(逐行对齐参考的判定顺序)
① `GET /api/client-activities/slot?placement=desktop_sidebar&clientVersion=…
&containerApiVersion=2&platform=win32`
- 查询失败 → failed;`slotState≠available` 或无 `activityCode` → **inactive**
② `GET /api/client-activities/{code}/context?configRevision=…`
- 查询失败 → failed;`claimedToday` → already-claimed;`actions` 不含 `check_in` → inactive
③ `POST /api/client-activities/{code}/actions/check_in`
body 带 `configRevision` 与**客户端幂等键**(对齐参考的 uuid4)
积分字段三级回退:`creditsGranted` → `rewardCredits` → `credits`
参考注释点名:**无可用活动是正常业务状态**,不能显示成"领取失败"(否则用户会去查
网络/账号,方向全错)。这条在判据里被钉死(含反向变异)。
## 顺带修掉一个阻断性缺陷
`claim_daily` 原本硬要求 `plat.checkin`,而**有道根本没声明 checkin** ⇒ 新钩子
永远走不到(测试直接暴露:返回"平台未声明 checkin")。改为「有 claim_fn 即放行」
(codearts 的 4 步流程同样受益)。通用路径对 `plat.checkin` 的取值也加了保护。
## Lua 前向引用(本文件第 N 次踩)
新代码段用到定义在文件后段的 `local` 助手,Lua 作用域看不到后面的定义:
`lobsteraiClientVersion` / `rfc3986Escape` / `newToken` 依次报
`attempt to call global 'x' (a nil value)`。统一在段首前向声明
(改为赋值形式)。这类错误只在运行时暴露,编译期完全静默。
## 判据
`TestLobsterActivityClaimDecisionTable`:5 个用例覆盖决策表四类结果
(inactive×2 / already-claimed / success)+ 「claim 是否该发出」+ 「body 不得为空」。
变异验证:把 inactive 误报为 failed、忽略 claimedToday —— 两个都被抓。
## 可测性
`LOBSTERAI_ACTIVITY_BASE` 可由 `set_endpoints` 覆盖(测试指到假上游)。
|
2026-10-13 15:44:51 +08:00 |
|
|
|
1586a051da
|
fix(channels): CodeArts 签名按参考重写(华为云 SDK-HMAC-SHA256)+ 完整 4 步领取
## 问题:签名是实现者凭印象编的
原实现用 `SDK-HMAC-SHA256 Credential={ak}/{ts}/{nonce}/sdk_request` —— 那是另一套
协议(阿里风格)的形状,与参考的**华为云 SDK-HMAC-SHA256** 完全不同:
- canonical request 拼装错(参考是 METHOD\nURI\nQUERY\n排序后的 k:v 行\n\nSIGNEDHEADERS\nPAYLOADHASH)
- string-to-sign 格式错(参考是 "SDK-HMAC-SHA256\n{dateStamp}\n{sha256(canonical)}")
- Authorization 头用 Credential= 而非 **Access=**
- 少了 x-sdk-content-sha256 / x-security-token 参与签名
- URI 末尾未补 '/'
⇒ 服务端只会回 `APIG.0301 ... verify ak sk signature fail`,看起来像"账号/权限问题",
这个平台注定领不到额度。
## 修
逐行对齐参考 `sign.ts:signRequestHuawei` + `codearts-credits.ts`:
1. canonical request / string-to-sign / signature 全部照参考
2. 参与签名的头:host / x-sdk-date / x-sdk-content-sha256 / x-security-token
(非 GET 再加 content-type),按字典序拼 SignedHeaders
3. Authorization = `SDK-HMAC-SHA256 Access={ak},SignedHeaders=…,Signature=…`
4. Agent-Type / X-Language 是**签名后**追加的,绝不进 canonical(否则验签失败)
5. URI 末尾补 '/'
6. 完整领取 4 步(参考的 6 步判定顺序):账户类型 → 活动列表 → claim →(benefit.id
非空时)confirm;用 done 哨兵回给 claim_daily(它自己发多个请求,契约只发一个)
- 非积分账户 → inactive(Token 账户不该被报成"领取失败")
- activity.claimable=false 且状态属已领取态 → already-claimed(**唯一的幂等保护**,
本协议没有幂等键)
- confirm 失败不算整体失败(积分已在待确认态),只是降级提示
## 判据(差分,与参考逐字节一致)
`TestCodeArtsSignatureMatchesReference` + `tools/codearts_sig_expected.mjs`
(照抄参考函数生成期望值)。覆盖 canonical / string_to_sign / signature /
SignedHeaders / Authorization 形状 / canonical 不得含签名后追加的头。
变异验证:`Access=`→`Credential=`、去掉 content-type 参与签名、URI 不补斜杠
—— 三个全部被抓。
★ 修判据自身缺陷:首版探针**自己重算** canonical,导致真实签名被改坏时判据仍绿
(去掉 content-type 的变异存活)。改为让签名函数返回真实中间值,探针直接取用。
## 文档
新增 docs/channels-parity.md:与参考逐平台对齐清单(登录 / 额度 / 推理链路三张表
+ 差分判据表 + 未对齐项按影响排序),未对齐项**明示**而非含糊。
|
2026-10-13 15:38:35 +08:00 |
|
|
|
41ff6d1be3
|
fix(channels): 修服务端浏览器栈的四处泄漏(含宿主 SHM 耗尽)
这些缺陷让「测试单独跑绿、全量跑红」,在生产同样会让登录用到一半失败。
## ① 宿主级:SIGKILL 杀 X 进程 → SysV SHM 段耗尽
X 服务用 MIT-SHM 与客户端通信,被强杀时这些段无人释放,成为系统级孤儿段
(nattch=0 但占着 kernel.shmmni 名额)。实测单个 Xvfb 约 84 段,上限 4096
⇒ 约 48 次异常退出即耗满。用满后**宿主上任何**新进程 shmget 都失败:
x11vnc 从 0.2s 能监听变成永远起不来(表现为「x11vnc 端口未就绪」)。
修:回收改为**先 SIGTERM、等 0.4s、再 SIGKILL**,让 X 自己释放 SHM。
实测 core 全包前后 shm 84 → 84(零泄漏)。
## ② 显示器锁泄漏 → 号段被烧穿
强杀留下 /tmp/.X<n>-lock,而选号是「锁/socket 存在就当被占」⇒ 每个被强杀的
显示器永久烧掉一个号;累积满 91 起后 pickFree 扫不到空位就 return start(91)
→ Xvfb 报 “Server is already active for display 91”(实测 41/41 全占)。
修:pickFreeX 回收陈旧锁(用 /proc/<pid> 判存活,不信 shell 退出码)+ 扫 200 个号
+ 扫不到如实报错;收尾清掉自己占的锁与 socket。
## ③ chromium profile 复用 → SingletonLock 卡死
数据目录原用 seq 编号,插件重启后 seq 重来 ⇒ 复用同目录 ⇒ 残留 SingletonLock
让 chromium 只打印 “Opening in existing browser session.” 就退出,新端口永不监听
→ 报「chromium 端口未就绪」。
修:目录名用唯一 tag(时间+随机),并在收尾清掉。
## ④ callback 流漏收浏览器栈
callback 流在 start_login 就拉起服务端浏览器栈,但没有任何路径负责回收
⇒ 每次放弃登录都留一套(实测一轮 core 全包攒 7 套 websockify+x11vnc)。
修:会话过期时若栈属于该会话就收掉;起新栈前先收旧栈(单会话栈);
kernelTestCore 统一 t.Cleanup 调 stop_browser。
## 判据
- TestXDisplaySelectionReclaimsStaleLocks:陈旧锁必须被**回收复用**(只跳过不算通过)
- TestXDisplaySelectionKeepsLiveLocks:活锁不得被回收(反向)
- 两个变异(去掉收尾清理 / 有锁就当被占)均被抓
## 另:变异测试会污染生产源码,加进程级兜底
discover_mutate_test.go 会改写 internal/gateway/discover.go,靠 defer 回滚 ——
进程被强杀(go test 超时 / Ctrl-C / 误杀 shell)时 defer 不执行,变异体留在工作区,
下次运行会把它当"原始源码",所有裁决静默失效(本仓真实踩过两次)。
新增 mutation_guard_test.go:进包即自愈已知变异体。
|
2026-10-13 15:24:07 +08:00 |
|
|
|
6fe6014d92
|
chore: 清理 emoji 与修正发布脚本仓库名
产品代码统一为纯文本风格(照参考实现),去掉 emoji:billing/quota/usage 插件、
channels 的提示文案、WebUI 文案,以及 go 测试、docs、packaging 脚本中的
注意/OK/FAIL 等符号化写法。第三方目录(vendor、node_modules、.probe)不动。
另:packaging/upload-release.sh 的仓库名改为真实全名
JianFeeeee/ModelRouter → HeNanMU-EEA/ModelRouter(前者是重定向,POST/PUT 不跟随
会 404)。
|
2026-10-13 13:42:57 +08:00 |
|
|
|
5ab047e48b
|
fix(channels): 修复 12 平台登录的系统性偏差(callback 闭环 + 动态模型 + Trae 设备身份)
用户报障:「除了 codebuddy,其他的登陆都不太正常,尤其是有道那个」。逐平台审计
参考实现(vendor/channel-pack/src)后发现根因不是 12 个独立 bug,而是几个系统性偏差。
## ① callback 流的回调是回环地址 → 必须走服务端浏览器(核心认识)
Trae/Gemini/有道 的 redirect_uri 都是回环地址(127.0.0.1 / localhost)。回环地址由
「打开授权页的那台机器」解析:远程用户在自己电脑上打开,重定向就落在**用户机器**上,
回调永远到不了网关。CodeBuddy 一直是好的,因为它走 state-poll,**根本不需要回调**。
→ 插件自起 loopback 接收器(Python,绑回环、双栈,路径按各平台注册值),
由服务端浏览器(VNC)承载授权页,重定向落回本机。
## ② 三个平台的回调路径各是注册值(写错就是 redirect_uri_mismatch)
有道 /auth/callback、Trae /authorize、Gemini /oauth-callback(参考 GEMINI_CALLBACK_PATH)
且 Gemini 的 redirect_uri 主机是 **localhost** 而不是 127.0.0.1。
另:Trae 的回调**不带 state**(token 直接放 query),其余平台必须带 → 校验规则按形态分。
## ③ 有道版本号不能写死
exchange/续期/签到的 version 是必填。实测 2026-10-13 上游已是 2026.9.23,而参考的兜底
常量还停在 2026.9.4。新增 lobsteraiClientVersion:远端为主(data.value.version,**非统一
信封**)+ 失败用兜底但**不缓存**(照参考 resolver)。失败时把上游 message 带出来
(实测形如 code=40102「无效或已过期的授权码」)。
## ④ Gemini token 交换缺 client_secret(症状正是「授权成功但账号不出现」)
参考注释点名:token 端点强制校验客户端身份,只发 client_id 会 invalid_request。
同时补 access_type=offline / prompt=consent(缺了拿不到 refresh_token),
并去掉误标的 pkce(上游 Cloud Code 用的是最朴素的 authorization_code + client_secret)。
## ⑤ Trae 授权 URL 少了 5 个必需参数
参考列为「一个都不能少」:login_trace_id(回调**反查 pending 的唯一凭据**)、
machine_id/device_id(hex32,每账号必须不同,否则被「该设备已签到」拦)、
x_device_id/x_machine_id。此前**完全没造**设备身份 → 登录页停在「授权中」。
## ⑥ Trae 签到头与设备身份
授权方案是 `Cloud-IDE-JWT`(不是 Bearer);参考点明简化头**实测签到失败**,
需约 20 个客户端头,且 X-Device-Id 是**基于 user_id 确定性派生的 15 位数字**。
实现 seededStream(SHA256(utf8(salt:seed) ++ counterBE32) 串联)+ 三级派生,
并用参考实现(Node)生成期望值做**差分验证**逐字节对齐。
凭据改为 JSON 以保留 uid/device_id/machine_id(签到与轮换需要)。
## ⑦ 同段逻辑抽一个助手,防两条路漂移
lobsteraiExchange 被 oauth_callback 与 poll_login 两条路共用(原先各内联一份)。
## 判据(新增测试)
- TestLobsterLoopbackClosedLoop / TestLobsterExchangePreservesIdentity:loopback 闭环、
state 不匹配 400、重放 409、凭据含 uuid/first_keyfrom
- TestCallbackPlatformsLoopback:三个平台的路径与 state 规则、Trae 无 state 也能接住
- TestAuthorizeURLsHaveNoUnfilledPlaceholders:授权 URL 不留 {占位符}
- TestTraeDeriveMatchesReference:与参考实现**逐字节**差分(tools/trae_derive_expected.mjs)
- TestTraeCheckinHeaders:Cloud-IDE-JWT + 派生设备身份 + 客户端头齐全
- TestRemoteModelCatalogMergesWithFallback:远端为主、兜底补位、失败不清缓存
## 顺带
- set_endpoints 补 callback / client_version 键(否则测试无法把端点指到假上游)
- 删掉 spawnBrowser 里会把 redirect_uri 一律改写为插件挂载点的死代码(对有道致害)
- session 过期与插件卸载时回收 loopback 接收器进程(防进程泄漏)
- e2e/channels_flows_e2e_test.go:qoder 已从 manual 改为 device-poll,断言同步更新
|
2026-10-13 13:42:45 +08:00 |
|
|
|
ec5e6dd57c
|
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:36 +08:00 |
|
|
|
705e079285
|
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:39 +08:00 |
|
|
|
8949405746
|
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:30 +08:00 |
|
|
|
d86514c474
|
feat(channels): VNC 兜底真正可用(依赖预检 + 就绪探针 + CDP 取凭据)
用户指出「vnc 无头浏览器兜底登陆你根本没有实现」。核对后确认:进程层代码存在,
但**只接在一个我编的假平台上**(loopbackish,已随夹具移出),四个真实平台全标
flow="manual" 并给一句"请你自己去某处搞定"。且代码写着「运行 deploy 脚本一键
安装」,而那个安装机制根本不存在(deploy.sh 里零浏览器栈内容)。
## 实现
- **依赖预检** browserDeps():探测 Xvfb/x11vnc/websockify/noVNC/chromium/
python3-websocket,如实报告缺什么、装哪个包、干什么用。
- **就绪探针** waitPort + X socket 等待:不再 sleep 2 拍脑袋(view_url 先于
服务可用会让 iframe 白屏)。
- **CDP 取凭据**:浏览器带 --remote-debugging-port,登录完成后经 CDP 读
localStorage/sessionStorage/cookie 或平台声明的 collect_expr。人只负责在 VNC
窗口里交互,机器负责把结果取出来 —— VNC 不该只是"看屏幕"。
实测:在真 HTTP 页面上 `localStorage.getItem('access_token')` → REAL-TOKEN-XYZ,
`document.cookie` → session=COOKIE-VAL,全部正确。
- **新端点**:channels/deps/status、browser/collect、browser/stop、browser/status。
- **安装入口** packaging/install-browser-stack.sh:识别 apt/dnf/yum/apk,
--check 只检查;装进发行包(/usr/share/llmsproxy/)。插件 install_hint 指向它。
默认不自动装:系统级包、数百 MB、chromium 安全面大,多数部署用不到 VNC。
## 过程中修掉三个真 bug(都是测试逼出来的)
1. **Xvfb 从未被启动**:steps 数组里有 Xvfb,但循环明确 `if st[1] ~= "Xvfb"`
跳过它(想先等 socket)→ 等一个永远不会出现的 socket。
2. **display 号按 seq 取模**:`90 + (seq % 10)`。Xvfb 锁文件在强杀后残留,
重启后 seq 从头 → 又选同一个号 → `Fatal: Server is already active`,
而报错只显示"Xvfb 未就绪"。改为**探测空闲**号(锁+socket+端口)。
3. **io.open 判 socket 存在**(两处):X socket 是 socket 文件,io.open 返回 nil
→ 探测误判"空闲"、等待循环永远等不到。改用 `[ -e ]`。
4. **孤儿进程**:spawn 往局部 pids 表追加,只在函数末尾回写 b.pids →
中途失败时 reapBrowser 读到空表,已起的 Xvfb 不被杀(实测留下 4 个孤儿)。
改为即时回写。
## 判据
- TestChannelsVncStackEndToEnd:注入 flow=vnc 平台 → 真起浏览器栈(view_url 含
vnc.html + CDP 端口)→ 回收。缺依赖则 SKIP 并说明。
- TestChannelsVncNoOrphanOnFailure:回收后 running=false、无孤儿。
- TestChannelsBrowserStackDeps:依赖清单三个字段齐全、缺件必须给安装提示。
- TestChannelsBrowserEndpointsRequireSession:无会话时三个端点如实报错/幂等。
- 全量 core 回归后确认:生产插件目录未被写入、无孤儿进程。
内核对照 v2.0.2:diff 为空(本次零内核改动)。
|
2026-10-13 09:33:18 +08:00 |
|
|
|
d4ae2a1d13
|
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 改为插件自持存储
- `.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:18:15 +08:00 |
|
|
|
652a035d47
|
fix(channels): 测试夹具不再内联进生产目录 + callback 池路径漏网
用户指出「为什么生产插件会有实例平台?」—— 确实是设计错误。
## ① 夹具内联进生产源码(根因)
`PLATFORMS` 里内联了 4 个测试夹具:
example / googleish / loopbackish / cookieish
后果:生产 `/api/plugins/http/channels/platforms` 返回 **16 个平台,其中 4 个是
夹具**,运维在 UI 上真的能选到。夹具属于测试,不属于发行物。
修法:
- 生产 `PLATFORMS` 只留 12 个真实厂商。
- 新增 `plugin.state.injected_platforms` + `allPlatforms()`;`platformById`
先查内建再查注入表。
- 新增端点 `channels/inject_platforms`(roles=admin):运行时整表替换注入,
边界校验 —— 不得覆盖内建平台(注入不能劫持真实厂商)、拒绝重复 id、
拒绝无 id、限 32 条;空数组 = 清空。
- 8 处运行时遍历点(list_platforms/provider_status/backup/auto_checkin/
gateway 开关等)改用 `allPlatforms()`;4 处**加载期**遍历(HMAC 钩子绑定、
declaredHosts)保持 `PLATFORMS` —— 它们在 plugin.state 建立之前、注入尚未
发生,改了无意义。
- 测试改为经 `injectTestPlatforms` 注入夹具(channels_plugin_test /
channels_stress_test);e2e 本就用真实平台(minimax/cline/opencode/qoder),
无需改动。
## ② callback 路径的 account_id 漏网
UI 流程 = account/add 建占位账号 → login 带 account_id → 完成时回填。
`oauth_callback`(trae/gemini/lobsterai 的 callback 流)调 complete_login 时
**没传 account_id** —— 与 buddy poll_login 同一缺陷,此前只修了 device-poll /
state-poll 两条分支,这条是漏网:凭据落到与占位账号无关的 `channels:<plat>`,
池账号永远 has_credential=false。
## 判据
- TestInjectPlatformsRejectsBadInput:注入生效 + 三条拒绝规则 + 空数组清空
- TestTraeCallbackPoolPath:callback 池路径凭据落 `channels:trae:<aid>` 且
account_list 报 has_credential=true
- 变异:callback 去掉 account_id → TestTraeCallbackPoolPath 立即 FAIL(已实测)
go test ./internal/core/ 全绿(119s)。
|
2026-10-13 08:33:28 +08:00 |
|
|
|
6bec260513
|
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:39 +08:00 |
|
|
|
ed954b77bd
|
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:46 +08:00 |
|
|
|
cb3ea25586
|
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:48:15 +08:00 |
|
|
|
0232b9cd24
|
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:42 +08:00 |
|
|
|
a07449d32e
|
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 |
|
|
|
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 |
|