Files
ModelRouter/docs/qoder-wasm-integration.md
dev d3bb47bd54 docs(qoder): wasm 接入评估 —— 事实固化 + 可复现取证工具(未实现)
对应主管任务 A/B。**没有实现 wasm 接入**,只交付事实结论与可执行方案。

## A. P1-4 结论固化(docs/channel-repair-plan.md)

原 P1-4 只有一句「阻塞:二进制不在仓库任何位置」,现补成可审计的事实段:

- 绝对路径 / 298606 字节 / sha256 6419471e…55b43d(位于第三方插件
  `dsh-our-free-model` 的 vendor 目录,**不随本仓分发**;本仓
  `git ls-files | grep -i wasm` = 0);
- 它是 wasm-bindgen 产物:31 个 import 全来自单一 JS glue 模块
  `./qoder_auth_wasm_bg.js`,且该 glue **上游也没提供**(改为手写
  `src/qoder-wasm.ts` 706 行)⇒ 代价在宿主侧;
- 31 import / 34 export 各分四类,逐类给出用途与成员;
- **我们仓库没有 wasm 运行时入口**:`grep -rniE 'wazero|wasmtime|wasmer|
  tetratelabs'` = 0 命中,`go.sum` wasm 命中 = 0,Lua 侧只有注释。

只写事实,未写「已修复」。

## B. 可行性判定(docs/qoder-wasm-integration.md,新文件)

实测推翻了一个此前的假定:**不需要 JS 引擎,纯 Go(wazero)可以驱动它**。
证据来自新增的 tools/qoder-wasm-probe(独立 module,主仓 go.mod 不动):

- 实例化:31 桩,实例化阶段真实调用 0 次;
- level 2 `generate_runtime_auth_fields` → encrypt_user_info/key 各 172 字符;
- level 3 `qodercontext_new` + `prepareInferRequest` → 20 个头,其中
  `Authorization: Bearer COSY.<载荷>.<签名>`(len=209),body 60 字节密文;
- 逐 import 计数:31 个里 **13 个从未被调用**(node/JS 探针居多);
- 量化:≈2.2 ms/请求,线性内存 1.25 MiB,实例可复用(-repeat 50 全 PASS)。

文档含:依赖选型(wazero vs wasmtime-go/wasmer-go,+1.16 MiB 实测)、
接入点设计(走 `__llmsproxy_kernel` 而非新开 Lua 全局,保持「按插件声明授权」)、
31 import 逐个的 Go 实现映射表、6 条 ABI 陷阱(两个 getRandomValues 方向相反、
两套返回布局、requestresult_url 参数顺序、drop_ref 必须 no-op、machineId 必填、
host 不能是 api2-v2)、9 条风险、7 层改动面。

## 为什么本轮不实现

跨 7 层、需新增内核依赖、且三个前置阻塞都不该由 fix 分支暗定:
① wasm 从哪来(不可再分发 / 需运维决策);② 加密端点的**响应也要解密**且是 SSE
逐帧(第二条未实现链路);③ 账号数 0 ⇒ 端到端不可验证。

## 纪律声明

- 未动 main、未开 PR、未部署、未 restart;
- 主仓 `go.mod`/`go.sum` **零改动**(tools/ 是嵌套 module,`go build ./...` 跳过);
- `go test -tags luajit ./...` 全绿(core 420s / gateway 206s);
- **本机 qoder 账号数 0**:能证明「产出了签名头」,不能证明「上游接受」。
  文档内每处 PASS 都标注是离线结论。

## 顺带更正一处过时事实

`internal/core/channels_upstream_gaps_test.go` 注释称 wasm「在参考仓库里也不存在」。
实测:文件存在(sha256 6419471e…),只是不在**我们**仓库。文档已记,本轮不改
代码避免动判据;接入时必须同步演进那条判据(否则新旧逻辑互相打脸)。
2026-10-18 07:36:49 +08:00

405 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Qoder WASM 接入评估(`qoder-auth-wasm.wasm`)
> **状态:未实现(not implemented)。**
> 本文给出**已实测的可行性结论**与**可执行的接入方案**,但我们仓库里
> **没有任何 wasm 运行时、也没有任何代码加载这个 wasm**。
> 本文写于 `fix/channel-auth-headers` 分支,仅文档 + 评测工具,不动内核。
> **判据 ≠ 功能可用。** 本文所有「PASS」都是**本机静态/离线**结论:
> qoder 账号数 **0**,所以「签名头是否被 Qoder 上游接受」**没有做过运行时验证**。
---
## 1. 结论先行
| 问题 | 结论 | 依据 |
|---|---|---|
| wasm 能不能被加载? | **能**,且**不需要 JS 引擎** —— 纯 Go(wazero)即可实例化 | 31 个 import 全部由 Go 侧桩提供,实例化阶段真实调用 **0** 次 |
| 能不能真正跑出鉴权头? | **能**,且**已实测做到** | `prepareInferRequest` 产出 20 个头,含 `Authorization: Bearer COSY.<载荷>.<签名>`,body 为 60 字节加密串 |
| 31 个 import 要全部实现吗? | **不用。实际路径只触碰 18 个**,其中 5 个是 node/JS 环境探针(`process`/`node`/`require`/`versions`/`call`),**本路径一次都没调用** | 逐 import 计数(§4 表) |
| 最小可行接入是什么? | **新增一个 `build_headers` 后端(wazero)**,由适配器把 `meta` 交给它,拿回 headers 原样透传 | §5 |
| 本轮能做进 `fix/` 分支吗? | **不能**。见 §7 改动面与风险 —— 这属于主分支级改动 | §7 |
| 风险最高的是什么? | **不是** wasm 调用,而是**二进制来源**(不在我们仓库,也不可再分发)与**账号数为 0 无法端到端验证** | §6 |
| 性能可以接受吗? | **可以**。实测 ≈ **2.2 ms/请求**,线性内存 ≈ **1.25 MiB**,实例可复用 | §8 |
> ⚠️ **一条红线**:qoder 加密端点要求 `Authorization` **原样透传**。
> 任何「适配器里再兜底一次 `Bearer <api_key>`」的写法都会覆盖签名,得到
> `403 Signature invalid`。这必须在接入时由代码结构保证,不能只靠注释
> (见 §5.4)。
---
## 2. 目标物:abs 路径 / 大小 / sha256
| 项 | 值 |
|---|---|
| 绝对路径 | `/opt/dsh-plugins/dsh-our-free-model/2.0.0/vendor/channel-pack/qoder-auth-wasm.wasm` |
| 大小 | `298606` 字节(291.6 KiB) |
| sha256 | `6419471effa631519def7797d76d7ede38b9fcfa9a83c2148c8ef5d43355b43d` |
| 格式 | `WebAssembly (wasm) binary version 0x1 (MVP module)` |
| 归属 | 第三方插件 `dsh-our-free-model` 的 vendored 依赖;`NOTICE.md` 注明来自 MIT 许可的上游 channel-pack,**不随本仓分发** |
| 本仓引用 | `git ls-files \| grep -i wasm` = **0 条** |
同一目录下的相关文件(**都不在我们仓库里**):
| 文件 | 作用 |
|---|---|
| `pack.js`(2.8 MB) | 插件运行时实际加载的单文件 bundle |
| `src/qoder-wasm.ts`(706 行) | **手写的 wasm-bindgen glue** —— 31 个 import 的逐个实现 + ABI 助手。本文的 Go 实现即照它翻译 |
| `NOTICE.md` / `LICENSE` | 出处与许可(MIT) |
> `qoder_auth_wasm_bg.js`(wasm-bindgen 本该生成的那份 glue)在该目录中
> **不存在** —— 上游改用手写的 `qoder-wasm.ts` 替代。这一点决定了接入代价
> 落在**宿主侧**,而这正好是可以由 Go 承担的部分。
---
## 3. 为什么它是「wasm-bindgen 产物,给 JS 用」
解析二进制各 section(不是推断):
- **只有 1 个 import module**:`./qoder_auth_wasm_bg.js`,31 个 import **全部**来自它;
- import 名同时带有 wasm-bindgen 哈希后缀(`__wbg_set_08463b1df38a7e29`)与
`__wbindgen_*` ABI 前缀;
- 导出含 ABI plumbing:`memory`、`__wbindgen_add_to_stack_pointer`、`__wbindgen_export1..4`;
- 返回多值靠**写栈槽**传递(`ptr/len/valIdx/isErr`),这是 wasm-bindgen 的约定。
⇒ 它**不是**「喂 WASI 就能跑」的模块。它要求宿主实现 `JS 值语义`
(对象表 + 索引、`Uint8Array`、`Map`、`crypto.getRandomValues`、`Date.now`)。
`--export-all` 之类参数改不了这一点:ABI 就写在 import 表里。
---
## 4. 31 import / 34 export 的四类用途(附实测触碰次数)
### 4.1 imports(31 个,全部来自 `./qoder_auth_wasm_bg.js`)
| 类 | # | 成员 | 纯 Go 可否 | **实测触碰** |
|---|---|---|---|---|
| **A. bindgen 对象表 ABI** | 4 | `__wbindgen_object_drop_ref`、`object_clone_ref`、`cast_0000000000000001`、`cast_0000000000000002` | ✅ 自建 `[]any` 索引表 | 3(`cast_..2`×40、`clone`×2、`drop`×65) |
| **B. 字节缓冲 / Map** | 6 | `new_with_length`、`length`、`subarray`、`set`、`prototypesetcall`、`new_99cabae`(Map) | ✅ `[]byte` + `map` | 6(全部触碰) |
| **C. 随机源与时间** | 4 | `getRandomValues` **两个方向相反的变体**、`randomFillSync`、`now` | ✅ `crypto/rand`、`time.Now` | 3(`randomFillSync` 未被调用) |
| **D. JS/Node 环境探针** | 17 | `crypto`、`msCrypto`、`process`、`versions`、`node`、`require`、`call`、`globalThis`、`self`、`window`、`throw`、`Error`、`is_object`、`is_string`、`is_function`、`is_undefined` | 部分:`self`/`window` 返 `0`;`process`/`node` 等**从不被调用**即可 | 4(`crypto`×1、`globalThis`×1、`self`×1、`window`×1、`is_object`×1、`is_undefined`×1) |
**实测合计:31 个中 13 个「从未被调用」。** 未触达的 13 个:
```
__wbg_process_44c7a14e11e9f69e __wbg_versions_276b2795b1c6a219
__wbg_node_84ea875411254db1 __wbg_require_b4edbdcf3e2a1ef0
__wbg_msCrypto_bd5a034af96bcba6 __wbg_randomFillSync_6c25eac9869eb53c
__wbg_call_d578befcc3145dee __wbg_static_accessor_GLOBAL_f2e0f995a21329ff
__wbg___wbindgen_throw_81fc77679af83bc6 __wbg_Error_2e59b1b37a9a34c3
__wbg___wbindgen_is_string_b29b5c5a8065ba1a
__wbg___wbindgen_is_function_49868bde5eb1e745
__wbindgen_cast_0000000000000001
```
> 这是**本路径**的观测(`generate_runtime_auth_fields` + `qodercontext_new`
> + `prepareInferRequest`)。错误分支会用到 `throw`/`Error`,所以实现里
> 仍应给它们一个真实语义,而不是留空。
### 4.2 exports(34 个)
| 类 | # | 成员 | 与本接入的关系 |
|---|---|---|---|
| **1. 鉴权字段生成** | 1 | `generate_runtime_auth_fields` | **必需**(`encrypt_user_info` / `key` 是 `qodercontext_new` 的前置) |
| **2. 请求构造 / 加解密** | 13 | `qodercontext_new`、`qodercontext_prepareInferRequest`、`qodercontext_prepareRequest`、`qodercontext_refreshAuthFields`、`qodercontext_get_external_providers_access`、`requestresult_url\|body\|headers\|headerCount`、`decrypt_server_response`、`profile_encrypt`、`__wbg_qodercontext_free`、`__wbg_requestresult_free` | **核心**:本接入只需其中 6 个 |
| **3. 本地存储 / 缓存 / 凭据加解密** | 8 | `credential_storage_encrypt\|decrypt`、`model_cache_encrypt\|decrypt`、`profileencryptor_new\|encryptChunk\|header`、`__wbg_profileencryptor_free` | **不需要**(推理路径不涉及;仅「离线读本机目录缓存」会用到) |
| **4. 静态常量 / HTTPDNS / 环境探测** | 6 | `get_httpdns_config\|account_id\|secret_key`、`build_httpdns_url`、`get_external_providers_feature_gate_fallback`、`get_profile_key_fingerprint` | **不需要**(可换成我们自己的常量) |
| plumbing | 6 | `memory`、`__wbindgen_export1..4`、`__wbindgen_add_to_stack_pointer` | 必需 |
> 即:**34 个 export 里,接入只需要 1 + 6 + plumbing**。
> 其余 27 个不影响「能不能发出一条被签名的 qoder 请求」。
---
## 5. 最小可行接入方案(设计,未实现)
### 5.1 依赖选型
| 候选 | 结论 | 理由 |
|---|---|---|
| **`github.com/tetratelabs/wazero` v1.9.0** | ✅ **推荐** | 纯 Go、`CGO_ENABLED=0` 可交叉编译、**零传递依赖**(`go list -m all` 只有它自己);`go 1.22` 下限,本仓 `go 1.25` 无冲突 |
| `wasmtime-go` | ❌ | 需要 CGO + 预编译动态库 ⇒ 破坏 `go build` 的单二进制分发与 Windows 交叉编译 |
| `wasmer-go` | ❌ | 同上,且维护活跃度低 |
| 自己写解释器 | ❌ | 不值当 |
| 用 Node 子进程跑上游 `pack.js` | ⚠️ 备选 | 现有 node 可跑,但把「一个 Go 单二进制网关」变成「依赖宿主 node + 一份 2.8 MB 外部 bundle」,运维形态倒退 |
体积实测(`CGO_ENABLED=0`,空 `main`):
| 构建 | 大小 |
|---|---|
| 空程序 | 1,895,775 B |
| `+ wazero`(仅引用 `NewRuntime`) | 3,106,358 B |
| 增量 | **≈ +1.16 MiB** |
> 交叉编译已验证:`GOOS=windows GOARCH=amd64 CGO_ENABLED=0` 出 3,050,496 B,
> 与现有 `gui-win` 流程兼容。
### 5.2 二进制从哪来(**必须先解决**)
wasm **不入库**会带来三个问题:换机失效、插件升级后 sha256 漂移、离线构建不安全。
三条可选路径,推荐 ①:
| 方案 | 做法 | 代价 |
|---|---|---|
| **① 运维侧提供,启动时校验**(推荐) | 新增配置项 `qoder_wasm_path`;启动/首次使用时读文件,**校验 sha256 必须等于** `6419471e…55b43d`,不符则拒绝启用并报错 | 需要运维放一份文件;但**不引入许可问题**,且校验把「用错版本」变成显式失败 |
| ② 我们仓库 `go:embed` | 把 wasm 放进 `internal/qoderwasm/assets/` 并 embed | ✅ 单二进制、无外部依赖;❌ **许可与再分发**需先确认(上游 MIT,但需保留 NOTICE),且 291 KiB 进 git |
| ③ 运行时从上游插件目录读(我们现在的路径) | 直接读 `/opt/dsh-plugins/...` | ❌ 对别人安装目录硬依赖,**不建议进主干** |
> **本轮不选任何一条** —— 选型本身就是需要主管决策的事项(涉及再分发与
> 运维约定),不该由一条 fix 分支暗定。
### 5.3 接入点:放在哪
现架构事实(`grep` 实测):
- 适配器是 **Lua**(`internal/lua/adapters/*.lua` + 插件自装 `channels-qoder`);
- `channels-qoder` 目前**没有 `build_headers`**,靠静态头 + 内核兜底 `Bearer`;
- 内核统一入口是 `Provider.buildHeaders`(`internal/provider/provider.go:1104`)
→ `VM.BuildHeaders`(`internal/lua/vm.go:1077`)→ Lua `adapter.build_headers(meta)`;
- **`go.mod` 里没有任何 wasm 运行时**;`internal/` 下 wasm 相关命中**全部是注释**;
- Lua 侧**没有** wasm 能力(`channels.lua:141` 自己写明「无法在当前 Lua 环境完成」)。
⇒ 既然桥接面是 `build_headers(meta) → map[string]string`,**最干净的接法是在
这个桥后面加一个 Go 侧后端**,而不是把 wasm 塞进 Lua:
```
Provider.buildHeaders (internal/provider/provider.go)
└─ VM.BuildHeaders (internal/lua/vm.go)
└─ Lua adapter.build_headers(meta) ← channels-qoder 适配器
└─ __llmsproxy_kernel("qoder.wasm_headers", meta) ← 新增内核动作
└─ internal/qoderwasm.PrepareHeaders(meta) ← 新增包(wazero)
└─ qoder-auth-wasm.wasm
```
**为什么走 `__llmsproxy_kernel` 而不是新增一个 Lua 全局**:
`internal/lua/plugin_bridges.go:146` 已有 `registerKernelBridge`,它是
**按插件声明授权**的(插件在 `plugin.kernel_actions` 里列白名单)。
新增一个 Lua 全局等于给**所有**插件开能力;走 kernel action 则保持「谁声明
谁获得」。这与现有 `plugin_http.go` 的收窄思路一致。
**为什么不在 provider 里直接 if-else 厂商名**:本仓的既有约束是
「内核零厂商名」(见 `channels.lua:1017` 的注释与 P2 段)。wasm 调用应作为
一个**通用内核能力**暴露(`wasm.headers`),由插件把 qoder 语义喂给它。
### 5.4 输入 / 输出边界
```go
// internal/qoderwasm(新包,示例形参;未实现)
type Credentials struct {
UID string `json:"uid"`
SecurityOauthToken string `json:"security_oauth_token"`
OrganizationID string `json:"organization_id"`
OrganizationTags []string `json:"organization_tags"`
DataPolicyAgreed bool `json:"data_policy_agreed"`
MachineID string `json:"machine_id"` // 必填:缺失会 AES-GCM 失败
ClientVersion string `json:"client_version"` // 默认 "1.1.49"
}
type Metadata struct { // 写入 QoderContext
ClientType string `json:"client_type"`
BusinessProduct string `json:"business_product"`
BusinessType string `json:"business_type"`
Scene string `json:"scene"`
}
type InferRequest struct {
Host string // ⚠️ 必须 api2.qoder.sh 系,不能是 api2-v2
Body json.RawMessage // 明文 payload(由插件构造)
ModelKey string // 目录 key,如 qfmodel
Source string
}
// PrepareHeaders 返回**必须原样透传**的头集合。
func PrepareHeaders(ctx context.Context, c Credentials, m Metadata, r InferRequest) (map[string]string, error)
```
**输出实测形态**(20 个头):
```
Accept text/event-stream Authorization Bearer COSY.<载荷>.<签名>(len=209)
Cache-Control no-cache Connection keep-alive
Content-Type application/json Cosy-Business-Product qoder
Cosy-Business-Type default Cosy-ClientType 10
Cosy-Data-Policy disagree Cosy-Date 1792279699
Cosy-Key <modelKey> Cosy-MachineId <machineId>
Cosy-MachineToken <machineId> Cosy-MachineType 5
Cosy-Scene chat Cosy-User <uid>
Cosy-Version 1.1.49 Login-Version v2
X-Model-Key <modelKey> X-Model-Source system
```
> 其中只有 `Authorization` / `Cosy-Key` / `Cosy-MachineId` / `Cosy-User` 等是
> **动态**的;其余来自 `Metadata`/常量。注意 `Cosy-Date` 并非当前时间
> (实测 `1792279699`,是 WASM 内部计算),**不要用 Go 时间覆盖**。
**红线(结构保证,不是注释)**:`Provider.buildHeaders`
(`provider.go:1141`)当前逻辑是
`if h.Get("Authorization")=="" && h.Get("X-Api-Key")=="" { 补 Bearer }`。
qoder 拿到签名 `Authorization` 后自然跳过兜底 —— 这个**已有**行为正好安全,
但新接入**必须有一个判据钉住**:签名头存在时兜底 Bearer 不得生效。
### 5.5 31 个 import 的 Go 实现映射表
| import | Go 实现 | 备注 |
|---|---|---|
| `__wbindgen_object_drop_ref` | **no-op(不释放槽位)** | ⚠️ 实测该模块会 `drop` 一个 Map 之后**继续用同一索引** `set`。wasm-bindgen 新版把 drop 退化为空操作(表交给 JS GC)。按「释放槽位」实现会得到 `set on undefined` |
| `__wbindgen_object_clone_ref` | `push(obj[idx])` | |
| `__wbindgen_cast_..._1` | wasm slice → `[]byte` | 本路径未触碰 |
| `__wbindgen_cast_..._2` | wasm str → Go `string` | 最高频(40 次):字符串返回靠它 |
| `__wbg_new_with_length` | `make([]byte, n)` | |
| `__wbg_length` | `len(buf)` | |
| `__wbg_subarray` | `buf[lo:hi]`(**共享底层数组**,不要 copy) | |
| `__wbg_set` | 两种形态:`[]byte.set(src, off)` / `Map.set(k,v)` | 全靠 `arg0` 的动态类型分派 |
| `__wbg_prototypesetcall` | 直接写 wasm 内存 | |
| `__wbg_new_99cabae` | `new Map()` → 有序 `{keys,vals}` | `requestresult_headers` 的容器 |
| **`__wbg_getRandomValues_d49329ff89a07af1`** | `crypto/rand` 写 **wasm 内存** `(ptr,len)` | ⚠️ 与下面那个**签名方向相反** |
| **`__wbg_getRandomValues_c44a50d8cfdaebeb`** | 对 **crypto 对象** `(objIdx, bufIdx)` 填随机 | ⚠️ 写反 ⇒ Rust `unreachable` panic |
| `__wbg_randomFillSync` | `crypto/rand` 填 `[]byte` | 本路径未触碰 |
| `__wbg_now` | `float64(time.Now().UnixMilli())` | 返回 **f64** |
| `__wbg_crypto` / `__wbg_msCrypto` | 返回一个「crypto 对象」标记值 | 只为喂 `getRandomValues` 变体 B |
| `__wbg_static_accessor_GLOBAL_THIS` / `GLOBAL` | 返回任意非 0 索引 | |
| `__wbg_static_accessor_SELF` / `WINDOW` | **返回 0** | 服务端无 window/self,wasm-bindgen 约定 0 = 缺席 |
| `__wbg___wbindgen_is_object` | 类型判断(`[]byte`/`Map`/crypto) | |
| `__wbg___wbindgen_is_string` / `is_function` / `is_undefined` | 类型判断 | 后两个本路径未触碰 |
| `__wbg___wbindgen_throw` | 记日志(或抛 panic 转 error) | 错误分支 |
| `__wbg_Error` | `fmt.Errorf(读出的字符串)` | 错误分支 |
| `__wbg_process` / `versions` / `node` / `require` / `call` | 返回 `undefined` / 0 | **实测从未被调用**;但若被调用说明换了 wasm 版本,应告警 |
| `__wbindgen_add_to_stack_pointer` / `export1..4` | 由 wasm 自己提供(是 **export**,不是 import) | |
### 5.6 调用顺序与 ABI 陷阱(都实测过)
1. `generate_runtime_auth_fields(userJSON)` → `{encrypt_user_info, key}`(各 **172** 字符)
2. `qodercontext_new(sp, machineId, len, version, len, userInfo, len, clientMeta, len)`
- 返回布局是 **`ptr/errIdx/isErr`**(与字符串类的 `ptr/len/valIdx/isErr` **不同**,混用会得到 `null pointer passed to rust`)
3. `qodercontext_prepareInferRequest(sp, ctxPtr, host, len, body, len, key, len, source, len)`
- 同样是 `ptr/errIdx/isErr`
4. `requestresult_headers(resPtr)` → Map
5. `requestresult_url(sp, resPtr)` / `requestresult_body(sp, resPtr)`
- ⚠️ **参数顺序是 `(栈指针, ptr)`**,与直觉相反
另外两条实测纪律:
- ⚠️ `machineId` 是**必填**:漏传得到 `AES-GCM decrypt failed: aead::Error`,
看起来像「密文损坏」,实际是缺参数。
- ⚠️ `host` 必须 `api2.qoder.sh` 系;传 `api2-v2.qoder.sh`(公开 OpenAI 兼容端点)
会 **404**。
### 5.7 并发模型
- **wasm 实例无状态可复用**,实测 `-repeat 50` 全部 PASS;
- 但上游注释明确 `QoderContext` **不是线程安全的**,且「一个账号一个实例」;
- ⇒ 设计为 **每个账号一个 `QoderContext` + 一把互斥锁**(复用现有 pool 模式),
或按 `(uid, machineId)` 做 key 的 `sync.Map`。**不要**单实例全局共享。
- 兜底:`objectHeap` 是**只增不回收**的(见 §5.5 drop_ref),长跑需设
`qodercontext_free` + 定期重建实例,否则对象表单调增长。
---
## 6. 风险点
| # | 风险 | 级别 | 说明 / 缓解 |
|---|---|---|---|
| R1 | **二进制不在我们仓库,也不可再分发** | 🔴 高 | 需先定 §5.2 的三种来源方案之一;`go:embed` 需法务/许可确认 |
| R2 | **账号数 0 ⇒ 端到端不可验证** | 🔴 高 | 本机能证明「产出了签名头」,**不能**证明「上游接受」。必须拿到 1 个 qoder 账号才能做真实验收 |
| R3 | wasm 版本漂移 | 🟠 中 | 上游换版本 ⇒ sha256 变,import 表可能变(尤其那 13 个未触碰项)。**必须**启动时校验 sha256 并在版本不符时显式拒绝 |
| R4 | `drop_ref` 语义与 wasm-bindgen 版本相关 | 🟠 中 | 本次实测必须 no-op;换 wasm 后需重跑 `tools/qoder-wasm-probe` 复验,否则会退化成 `set on undefined` |
| R5 | 对象表只增不回收 ⇒ 内存缓涨 | 🟠 中 | 每个 `QoderContext` 定期重建;`requestresult_*` 用后 `__wbg_requestresult_free` |
| R6 | 加密端点意味着**响应也是密文** | 🟠 中 | 需 `decrypt_server_response` 解包,且要处理 SSE 逐帧解密(上游走 `Encode=1`)。这是**第二条**未实现链路,工作量不小 |
| R7 | 端点语义与现有明文路径**并存** | 🟡 低-中 | `channels_upstream_gaps_test.go` 现有判据钉住「走明文端点且无信封解包」。接入加密端点必须**同时**改那条判据,否则两套逻辑互相打脸 |
| R8 | 引入 wazero 抬升内核版本 | 🟡 低 | 按 §4 的版本纪律,新增 Go 依赖 = 核心版本递增(不是 patch) |
| R9 | 体积 +1.16 MiB | 🟡 低 | 桌面端/服务端均可接受,但需确认 `packaging/` 的体积校验阈值 |
---
## 7. 预计改动面(为什么不该放进 `fix/` 分支)
| 层 | 文件(预计) | 性质 |
|---|---|---|
| 新依赖 | `go.mod` / `go.sum`(wazero) | **内核级** |
| 新包 | `internal/qoderwasm/{glue.go,abi.go,context.go,headers.go}` + 测试 | 新代码 ≈ 600–900 行(glue 706 行 TS 的 Go 翻译) |
| 内核桥 | `internal/lua/plugin_bridges.go`(新增 kernel action)、`internal/lua/kernel_api.go` 白名单 | **内核级** |
| 插件 | `internal/lua/plugins/channels.lua`(`channels-qoder` 增加 `build_headers` + 密文响应解包) | 插件级 |
| 判据 | `internal/core/channels_upstream_gaps_test.go`(**必须改**,见 R7)、新增 adapter 判据 | 判据级 |
| 部署 | `packaging/`(wasm 放置 + 校验)、`config`(`qoder_wasm_path`) | 运维级 |
| 文档 | 本文 + `docs/channel-repair-plan.md` P1-4 段 | 文档级 |
**结论:这是一个跨 7 个层、含新内核依赖、且需要账号才能验收的主分支级改动。**
一条 `fix/channel-auth-headers` 分支承担它会把「已修好的三条头缺陷」
一起拖成不可发布状态。⇒ 本轮**不做**,只交付本文 + 可复现工具。
### 建议的推进顺序(若主管决定做)
1. **先决策 §5.2**(wasm 从哪来)—— 这是唯一的前置阻塞。
2. `internal/qoderwasm` 落地 + 用 `tools/qoder-wasm-probe` 的用例做单测
(**离线可测**:不需要账号,断言头集合形状与 sha256 校验)。
3. 内核桥 `wasm.headers` + 插件 `build_headers` 接线,**只走明文端点**先跑通
头生成链路(不切端点)。
4. 拿到 1 个真实账号后,验证「签名头被接受」→ 再决定是否切加密端点。
5. 切端点那一轮再处理 R6(响应/SSE 解密)与 R7(判据改写)。
---
## 8. 实测记录(可复现)
工具:`tools/qoder-wasm-probe`(**独立 module**,主仓 `go.mod` 不含 wazero)。
```bash
cd tools/qoder-wasm-probe
go run . -wasm /opt/dsh-plugins/dsh-our-free-model/2.0.0/vendor/channel-pack/qoder-auth-wasm.wasm
# 加 -json 出结构化结果;-repeat N 测实例复用;-strict 让未实现 import 被调用即失败
```
本次输出(2026-10-18,go1.27.1 linux/amd64,wazero v1.9.0):
```
wasm : .../qoder-auth-wasm.wasm
size : 298606 bytes
imports : 31 (all from "./qoder_auth_wasm_bg.js")
exports : 33
instantiate: true calls-at-instantiate=0
-- LEVEL 2: generate_runtime_auth_fields --
encrypt_user_info <string len=172>
key <string len=172>
-- LEVEL 3: qodercontext_new + prepareInferRequest --
url = api2.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1
body = 60 bytes (encrypted, not logged)
hdr Authorization Bearer COSY.eyJ2ZXJzaW9u…<len=209>
...(共 20 个头)
-- IMPORT USAGE (18 of 31 touched) --
RESULT: PASS
```
量化(`-repeat 50 -json`):
| 指标 | 值 |
|---|---|
| 50 次 `prepareInferRequest` 端到端 | ≈ 108 ms ⇒ **≈ 2.2 ms/请求** |
| wasm 线性内存(跑完后) | 20 页 = **1.25 MiB** |
| 未触碰 import | **13 / 31** |
| 实例复用 | 50 次全部 PASS(无状态泄漏迹象) |
> **再次声明:以上全部是离线静态/单机结论。**
> 「签名头被 qoder 上游接受」**未验证** —— 本机 qoder 账号数 **0**。
---
## 9. 与现有实现的冲突(必须在动手前处理)
`internal/core/channels_upstream_gaps_test.go` 当前把**明文端点**写成了判据:
> 「qoder 的 chat 端点变了 —— 若改成加密端点 `agent_chat_generation`,
> 则**必须**同时实现信封解包(qoder-envelope)与 WASM 签名」
这条判据是对的(它防的是「只改一半」)。接入 wasm 时**它必须同步演进**,
否则会出现「新代码已走加密端点、旧判据仍断言没有信封解包」的自相矛盾。
> 注意该判据注释里的一处**过时事实**:它写「该二进制在参考仓库里也不存在」。
> 实测:文件**存在**于 `vendor/channel-pack/qoder-auth-wasm.wasm`
> (sha256 `6419471e…`),只是**不在我们仓库**。接入时应顺手更正这句,
> 但**不要**为了「改注释」而放宽判据。