# channels 插件与参考插件的三线对齐表 参考仓:`Ebony-Vinyl/dsh-our-free-model`(本地克隆 `/tmp/pi-github-repos/Ebony-Vinyl/dsh-our-free-model`),关键实现位于 `vendor/channel-pack/src/`(144 个 TS 文件)。 命名规律(照它逐厂商对齐的三条线): | 后缀 | 职责 | 我们要对齐的东西 | |---|---|---| | `X-auth.ts` / `X-oauth.ts` / `login.ts` | **登录** | 登录流程、端点、参数、凭据形状 | | `X-product.ts` / `models.ts` | **模型获取** | 远端端点、解析路径、兜底表 | | `X-adapter.ts` / `llm-adapter.ts` | **接入模型服务** | chat 端点、载荷转换、鉴权头 | ## 一、登录(line ①) | 平台 | 参考流程 | 我们的 flow | 状态 | |---|---|---|---| | trae | OAuth 17 参数 + 设备身份派生 | callback | ✅ 逐字节对齐(含 login_trace_id / machine_id / x_device_*) | | minimax | device-poll | device-poll | ✅ | | cline | device-poll | device-poll | ✅ | | buddy | state-poll(**无回调**) | state-poll | ✅ | | gemini | OAuth + client_secret | callback | ✅(含 client_secret、localhost 主机、6 scope) | | lobsterai | 本地回调 + authCode 换 token | callback | ✅(含动态版本号、3 步活动领取) | | opencode | paste | paste | ✅(参考本身即粘贴凭据) | | **codearts** | **PKCE + DPoP ES256 + STS 换 AK/SK** | **callback** | ✅ **本轮修复**(此前误标 paste) | | qoder | device-poll + 设备身份 | device-poll | ✅(含 Cosy-MachineToken/Type 成对) | | zcode | init-poll | init-poll | ✅ | | loomy | 微信扫码 poll | wechat-poll | ✅ | | raccoon | 二维码 poll | qr-poll | ✅ | ### codearts 登录(本轮修复的重点) 此前标成 `flow="paste"`(手填凭据)是**错的**。参考 `login.ts` / `oauth.ts` 默认走**新式 IAM OAuth**: 1. 起本地回调服务器,端口须 **≥10000**(`MIN_CALLBACK_PORT`,低于此 portal 拒绝), 路径 `/oauth/callback` 2. 打开 `https://codearts.huaweicloud.com/portal/authorize`,参数逐项: `theme=2`、`locale=zh-cn`、`uri_scheme=codearts-agent`、`client_id=codearts-agent`、 `code_challenge_method=**SHA-256**`(不是 RFC 的 S256 —— 错值会让 portal **回退旧 ticket 流程**,看起来"也能用"但不是 OAuth)、`ticket_id`、 `plugin-name=snap_AIIDE`、`plugin-version=5.2.0`; **不得**含 `auth_callback_url`(会被判异常并回退) 3. 回调收到 `code` 后,带 **DPoP 证明**向 `https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens` 换 `credentials{access_key_id, secret_access_key, security_token, expiration}` + `refresh_token` 4. 凭据持久化须含续期材料:`refresh_token` / `code_verifier` / `dpop_private_key_jwk` DPoP 细节:ES256 JWS,`header={alg,typ:dpop+jwt,jwk}`、 `payload={htm,htu,iat,jti}`;**签名是 raw r||s 64 字节**,而 openssl 输出 DER ——必须转换格式,否则服务端验签失败而字符串看起来完全正常。 终态判定只认 `invalid_grant` / `ErrorCode 含 ExpiredRefreshToken`; **不认** `InvalidDPoPHeader`(那是一次请求层面的拒绝:时钟偏差、proof 重放、 网关抖动都会触发,当终态会把完好账号一步标死)。 旧 ticket 流程(`CODEARTS_LOGIN_BASE` + `/snap-manager/v1/login/ticket` 轮询) 保留作回退。 ## 二、模型获取(line ②) 远端优先、兜底补位的机制已具备(`models_remote` + `effectiveModels`)。 本轮把**兜底表**逐平台补成参考全量: | 平台 | 参考来源 | 兜底数 | 状态 | |---|---|---|---| | trae | `TRAE_FALLBACK_MODELS` | 28 可见 + 4 hidden | ✅(hidden 单列 `models_hidden`) | | minimax | `GET /mavis/api/v1/models?region=cn&buildEnv=prod` | 4 | ✅ | | cline | `GET /api/v1/models` + `recommended-models` | 3 | ✅ | | buddy | (无独立表) | 7 | ✅ | | gemini | — | 2 | ✅ | | lobsterai | `GET /api/models/available`(**须带 model_flag**) | 19 | ✅ | | opencode | `GET /v1/models` | 14 | ✅(含 `models_free`) | | codearts | opengw `gateway/config` + `/v1/model/builtin` | 3 | ⚠️ 见下 | | qoder | `/api/v2/model/list`(需 WASM 签名) | 19 | ✅(含 CN 独有 q37fmodel/gm51model) | | zcode | `GET /api/v1/client/configs` | 2 | ✅ | | loomy | `GET /api/v1/models`(取 `type==='chat'`) | 8 | ✅ | | raccoon | 远端 + 兜底 | 6 | ✅ | **trae 的 `isHidden` 语义**:`browser_use_subagent` / `explore_sub_agent_v13` / `explore_sub_agent_v2` / `summary` 是内部子 agent,不进用户可选列表。 `model_list` 已按此过滤(`models_hidden`)。 **opencode 的 `isFree`**:参考明确标注 7 个模型匿名可用、其余需付费 key。 此前 `model_list` 把 `isFree` 硬编码为 false,等于把官方免费模型标成付费。 ### codearts 模型获取(未闭环) 参考 `models.ts` 从两个端点拉: - `OPENGW_GATEWAY_CONFIG_URL`(`opengw.developer.huaweicloud.com/api/v1/gateway/config`) → `result.models`,这是 **benefit(免费额度)模型**权威来源 - `SNAP_MODEL_BUILTIN_URL`(`/v1/model/builtin`)→ `builtinModels`,常规模型 两者都要 **AK/SK 的 SDK-HMAC-SHA256 签名**。另有 `normalizeModelId` (去尾部 `-NNNN` 日期版本)与 `isCodeArtsBenefitModel`(决定 chat 是否带 `maas_type: benefit`,不带则 404 `model is not registered`)。 **这部分尚未实现**——我们的 codearts 目前只有 3 个硬编码模型名。 ## 三、接入模型服务(line ③) **本轮发现的最严重问题**:源注册时 `base_url + adapter.endpoint` 拼出的 chat URL **全部缺路径前缀**,11 个平台里 11 个都不对。已逐个修正: | 平台 | 正确 chat 端点 | 修正前 | |---|---|---| | trae | `…/api/agent/v3/llm_utils_chat` | `…/chat/completions` | | cline | `api.cline.bot/api/v1/chat/completions` | `api.cline.bot/chat/completions` | | lobsterai | `…/api/proxy/v1/chat/completions` | `…/chat/completions` | | qoder | `api2-v2.qoder.sh/model/v1/chat/completions` | `…/chat/completions` | | buddy | `copilot.tencent.com/v2/chat/completions` | `…/chat/completions` | | codearts | `…/api/v2/chat/completions` | `…/chat/completions` | | opencode | `opencode.ai/zen/v1/chat/completions` | `opencode.ai/zen/chat/completions` | | raccoon | `…/api/web/llm/v2/chat/completions` | `…/chat/completions` | | loomy | `loomy.xunfei.cn/chat/completions` | `loomy.xunfei.cn` | | zcode | `zcode.z.ai/api/v1/zcode-plan/anthropic/v1/messages` | `zcode.z.ai/v1/messages` | | gemini | `/v1internal:generateContent`(私有动词) | `/v1beta/models/{model}:generateContent`(公开,凭据不匹配) | ### ✅ 已闭环:三个私有协议平台改用**插件自装适配器** 这是"为什么参考正常、我们不正常"的正面修复。参考每个平台是继承宿主 `LlmAdapter` 的真类;我们此前把平台协议差异当成适配器名差异(全塞 openai)。 内核其实**早已实现** `__llmsproxy_adapters.install`(含归属校验/冲突仲裁/ 测试),但 channels 插件对该全局的引用数是 **0**。现已第一次真正使用: | 平台 | 自装适配器 | 解决的协议差异 | |---|---|---| | codearts | `codearts` | 华为云 SDK-HMAC-SHA256 **AK/SK 签名**(canonical/string-to-sign 逐字节) | | trae | `channels-trae` | **SOLO 载荷**:config_name 双字段、function 通道、tool_calls→function_call、parameters→JSON 字符串、tool_choice 归一化 | | gemini | `channels-gemini` | 私有 `/v1internal:` **信封** + 身份五头 + 字母序序列化 + 响应剥一层 | 命名说明:trae 用 `channels-trae` 而非 `trae`,因为内置 `trae.lua` 服务的是 **运维自建的 trae-local-api 反代**(`127.0.0.1:19900`,生产 config.yaml 在用), 与本插件的直连通道是两回事。重名会被内核冲突仲裁挂起(正确的护栏)—— 判据里专门断言内置 `trae` 未被顶掉。 **新增能力:`credential_shape`**。签名平台需要**完整凭据 JSON**(适配器自己 解 AK/SK),其余平台交裸 access token。此前 `syncAccountSource` 一律用 `credentialOf` 取 `access_token`,于是 codearts 凭据(只有 `access_key_id`/`secret_access_key`/`security_token`)取不到 token → **静默 return、不出源**。现已按 `plat.credential_shape` 区分。 **另一处静默失败已修**:`syncAccountSource` 拿到内核 `add_source` 的失败 (权限未授、形状不符)时不记日志,表现为"账号加了但不出源",排查零线索。 现补失败日志(区分内核 err 与 `json.ok==false`)。 ### 仍未闭环:gemini 的 sigstore(明确不做) 参考 `gemini-sigstore.ts`(268 行)持久化 tool-call 签名,供跨请求回填。 我们推理走**无状态适配器**,插件侧缓存会成为死代码。签名缺失时上游会拒, 适配器如实透传错误。理由与结论已记在此处,避免反复讨论。 ## 四、明确不做的(附理由) | 参考模块 | 行数 | 为什么不做 | |---|---|---| | `gemini-sigstore.ts` | 268 | 推理走无状态适配器,插件侧缓存会成为死代码。签名缺失时上游会拒,适配器如实透传 ⛔ | | `qoder-wasm.ts` | 1442 | 需随插件分发 298KB wasm 二进制;B 类,待立项 | | `zcode-captcha.ts` | 2212 | 阿里云滑块,需真实浏览器/验证码链路;B 类 | | `captcha-*.ts` | 1223 | 同上 | ## 五、本机实测踩到的坑(都已修) 1. **`xxd` 不存在** —— `command -v xxd` 为空,用它取十六进制会**静默得到空串**。 改用 `od -An -tx1`。 2. **`openssl ec -text` 会截断 32 字节 priv**(实测只给 30 字节)→ `d` 必须用 `openssl asn1parse` 的 `[HEX DUMP]`;`tail -c 32` 拿到的是 SEC1 结构里 **公钥的 y**,不是 `d`。 3. **Lua local 前向引用**:`local function` 只从定义处向后可见。`hexToBin` / `b64url` / `b64urlRaw` 被 CodeArts 的 PKCE/DPoP 调用,必须移到 CodeArts 块 **之前**(本文件累计踩过 6 次)。 4. **`min_port` 参数读到 `argv[2]`** 与 `out_path` 冲突 —— 回调脚本的第 5 个 参数才是 minPort。 5. **Lua 双引号串里的 `\1`** 是转义坑(`sed 's/…/…/\1/'` 会报 invalid escape sequence)→ 改用 `awk -F': ' '{print $NF}'`。 --- # 为什么参考插件是"正常"的:架构差异(2026-10-13 查证) 用户问「为什么他们就正常」。查完参考仓的架构后,答案是**三层结构差异**, 不是某一个端点的笔误。 ## 参考的架构:一个平台 = 一个适配器类 参考依赖宿主 SDK 的 seam:`import { LlmAdapter } from '@deepseek-ai/dsh-llm'`。 每个平台有一个**继承基类的真类**,同时拥有登录、模型目录、推理三件事: | 文件 | 类 | 干什么 | |---|---|---| | `trae-adapter.ts` | `TraeAdapter` | SOLO 协议转换(`config_name`/`function`,非 OpenAI 兼容) | | `llm-adapter.ts` | `CodeArtsAdapter` | 华为云 AK/SK 签名 + `maas_type: benefit` 判定 | | `zcode-adapter.ts` | `ZcodeAdapter` | Anthropic Messages 协议(非 OpenAI) | | `qoder-adapter.ts` | `QoderAdapter` | WASM 加密端点 + 信封剥离 | | `gemini-adapter.ts` | `GeminiAdapter` | `/v1internal:` 私有动词 + 字母序信封 | 注册方式是 `registerAdapterIdempotent(ctx.llm, [PROVIDER], adapter)` (`llm-adapter.ts:2080`)。 **但它不重复**:共享层是 `src/openai-compat.ts`(1196 行)—— `serializeMessages`(消息序列化)、`consumeOpenAiSse`(SSE 消费)、 `httpErrorCode` / `isTransportError`(错误归类)、`userContentParts`(多模态)。 12 个 adapter 里有 8 个 `import from './openai-compat'`。 即:**公共协议逻辑一份,平台差异各自一个类**。 ## 我们的架构:内核是 proxy,插件只注册源 - 适配器是 Lua 文件(12 个),通过 4 个钩子参与: `transform_request` / `transform_response` / `transform_stream_chunk` / `transform_error`,外加 `build_headers`(动态签名,见下)。 - 声明面:`endpoint` / `endpoint_stream` / `ingress` / `headers`。 - 插件通过 `__llmsproxy_kernel.call("add_source", {source={...}})` 把平台 注册为上游源,`source.adapter` 引用适配器名。 ## 关键发现:机制早就有,但 channels 插件从没用过 内核**完整实现了**插件自装适配器: ``` internal/lua/plugin_adapter_bridge.go func (ps *Plugins) InstallAdapter(plugin, name, code string) (string, error) registerAdapterBridge → Lua 全局表 __llmsproxy_adapters.install / .update / .remove ``` - 有归属校验(`adapterOwnership`:只能改自己装的) - 有冲突仲裁(影子内置/他人所有 → 挂起等运维裁决,不是硬拒) - 有测试证明可用:`TestPluginInstallsAdapterFromLua`(PASS) **但 `grep -c '__llmsproxy_adapters' internal/lua/plugins/channels.lua` = 0。** channels 插件一个适配器都没装过 —— 它只会注册 `adapter = "openai"` 的源。 这就是"为什么我们不正常"的直接原因:**平台协议差异被当成适配器名差异**。 ## 我们还缺的一层:共享协议模块 参考有 `openai-compat.ts`;我们没有对应物。`openai.lua` 自己写明 「Lua adapters have no shared prelude」,`safe_tool_id` 在 `openai.lua` 与 `anthropic.lua` 里**各复制了一份**(各 6 处引用)。 所以正确做法是两层: 1. 抽一个 Lua 共享前导(消息序列化 / SSE 消费 / 错误归类), 或内核在加载时注入 —— 消掉复制粘贴 2. channels 插件为需要真协议转换的平台(trae SOLO、gemini 信封、 codearts AK/SK 签名)**装自己的适配器**,而不是硬塞给 `openai` ### 所需的适配器能力(crypto + 出站 + 流式) 已核实内核提供: - `sha256_hex` / `hmac_sha256_hex`(`vm.go` setupGlobals) - `build_headers(meta)` 收 `{url, method, body, api_key, timestamp, client_session, source{name,meta}}` —— CodeArts 的 AK/SK 签名用它 (与参考 `signRequestHuawei` 同构,但 AK/SK 要从 `api_key` 里解出) - `transform_stream_chunk` 逐 chunk 调用(`provider.go:1295`) - 插件有完整 OS 能力(`io.popen`/`os.execute`)→ 可用 openssl 做 EC 签名 ### 未验证的风险 - 适配器 VM 能否访问 `__llmsproxy_secrets`(AK/SK 要解出 SK 才能签名) - 流式转换能否跨 chunk 保持状态(trae 的 SSE 需要跨帧累积 tool_calls) 这两条是下一步落地前必须先探的。 --- # 插件自装适配器:机制与判据(2026-10-13) ## 机制(内核早已具备,本轮才开始使用) ``` internal/lua/plugin_adapter_bridge.go InstallAdapter(plugin, name, code) # 含归属校验、冲突仲裁(影子内置 → 挂起等裁决) registerAdapterBridge → Lua 全局 __llmsproxy_adapters.install / .update / .remove ``` 注册点在 `bootPluginState`,**先于** `DoString(code)` —— 所以插件在脚本执行期 就能装适配器。适配器装在**适配器 VM**(与插件 VM 分离,各自独立)。 ## 三条能力(实测,不是推断) `internal/lua/plugin_adapter_capability_test.go` 锁死: | 能力 | 结论 | 为什么关键 | |---|---|---| | 拿凭据 | 适配器**能**经 `build_headers` 的 `meta.api_key` 拿到(`url`/`method`/`body` 也在) | AK/SK 签名的前提;同时**验不到** `__llmsproxy_secrets`(只注入插件 VM,安全边界完好) | | 流式状态 | `transform_stream_chunk` **跨 chunk 保持同一 Lua 状态**(表地址恒定) | SOLO 跨帧累积 tool_calls、gemini 跨帧剥信封的前提 | | 加密原语 | 适配器可用内核 `sha256_hex` / `hmac_sha256_hex`(用标准向量校验) | 签名不需要外部进程 | ## 踩过的坑(都是"静默失败"类,值得记) 1. **`function` 是 Lua 关键字** —— 写 `body.function = x` 是**语法错误** (`'' expected near 'function'`),必须 `body["function"]`。 插件脚本本身能编译通过,**只在适配器 `load()` 时才暴露**。 2. **Lua 长字符串里不能有字面 NUL 字节** —— 报 `unfinished long string near ''`,且**报在文件末尾**,完全指不到现场。 3. **本 Lua 构建不支持按位异或 `~`**(`unexpected symbol near '~'`)。 4. **装载失败只回"源码无法编译"、不给行号** —— 三个自装适配器现统一加 **本地预编译护栏**(先 `load` 一次拿行号)。上面三个坑都是靠它定位的。 5. **抽公共代码要抽最全的实现**。前导重构时我先抽了 `transform_error` 的 最小版(只认 `{error:{message}}`),打挂 6 个既有测试 —— 最小版丢掉了 HTML 错误页、裸文本、扁平 `{code,message}`、FastAPI `{detail}`。 抽最小实现是**静默降级**:不报错,只是少认几种错误体。 6. **判据必须能看见真实函数的返回值**。我一度用 `kernelTestCore` 裸加载插件 (没授 `add_source` 权限)测源注册,源一直不出;直到把内核原始返回 (`kernel action add_source is not granted to plugin channels`)带出来才 定位。**黑盒断言只在失败时告诉你"不对",不告诉你"为什么"**。 ## 判据清单 | 文件 | 锁死什么 | |---|---| | `internal/lua/plugin_adapter_capability_test.go` | 三条能力(凭据/流式状态/加密原语) | | `internal/lua/adapter_prelude_test.go` | 前导注入 + 语义值正确 + 旧适配器行为不变 | | `internal/lua/adapter_prelude_equivalence_test.go` | 重构前后逐字节等价(含反向断言) | | `internal/lua/channels_trae_solo_test.go` | SOLO 7 条规则 + 三处反向断言 | | `internal/lua/channels_gemini_adapter_test.go` | 身份五头逐值 + 不得发 google key/Accept + 字母序 + 响应剥信封 | | `internal/core/channels_codearts_adapter_test.go` | 装机/归属 + **Go stdlib 独立复算 HMAC 验签** | | `internal/core/channels_private_adapter_wiring_test.go` | 三平台的**源真的引用了**自装适配器 | --- # 生产实测:不可用的真相(2026-10-14) ## 症状与根因 用户报"完全不可用"。实测(`/api/plugins/http/channels/account/list` 逐平台): | 平台 | 账号数 | **有凭据的** | |---|---|---| | buddy | 2 | **1** | | trae | 3 | 0 | | minimax | 1 | 0 | | lobsterai | 1 | 0 | | codearts | 5 | 0 | | 其余 7 平台 | 0 | 0 | **13 个账号里只有 1 个真有凭据**,其余全是**占位壳子**(`has_credential: false`)。 占位账号不是"已登录",它只是为「先建账号行、登录后填凭据」这个两步流程预留的 行。此前的判断只看「12 平台 0 个 manual」,那是**错的判据** —— manual 是 flow 类型,不是"登录成功"。 ## 排除项(都实测过,不是猜) | 检查 | 结果 | |---|---| | 服务状态 | `active`,`NRestarts=0` | | 部署一致性 | 4 个插件 sha256 与仓库**全部一致**;关键标记(installCodeartsAdapter / installTraeAdapter / installGeminiAdapter / sorted_json / sign_parts)全部在位 | | **推理链路** | `POST /v1/chat/completions` 实测返回 `"OK"`(走 buddy 源、deepseek-v4.1-flash)—— **网关本身完全可用** | | 登录机制 | minimax 真实返回 `user_code=T49S-RC7G` + 验证地址;trae 起了服务端浏览器 + 回环回调 | | 设备码轮询 | 5 次全部 `pending`(未授权时**不误判**为失败或成功 —— 这是正确行为) | ## 结论:不是代码坏了,是**授权没完成** 剩下的 12 个平台都需要**人工动作**才能走完登录: - **device-poll**(minimax / cline / qoder):把 `user_code` 拿到 `account.minimax.cn/oauth-authorize` 之类的**官方页面**输入 - **callback**(trae / gemini / lobsterai):在 **VNC 窗口**里点授权 (回环回调只能由本机解析,远程浏览器永远接不住) - **qr-poll / wechat-poll**(raccoon / loomy):扫码 - **init-poll**(zcode):授权页 + 可能的验证码 这些**按设计就必须人工**,assistant 无法独立端到端完成。 ## B 类:实测结论 | 项 | 行数 | 结论 | |---|---|---| | qoder-wasm + envelope | 706 + 226 | **二进制不在仓库**(`find -name '*.wasm'` 为空,属构建产物)。没有 `qoder-auth-wasm.wasm` 无法实现;拿到才有意义 | | zcode-captcha + carrier | 2119 + 93 + 280 + 206 | 阿里云滑块。且**登录本身不需要它** —— captcha 只用于**签到/领取**环节(`zcode-auth.ts:22-23`:claim 时每个 plan 单独 mint captcha)。登录闭环不受阻,受阻的是自动领取 | | captcha-pool | 280 | 同上,服务于领取环节的验证码供给 | **修正判断**:B 类不是"登录闭环的拦路虎"。zcode 的登录(`/api/v1/oauth/cli/init` → poll)不需要验证码;captcha 只在**自动领取**时出现。所以 B 类的真实价值是 "让领取全自动",而不是"让平台可用"。 --- # 真实数据事故:测试覆盖了生产账号池(2026-10-14) ## 症状 用户报「完全不可用」。逐平台 `account/list` 后发现:**13 个账号里只有 1 个有凭据**, 其余是占位壳子;再查发现**账号池被清空了**(`pool` 全为空 `{}`), 而保险库 `.secrets/channels.json` 里的凭据**还在**。 症状正是「账号凭空消失」。 ## 根因(实测输出,非推断) channels 插件用 `storeDir()` 决定存储位置,它有多级兜底: ``` env MODELROUTER_PLUGIN_DIR → settings.store_dir → 读 /etc/llmsproxy/config.yaml 的 plugin_dir → 硬编码 ``` 测试台没设 `MODELROUTER_PLUGIN_DIR`,**后两级直接命中生产**: ``` 测试插件目录 = /tmp/TestXXX/002/plugins storeDir 结果 = /etc/llmsproxy/plugins ← 探针实测输出 ``` 于是测试加载真 `channels.lua` 时**读的是生产存储、写的也是生产存储**。 一次全量 `go test ./internal/lua` 就把 `.channels-store.json` 从 1730 覆盖成 1986 字节、账号池清空(inode 都换了)。 只有 `internal/lua` 复现,`internal/core` 不复现 —— 因为 core 的测试台 (`kernelTestCore`)设了 `MODELROUTER_PLUGIN_DIR`。**这是隔离做得不一致**的代价。 同类前科:曾把 26 个测试账号写进生产 store。两次同一类错误。 ## 为什么 `DisableStatePersistence` 不够 第一版修复给 `runtimePlugins` 关了持久化,**仍然复现**:插件声明了 `schedule` 时内核会自动起调度器(`plugins.go: p.Runtime = ps.startRuntime(...)`), 真 channels 的 `refresh_models` 会在 5s 后(first_due_now)跑一轮并 `logEvent → markDirty`。那是**后台 goroutine,比 `t.Setenv` 的生命周期长** —— 测试结束后它仍可能落盘,此时环境变量已还原。 ## 修法(两层,第二层才是根治) 1. **内核把插件的真实目录交给插件**(ground truth,不再靠猜): 新增 `__llmsproxy_plugin_dir` 全局(`registerPluginDirBridge`), `storeDir()` 优先用它。**即使没有环境变量也不会落到生产路径** —— 实测:撤掉 env 后 `storeDir` 仍落在临时目录。 2. 测试台补隔离:`runtimePlugins` 设 env + `DisableStatePersistence()`。 ## 判据 + 变异验证 `internal/lua/store_dir_isolation_test.go`: `storeDir` 绝不能是 `/etc/llmsproxy` 前缀、必须落在临时目录、env 必须真的被设。 **变异验证**:把隔离设成生产路径 → 判据变红并打印「storeDir 指向了**生产**目录」; 恢复 → 变绿。 全量回归:9 个包全绿,且 `go test ./...` 后生产存储**未被改动**(此前必被改)。 ## ⚠️ 修复过程中我自己踩的坑 恢复账号时我把**保险库文件里的密文**当成明文,喂给 `account/add` 的 `credential` —— 保险库 `loadSecretsLocked` **会在读取时解密**,于是那份凭据被 **二次加密**(1971 → 2965 字符),该账号的推理开始返回空 (`codebuddy_*` 源失效,而非池路径 `channels:buddy` 正常)。 教训:**凭据只能由登录/续期路径写入,绝不要手工把密文塞回 API**。 要复用既有凭据,应走「让插件自己读保险库」的路径,或者重新登录一次。 --- # 逐模块对照的当前状态(2026-10-14 更新) 穷尽对照参考仓 **143 个 .ts 模块 / 77,720 行**。约定: ✅ 已对齐 · ⚠️ 部分/简化 · ❌ 未实现 · ⛔ 明确不做 ## 本轮(第 2 批)新补的 | 模块 | 参考规模 | 补了什么 / 为什么 | |---|---|---| | `dead-model-store` | 708 | 上游下架的模型仍显示且标"免费",一点就 404。**判据只用阳性 404 证据,绝不拿远端目录反推**(buddy 那类平台目录残缺,反推会删可用模型) | | `remote-catalog-gate` | 114 | 参考点名"不能省":目录拉不到会放大成「一次请求 × 每个模型」,首屏卡死 | | `buddy-balance-rank` + `loomy-balance-rank` + 两个 selector | ~1000 | **余额分档选号**:优先用「15 天内到期」的号,否则积分白白作废;锁定时只剩永久的号等同不可用 | | `account-probe` | 568 | `account_test` **此前一个网络请求都不发**(恒返回 ok:true/ms:1)—— 假阳性 | | `model-gate` | 218 | 源改**串行 + 按模型节流**(并发发起必然浪费一个配额;串行不保证间隔) | | `ttl-cache` / `serial-queue` | 291 | 抽成通用件,消掉三处各自手写的缓存/冷却逻辑 | | `opencode-capability` | 423 | isFree 改由 models.dev 的 `cost` 权威决定(不再是硬编码快照)。**受内核 4 MiB 上限约束不可用**,已如实降级为可选增强 | 另修 3 个**静默失败**:`account_test` 不发请求、业务级错误(HTTP 200 + body 带 error)被当成功、`pcall` 只取 ok 不看返回值(把诊断信息扔了)。 ## 仍未实现(按影响面排序) | 模块 | 行数 | 为什么不做的理由 / 阻塞 | |---|---|---| | `zcode-captcha` + 6 个 captcha-* | ~3345 | 阿里云滑块。**登录不需要它** —— captcha 只在签到/领取环节(`zcode-auth.ts:22-23`)。真实价值是"让领取全自动"而非"让平台可用" | | `qoder-wasm` + `qoder-envelope` | 932 | **wasm 二进制不在仓库**(`find -name '*.wasm'` 为空,属构建产物)。拿不到就无法实现 | | `zcode-identity` / `transport` / `diagnostics` | 1104 | 设备身份 + 传输层 + 错误诊断。与 captcha 链路耦合 | | `account-pool` / `channel-pack-store` | 1912 | 我们已自建(形态不同,非缺口) | | `opencode-proxy` / `opencode-rpc` | 530 | 多出口代理(NAT 后多机共享出口时按 IP 限流才需要) | | `message-shape` | 146 | **不适用**:那是 DSH 宿主的消息形状迁移(0.1.5→0.1.7)。我们的适配器直接透传 OpenAI 形状,无此问题 | | `loop-recovery` | 346 | **前提不存在**:它做的是"循环守卫中止后自动续跑",而我们的内核**没有循环守卫** | | `reasoning-ladder` | 182 | 需要内核支持 `efforts` 字段(`Model` 结构只有 id/priority/kind/meta)。属内核改动,按纪律不做 | | `badge-*` / `image-budget` / `ttl-cache`(已补) | ~500 | 徽标订阅、图片预算 —— 宿主 UI 特性 | | `raccoon-login-page` / `raccoon-qr` | 1124 | 登录页与二维码的完整实现(我们走简化版) | | `zcode-carrier-page` | 93 | 内部载体页(依赖 DSH Desktop 的 webview guest,我们没有那个壳) | | `cline-*` 4 个 / `gemini-project` / `gemini-sigstore` / `loomy-sign`(部分) | ~900 | 平台细节与签名持久化 | ## 一句话结论 **平台登录/凭据/源注册这条主线已打通且逐平台对齐**(12 平台零 manual、三平台 自装适配器、SOLO/信封/AK-SK 签名逐字节验证)。 **未实现的部分集中在两类**:(1) 需要**真实浏览器/验证码**的领取全自动 (captcha 系 ~3300 行);(2) **二进制缺失**(qoder wasm)。 其余是宿主 UI 特性或前提不存在的模块。 --- # 第三批(2026-10-14 晚)—— 逐模块对照的**新发现** ## ★ 本轮新补的三个真缺陷 ### ① 自动领取每天静默漏领(`credits.ts` / `auto-checkin.ts`) 活动刷新时刻**各平台不同**(Qoder 每日 10:00 UTC+8),而我们只在 08:00 跑一次 ⇒ 上午看到昨天那条 `CLAIMED`、报 `already-claimed` 并记账 ⇒ 当天再不去领。 **修法照参考**:不配"时刻表"(必然漂移),让**产出方声明 `covers_today`**, 记账方只数"有几条能证明今天已处理"。两层都要:频率提高 + `first_due_now`。 ### ② 凭据续期此前是假的(`refresh.ts` / `expiry-sync.ts`) `account_refresh` **一个换 token 的请求都不发**却恒返回 `ok:true` ⇒ `refresh_creds` 打「已续期 N 个」而什么都没续 —— **日志在说谎**。 这是本轮第三次遇到同形态(前两次:`account_test` 恒 ok、业务级错误被当成功)。 ### ③ `isFree` 与声明自相矛盾(`cline-models.ts`) cline 的免费模型声明在 `models` 里 ⇒ 被标成收费。 ★ 关键认识:**两个参考用的是不同机制** —— cline 用远端 `free[]`, opencode 用 models.dev 的 `cost`。混成一条 OR 链会与既有判据正面冲突。 ⇒ 加 `free_source` 由**平台声明**权威来源。 ## 插件自装适配器(第 3、4 个) | 适配器 | 解决的问题 | |---|---| | `channels-qoder` | 错误信封**双层编码**;`10605`(排队) 与 `110`(额度耗尽) 语义**相反**,必须分开说(参考实测:110 原被归成通用服务端错误 → 白重试 5 次 ≈15.5 秒) | | `channels-cline` | 429 **无 retry-after 头**、等待时长写在英文句子里(`Try again in 19h 39m`)。解析不出 ⇒ 内核按 30 秒短冷却 ⇒ **每 30 秒猛敲上游**(比参考的「永远 60 分钟」更糟) | 现共 4 个:`channels-trae` / `channels-gemini` / `channels-qoder` / `channels-cline`。 ## 对照后判定「不适用」的(附理由,不是"没做") | 模块 | 为什么不需要 | |---|---| | `account-pool` 并发写竞态 | 我们的落盘是**同步** tmp+rename(Lua 无异步),每次写序列化**当前** state。参考的缺陷是"异步后端 + 共享可变快照"的产物。**已加判据钉住**(已删账号不复活、新增不丢失、内存/磁盘不分叉) | | `gemini-messages` SSE 末帧丢失 | 我们的 `bufio.Scanner` 对无尾换行的最后一行**照样产出**(已实测自证)。参考那是在 `while(!done)` 里按行处理所致 | | `cline-rate-limit` 的 30 分钟策略冷却 | 我们**每账号一个源**(`channels--`)⇒ 某账号被拦只冷却它自己,下个请求自然换源。参考是"一个池里换号",才需要长冷却挡住同一轮里的连续重撞 | | `message-shape` | DSH 宿主的消息形状迁移;我们直接透传 OpenAI 形状 | | `loop-recovery` | 前提不存在(我们的内核没有循环守卫) | | `reasoning-ladder` | 需要内核支持 `efforts` 字段(属内核改动,按纪律不做) | | `retry-after.ts` | 参考那份实现**自己就是死代码**(grep 无消费者);我们的固定 30s 冷却有明确理由(429 是"太快"不是"坏了",走指数退避会让额度充沛的源被锁 30 分钟) | | `image-budget` | 实际缩放委托给宿主的附件服务,我们无对应物 | ## 运行环境的一条教训(不是代码缺陷) 全量 `go test` 曾出现 `TestConcurrentPluginAdapterUpdates` **挂 59 分钟**、 看着像死锁。单独跑 **0.058s 通过** ⇒ 真因是 **`/tmp`(8.8G tmpfs)被 Go 构建 缓存占满 100%**。教训:构建/测试挂住时先 `df -h /tmp`(`/` 空闲不代表 `/tmp` 够)。