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…),只是不在**我们**仓库。文档已记,本轮不改
代码避免动判据;接入时必须同步演进那条判据(否则新旧逻辑互相打脸)。
This commit is contained in:
dev
2026-10-18 07:36:49 +08:00
parent 4174310421
commit d3bb47bd54
5 changed files with 1172 additions and 1 deletions

View File

@ -0,0 +1,404 @@
# 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…`),只是**不在我们仓库**。接入时应顺手更正这句,
> 但**不要**为了「改注释」而放宽判据。