diff --git a/docs/channel-repair-plan.md b/docs/channel-repair-plan.md index 49543b6..7a43c80 100644 --- a/docs/channel-repair-plan.md +++ b/docs/channel-repair-plan.md @@ -123,7 +123,7 @@ | P1-1b | `channels-zcode` | **★ 证据冲突**:zcode 的 Messages 端点虽然**说 Anthropic 协议**,鉴权却是 `Bearer `(参考 `zcode-transport.ts:121-125`,全仓 `x-api-key` 命中 **0** 次)。基座补了 x-api-key 后它**继承就会变错** | zcode **显式覆盖** `build_headers` 发 Bearer,不用继承 | ✅ 已修+已判据;运行需账号 | | P1-2 | `channels-cline` | 参考要 `Bearer workos:`,我们的 token 无 `workos:` 前缀 | 适配器 `build_headers` 补前缀(幂等,已带不重复) | ✅ 已修+已判据;运行需账号 | | P1-3 | `channels-gemini` | 有静态头 + Bearer ⇒ **本来就对**(参考 `gemini.ts:353-366` 也是 OAuth Bearer,且刻意不发 `x-goog-api-key`) | **不改**,只加判据把这个结论钉住 | ✅ 已确认+已判据 | -| P1-4 | `channels-qoder` | 头由 **WASM 生成必须原样透传**,我们拿不到 `.wasm` | **阻塞**:二进制不在仓库任何位置 | 不可验证 | +| P1-4 | `channels-qoder` | 头由 **WASM 生成必须原样透传**,我们拿不到 `.wasm` | **仍阻塞**:二进制不在我们仓库,且本仓**没有 wasm 运行时入口**。结论与接入方案见下节与 `docs/qoder-wasm-integration.md` | 本机账号 0 ⇒ 运行时不可验证 | > #### ★ P1-1 与 P1-1b 的证据冲突(本轮最重要的一条) > @@ -144,6 +144,85 @@ > `buildHeaders` 的兜底 Bearer **必须让位**(否则同一请求带两套鉴权), > 这处改动在 `internal/provider/provider.go`(内核改动)。 +> #### ★ P1-4 的结论:qoder 为什么卡住(事实陈述,**未修复**) +> +> 本段只记录查证结果,不代表任何修复已完成。 +> +> **① 二进制客观存在,但不在我们仓库里。** +> +> | 项 | 值 | +> |---|---| +> | 绝对路径 | `/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 条**。任何「我们直接调用它」的实现都是 +> 对**别人安装目录**的硬依赖,换机/升级插件即失效。这是 P1-4 的第一层阻塞。 +> +> **② 它是 wasm-bindgen 产物,为 JS 宿主生成,不是 self-contained wasm。** +> +> 证据(解析二进制 section 得到,非推断): +> - 只有 **1 个 import module**:`./qoder_auth_wasm_bg.js`,31 个 import **全部**来自它; +> - import 名带 wasm-bindgen 的哈希与 `__wbindgen_*` ABI 前缀 +> (`__wbindgen_object_drop_ref` / `__wbindgen_object_clone_ref` / +> `__wbindgen_cast_0000000000000001|2` / `__wbg_static_accessor_GLOBAL_THIS_*`); +> - 导出含 `__wbindgen_add_to_stack_pointer` / `__wbindgen_export1..4`、`memory`。 +> +> 结论:它**不是**「喂 WASI 就能跑」的模块,而是要求宿主**自己实现一套 JS 值语义** +> (对象表 + 索引、`Uint8Array`、`Map`、`crypto.getRandomValues`、`Date.now`)。 +> wasm-bindgen 的标准做法是「随 wasm 一起生成 12 KB 的 JS glue」,而该目录里 +> **没有** `qoder_auth_wasm_bg.js` —— 上游改为手写了一份等价 glue +> (`vendor/channel-pack/src/qoder-wasm.ts`,706 行,含 31 个 import 的逐个实现)。 +> 这是 P1-4 的第二层阻塞:**代价在宿主侧,不在 wasm 侧**。 +> +> **③ 31 个 import 按用途分四类。** +> +> | 类 | 数量 | 代表 | 纯 Go 宿主能否实现 | +> |---|---|---|---| +> | A. bindgen 对象表 ABI | 5 | `__wbindgen_object_drop_ref` / `object_clone_ref` / `cast_..._1` / `cast_..._2` | 能(Go 侧自建 `[]any` 索引表) | +> | B. 字节缓冲原语 | 6 | `__wbg_new_with_length` / `length` / `subarray` / `set` / `prototypesetcall` / `new_...`(Map) | 能(`[]byte` + `map[string]any`) | +> | C. 随机源与时间 | 4 | `getRandomValues` **两个方向相反的变体** / `randomFillSync` / `now` | 能(`crypto/rand`、`time.Now`),**但两个变体必须区分**:一个写 wasm 内存,一个调 JS crypto 对象 | +> | D. JS/Node 环境探针与调用 | 16 | `crypto` / `msCrypto` / `process` / `versions` / `node` / `require` / `call` / `globalThis` / `self` / `window` / `throw` / `Error` / `is_object|string|function|undefined` | 部分能:`self`/`window` 按 JS 惯例返 `0`;`process`/`node`/`require` 返回 `undefined` 即可 —— **前提是从未被调用** | +> +> **④ 34 个 export 按用途分四类。** +> +> | 类 | 数量 | 成员(去 bindgen plumbing) | +> |---|---|---| +> | 1. 鉴权字段生成 | 1 | `generate_runtime_auth_fields` | +> | 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` · 2 个 `__wbg_*_free` | +> | 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` | +> +> 只有第 2 类与 qoder 的**推理鉴权**直接相关(`prepareInferRequest` 产出的 +> `Authorization` 是 `Bearer COSY.<载荷>.<签名>`,**必须原样透传**); +> 第 1 类是它的前置(`encrypt_user_info` / `key`);第 3、4 类在推理路径上不需要。 +> +> **⑤ 我们仓库当前没有 wasm 运行时入口 —— 这是 P1-4 卡住的直接原因。** +> +> | 查证项 | 命令 | 结果 | +> |---|---|---| +> | Go 侧 wasm 运行时依赖 | `grep -rniE 'wazero|wasmtime|wasmer|tetratelabs' --include=*.go --include=*.mod --include=*.sum .` | **0 命中** | +> | `go.mod` 直接依赖 | `cat go.mod` | 仅 `aarzilli/golua` + `yaml.v3` | +> | `go.sum` 中 wasm 相关 | `grep -ci wasm go.sum` | **0** | +> | Lua 侧 wasm 能力 | `grep -rniE 'wasm' internal/lua/` | 仅 **注释**(`plugins/channels.lua:141` 明写「WASM 签名(Qoder) 这类无法在当前 Lua 环境完成」) | +> | 插件 VM 外呼能力 | `internal/lua/plugin_http.go` | 只有受限 HTTP(host 白名单 + 禁重定向 + 4 MiB 上限),**无任意代码执行/无 wasm** | +> +> 即:现状是「**Go 内核无 wasm 依赖、Lua 插件无 wasm 能力**」,而 qoder 的 +> 加密端点头必须由该 wasm 生成。我们目前走的是**明文端点** +> `api2-v2.qoder.sh` + `/model/v1/chat/completions`(见 +> `internal/core/channels_upstream_gaps_test.go` 的判据),因此**不需要** wasm —— +> 代价是该端点只认一小撮通用模型名,拿不到目录里 `qmodel_*` 系列的完整能力。 +> +> **⑥ 本机不可验证。** qoder 账号数 **0**(`plan.md` §1.1 快照), +> 因此「签名头是否被上游接受」这一层**无法做运行时验证**。 +> 能验证的只有静态层:wasm 可实例化、能被驱动产出请求头(见方案文档的实测节), +> **但「判据通过」≠「功能可用」**。 +> +> 可执行的最小路径方案与实测记录另见 **`docs/qoder-wasm-integration.md`**。 + ### P2 — 内核对"限定名"的契约收口 已落地(详见 **`docs/source-name-contract.md`**): diff --git a/docs/qoder-wasm-integration.md b/docs/qoder-wasm-integration.md new file mode 100644 index 0000000..ad93139 --- /dev/null +++ b/docs/qoder-wasm-integration.md @@ -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 `」的写法都会覆盖签名,得到 +> `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 Cosy-MachineId +Cosy-MachineToken Cosy-MachineType 5 +Cosy-Scene chat Cosy-User +Cosy-Version 1.1.49 Login-Version v2 +X-Model-Key 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 + key + +-- 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… + ...(共 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…`),只是**不在我们仓库**。接入时应顺手更正这句, +> 但**不要**为了「改注释」而放宽判据。 diff --git a/tools/qoder-wasm-probe/go.mod b/tools/qoder-wasm-probe/go.mod new file mode 100644 index 0000000..83d6ce9 --- /dev/null +++ b/tools/qoder-wasm-probe/go.mod @@ -0,0 +1,8 @@ +module qoder-wasm-probe + +// 独立 module(不是主 module 的一部分):主仓 go.mod **不引入** wazero。 +// 本目录只用于**评测/取证**,`go build ./...` 与 `go test ./...` 在根目录 +// 会跳过嵌套 module,因此不会给内核带来任何依赖。 +go 1.25 + +require github.com/tetratelabs/wazero v1.9.0 diff --git a/tools/qoder-wasm-probe/go.sum b/tools/qoder-wasm-probe/go.sum new file mode 100644 index 0000000..b0ddcac --- /dev/null +++ b/tools/qoder-wasm-probe/go.sum @@ -0,0 +1,2 @@ +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= diff --git a/tools/qoder-wasm-probe/main.go b/tools/qoder-wasm-probe/main.go new file mode 100644 index 0000000..46a8230 --- /dev/null +++ b/tools/qoder-wasm-probe/main.go @@ -0,0 +1,678 @@ +// Command qoder-wasm-probe 评测 `qoder-auth-wasm.wasm` 能否被**纯 Go** wasm +// 运行时(wazero,无 JS 引擎)驱动,并给出它实际需要宿主提供哪些能力。 +// +// 这是**取证/评测工具**,不是生产代码:它不注册进网关,也不被内核 import。 +// 主仓的 go.mod **不包含** wazero —— 本目录是独立 module。 +// +// 为什么需要它:qoder 的加密推理端点头由该 wasm 生成, +// 而 wasm 是 wasm-bindgen 产物(31 个 import 全部来自一个 JS glue 模块)。 +// "能不能实例化" 与 "能不能真正跑出鉴权头" 是两件事 —— 上游参考甚至没有 +// 提供那份 JS glue(手写了 706 行替代),所以必须实测,不能推断。 +// +// 用法: +// +// go run . -wasm /path/to/qoder-auth-wasm.wasm [-json] +// +// 退出码:0 = 三级证据全部通过;1 = 任一环节失败(详见输出)。 +package main + +import ( + "context" + "crypto/rand" + "encoding/binary" + "encoding/json" + "flag" + "fmt" + "math" + "os" + "sort" + "time" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" +) + +const importModule = "./qoder_auth_wasm_bg.js" + +type probe struct { + ctx context.Context + rt wazero.Runtime + mod api.Module + hits map[string]int + order []string + warns []string + strict bool + repeat int +} + +// ---- JS-ish 值模型 ------------------------------------------------------- +// +// wasm-bindgen 的 ABI 把 JS 值当作整数索引传进导出表。纯 Go 没有这张表, +// 宿主必须自己模拟。qoder 模块实际用到的形状只有三种: +// 1. Uint8Array —— 字节缓冲 +// 2. Map —— requestresult_headers 的产物 +// 3. crypto 对象 —— 只用来喂 getRandomValues(见 C 类 import) + +type ( + undefined struct{} + u8arr []byte + jsMap struct{ keys, vals []string } + cryptoObj struct{} +) + +// wasm-bindgen 把索引 0..1027 保留给 undefined/null/true/false 等哨兵。 +const sentinelN = 1028 + +func newHeap() []interface{} { + o := make([]interface{}, sentinelN, 4096) + for i := range o { + o[i] = undefined{} + } + o[1024], o[1025], o[1026], o[1027] = undefined{}, nil, true, false + return o +} + +func main() { + wasmPath := flag.String("wasm", "", + "qoder-auth-wasm.wasm 路径(必填)") + asJSON := flag.Bool("json", false, "以 JSON 输出结果") + repeat := flag.Int("repeat", 1, "重复调用 prepareInferRequest 的次数(测实例复用)") + strict := flag.Bool("strict", false, + "任何未实现的 import 被调用即失败(默认只告警)") + flag.Parse() + if *wasmPath == "" { + fmt.Fprintln(os.Stderr, "-wasm is required") + os.Exit(2) + } + + p := &probe{ctx: context.Background(), hits: map[string]int{}, strict: *strict, repeat: *repeat} + res := p.run(*wasmPath) + + if *asJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.Encode(res) + } else { + p.report(res) + } + if !res.OK() { + os.Exit(1) + } +} + +type result struct { + WasmPath string `json:"wasm_path"` + WasmSize int `json:"wasm_size"` + Imports int `json:"imports"` + Exports int `json:"exports"` + Instantiated bool `json:"instantiated"` + CallsAtInstall int `json:"calls_at_instantiate"` + AuthFields map[string]any `json:"generate_runtime_auth_fields"` + AuthFieldLens map[string]int `json:"auth_field_lengths"` + AuthErr string `json:"auth_error,omitempty"` + InferURL string `json:"infer_url,omitempty"` + InferBodyLen int `json:"infer_body_len"` + InferHeaders map[string]string `json:"infer_headers,omitempty"` + InferErr string `json:"infer_error,omitempty"` + ImportHits map[string]int `json:"import_hits"` + NeverCalled []string `json:"imports_never_called"` + UnimplementedHit []string `json:"unimplemented_imports_hit,omitempty"` + Warnings []string `json:"warnings,omitempty"` + // MemPages 是跑完三级调用后 wasm 线性内存的页数(1 页 = 64 KiB)。 + MemPages uint32 `json:"mem_pages"` + Repeat int `json:"repeat"` + ElapsedMS float64 `json:"elapsed_ms"` +} + +func (r result) OK() bool { + return r.Instantiated && r.AuthErr == "" && r.InferErr == "" +} + +func (p *probe) heap() []byte { b, _ := p.mod.Memory().Read(0, p.mod.Memory().Size()); return b } + +func (p *probe) readStr(ptr, ln uint32) string { + if b, ok := p.mod.Memory().Read(ptr, ln); ok { + return string(b) + } + return "" +} + +func (p *probe) warn(format string, a ...any) { + msg := fmt.Sprintf(format, a...) + p.warns = append(p.warns, msg) + fmt.Fprintln(os.Stderr, "warn: "+msg) +} + +func (p *probe) run(path string) result { + res := result{WasmPath: path, ImportHits: map[string]int{}} + t0 := time.Now() + + b, err := os.ReadFile(path) + if err != nil { + res.InferErr = err.Error() + return res + } + res.WasmSize = len(b) + + p.rt = wazero.NewRuntime(p.ctx) + defer p.rt.Close(p.ctx) + + cm, err := p.rt.CompileModule(p.ctx, b) + if err != nil { + res.InferErr = "compile: " + err.Error() + return res + } + imports := cm.ImportedFunctions() + res.Imports = len(imports) + res.Exports = len(cm.ExportedFunctions()) + + // ---- 宿主模块:31 个 import 的纯 Go 实现 ---- + host := p.rt.NewHostModuleBuilder(importModule) + for _, f := range imports { + _, name, _ := f.Import() + params, results := f.ParamTypes(), f.ResultTypes() + host.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(p.impl(name)), params, results). + Export(name) + } + if _, err := host.Instantiate(p.ctx); err != nil { + res.InferErr = "host module: " + err.Error() + return res + } + + p.mod, err = p.rt.InstantiateModule(p.ctx, cm, wazero.NewModuleConfig().WithName("qoder")) + if err != nil { + res.InferErr = "instantiate: " + err.Error() + return res + } + res.Instantiated = true + res.CallsAtInstall = len(p.order) // 实例化阶段真实调用次数 + + p.level2(&res) + if res.AuthErr == "" { + for i := 0; i < p.repeat; i++ { + p.level3(&res) + if res.InferErr != "" { + res.InferErr = fmt.Sprintf("iteration %d/%d: %s", i+1, p.repeat, res.InferErr) + break + } + } + } + + res.ElapsedMS = float64(time.Since(t0).Microseconds()) / 1000 + if mem := p.mod.Memory(); mem != nil { + res.MemPages = mem.Size() / 65536 + } + res.ImportHits = p.hits + res.NeverCalled = p.neverCalled(imports) + res.Warnings = p.warns + return res +} + +// ---- 三级证据 ------------------------------------------------------------ + +// level2: generate_runtime_auth_fields —— 第一次真实调用。 +func (p *probe) level2(res *result) { + payload := `{"uid":"probe-uid","security_oauth_token":"probe-token",` + + `"organization_id":"","organization_tags":[],"data_policy_agreed":false}` + ptr, ln := p.writeString(payload) + raw, err := p.callString("generate_runtime_auth_fields", uint64(ptr), uint64(ln)) + if err != nil { + res.AuthErr = err.Error() + return + } + var fields map[string]any + if err := json.Unmarshal([]byte(raw), &fields); err != nil { + res.AuthErr = "non-JSON output: " + err.Error() + return + } + res.AuthFields = map[string]any{} + res.AuthFieldLens = map[string]int{} + for k, v := range fields { + // 只暴露类型与长度:字段本身是敏感材料,不该进日志。 + switch s := v.(type) { + case string: + res.AuthFields[k] = fmt.Sprintf("", len(s)) + res.AuthFieldLens[k] = len(s) + default: + res.AuthFields[k] = fmt.Sprintf("<%T>", v) + res.AuthFieldLens[k] = 0 + } + } +} + +// level3: qodercontext_new + prepareInferRequest —— 产出真实加密请求。 +func (p *probe) level3(res *result) { + userInfo := `{"uid":"probe-uid","encrypt_user_info":"probe","key":"probe",` + + `"organization_id":"","organization_tags":[],"data_policy_agreed":false}` + meta := `{"client_type":"10","business_product":"qoder",` + + `"business_type":"default","scene":"chat"}` + + mp, ml := p.writeString("probe-machine-uuid") + vp, vl := p.writeString("1.1.49") + up, ul := p.writeString(userInfo) + tp, tl := p.writeString(meta) + + sp := p.stackPtr() + _, err := p.export("qodercontext_new").Call(p.ctx, uint64(sp), + uint64(mp), uint64(ml), uint64(vp), uint64(vl), + uint64(up), uint64(ul), uint64(tp), uint64(tl)) + v := p.dv() + ctxPtr := int32(v.u32(sp)) + isErr := int32(v.u32(sp + 8)) + errIdx := int32(v.u32(sp + 4)) + p.stackFree() + if err != nil || isErr != 0 { + res.InferErr = fmt.Sprintf("qodercontext_new err=%v isErr=%d obj=%v", + err, isErr, p.getObj(uint32(errIdx))) + return + } + + hp, hl := p.writeString("api2.qoder.sh") + bp, bl := p.writeString(`{"messages":[{"role":"user","content":"hi"}]}`) + kp, kl := p.writeString("qfmodel") + skp, skl := p.writeString("system") + + sp2 := p.stackPtr() + _, err = p.export("qodercontext_prepareInferRequest").Call(p.ctx, uint64(sp2), + uint64(ctxPtr), uint64(hp), uint64(hl), uint64(bp), uint64(bl), + uint64(kp), uint64(kl), uint64(skp), uint64(skl)) + v2 := p.dv() + resPtr := int32(v2.u32(sp2)) + rErr := int32(v2.u32(sp2 + 8)) + rErrIdx := int32(v2.u32(sp2 + 4)) + p.stackFree() + if err != nil || rErr != 0 { + res.InferErr = fmt.Sprintf("prepareInferRequest err=%v isErr=%d obj=%v", + err, rErr, p.getObj(uint32(rErrIdx))) + return + } + + hdrIdx, _ := p.export("requestresult_headers").Call(p.ctx, uint64(uint32(resPtr))) + switch hm := p.getObj(uint32(hdrIdx[0])).(type) { + case jsMap: + res.InferHeaders = map[string]string{} + for i := range hm.keys { + k, val := hm.keys[i], hm.vals[i] + // Authorization 是 WASM 产出的签名头:只报形状,不回显载荷。 + if len(val) > 24 { + res.InferHeaders[k] = val[:24] + fmt.Sprintf("…", len(val)) + } else { + res.InferHeaders[k] = val + } + } + default: + res.InferErr = fmt.Sprintf("requestresult_headers -> %T (expected jsMap)", hm) + } + + res.InferURL = p.resultString(resPtr, "requestresult_url") + body := p.resultString(resPtr, "requestresult_body") + res.InferBodyLen = len(body) + res.Repeat = p.repeat +} + +func (p *probe) report(r result) { + fmt.Printf("wasm : %s\n", r.WasmPath) + fmt.Printf("size : %d bytes\n", r.WasmSize) + fmt.Printf("imports : %d (all from %q)\n", r.Imports, importModule) + fmt.Printf("exports : %d\n", r.Exports) + fmt.Printf("instantiate: %v calls-at-instantiate=%d\n", r.Instantiated, r.CallsAtInstall) + + fmt.Println("\n-- LEVEL 2: generate_runtime_auth_fields --") + if r.AuthErr != "" { + fmt.Println(" FAILED:", r.AuthErr) + } else { + ks := make([]string, 0, len(r.AuthFields)) + for k := range r.AuthFields { + ks = append(ks, k) + } + sort.Strings(ks) + for _, k := range ks { + fmt.Printf(" %-20s %s\n", k, r.AuthFields[k]) + } + } + + fmt.Println("\n-- LEVEL 3: qodercontext_new + prepareInferRequest --") + if r.InferErr != "" { + fmt.Println(" FAILED:", r.InferErr) + } else { + fmt.Printf(" url = %s\n", r.InferURL) + fmt.Printf(" body = %d bytes (encrypted, not logged)\n", r.InferBodyLen) + for _, k := range sortedKeys(r.InferHeaders) { + fmt.Printf(" hdr %-20s %s\n", k, r.InferHeaders[k]) + } + } + + fmt.Printf("\n-- IMPORT USAGE (%d of %d touched) --\n", len(r.ImportHits), r.Imports) + for _, k := range sortedKeys(r.ImportHits) { + fmt.Printf(" %-50s %d\n", k, r.ImportHits[k]) + } + fmt.Printf(" never called: %d\n", len(r.NeverCalled)) + for _, n := range r.NeverCalled { + fmt.Printf(" %s\n", n) + } + if len(r.Warnings) > 0 { + fmt.Printf("\n-- WARNINGS (%d) --\n", len(r.Warnings)) + for _, w := range r.Warnings { + fmt.Printf(" %s\n", w) + } + } + fmt.Printf("\nRESULT: %s\n", map[bool]string{true: "PASS", false: "FAIL"}[r.OK()]) +} + +// ---- wasm-bindgen ABI 助手(照 upstream src/qoder-wasm.ts 的语义)--------- + +func (p *probe) export(n string) api.Function { + f := p.mod.ExportedFunction(n) + if f == nil { + panic("missing export " + n) + } + return f +} + +func (p *probe) dv() dataView { + b, _ := p.mod.Memory().Read(0, p.mod.Memory().Size()) + return dataView(b) +} + +type dataView []byte + +func (d dataView) u32(off uint32) uint32 { return binary.LittleEndian.Uint32(d[off:]) } + +func (p *probe) stackPtr() uint32 { + out, _ := p.export("__wbindgen_add_to_stack_pointer").Call(p.ctx, api.EncodeI32(-16)) + return uint32(api.DecodeI32(out[0])) +} + +func (p *probe) stackFree() { + p.export("__wbindgen_add_to_stack_pointer").Call(p.ctx, api.EncodeI32(16)) +} + +func (p *probe) writeString(s string) (uint32, uint32) { + b := []byte(s) + out, err := p.export("__wbindgen_export2").Call(p.ctx, uint64(len(b)), 1) + if err != nil { + panic(err) + } + ptr := uint32(out[0]) + p.mod.Memory().Write(ptr, b) + return ptr, uint32(len(b)) +} + +// callString 对应「返回值布局 = ptr/len/valIdx/isErr」那一类函数。 +func (p *probe) callString(name string, args ...uint64) (string, error) { + sp := p.stackPtr() + defer p.stackFree() + if _, err := p.export(name).Call(p.ctx, append([]uint64{uint64(sp)}, args...)...); err != nil { + return "", fmt.Errorf("%s: %w", name, err) + } + v := p.dv() + ptr, ln := int32(v.u32(sp)), int32(v.u32(sp+4)) + if isErr := int32(v.u32(sp + 12)); isErr != 0 { + return "", fmt.Errorf("%s: error idx=%d (%v)", name, int32(v.u32(sp+8)), p.getObj(v.u32(sp+8))) + } + return p.readStr(uint32(ptr), uint32(ln)), nil +} + +// resultString 对应 `requestresult_url|body`:参数顺序是 (栈指针, ptr)。 +func (p *probe) resultString(result int32, name string) string { + sp := p.stackPtr() + defer p.stackFree() + if _, err := p.export(name).Call(p.ctx, uint64(sp), uint64(uint32(result))); err != nil { + p.warn("%s: %v", name, err) + return "" + } + v := p.dv() + ptr, ln := int32(v.u32(sp)), int32(v.u32(sp+4)) + if ptr == 0 { + return "" + } + return p.readStr(uint32(ptr), uint32(ln)) +} + +// ---- 对象堆 -------------------------------------------------------------- + +var objects = newHeap() + +func pushObj(v any) uint32 { + objects = append(objects, v) + return uint32(len(objects) - 1) +} + +func (p *probe) getObj(i uint32) any { + if int(i) < len(objects) { + return objects[i] + } + return undefined{} +} + +var dbg = os.Getenv("PROBE_DBG") != "" + +func (p *probe) takeObj(i uint32) { + if dbg && i >= sentinelN { + fmt.Printf("[dbg] drop %d (was %T)\n", i, p.getObj(i)) + } + if i >= sentinelN && int(i) < len(objects) { + objects[i] = undefined{} + } +} + +func (p *probe) asBytes(i uint32) u8arr { + switch v := p.getObj(i).(type) { + case u8arr: + return v + case []byte: + return u8arr(v) + } + p.warn("heap[%d] is %T, expected Uint8Array", i, p.getObj(i)) + return nil +} + +// ---- 31 个 import 的纯 Go 实现 ------------------------------------------- +// +// 分四类(与 docs/qoder-wasm-integration.md 的接口映射表对应): +// A bindgen 对象表 · B 字节缓冲 · C 随机/时间 · D JS 环境探针 + +func (p *probe) impl(name string) func(context.Context, api.Module, []uint64) { + mark := func(n string) { + p.hits[n]++ + if len(p.order) < 4096 { + p.order = append(p.order, n) + } + } + wrap := func(f func(s []uint64)) func(context.Context, api.Module, []uint64) { + return func(_ context.Context, _ api.Module, s []uint64) { + mark(name) + defer func() { + if r := recover(); r != nil { + p.warn("host func %s panicked: %v", name, r) + } + }() + f(s) + } + } + // 未在 auth 路径上验证过的 import:被调用就是新信息,必须记下来。 + unimpl := func(what string) func(context.Context, api.Module, []uint64) { + return wrap(func(s []uint64) { + p.warn("import %s reached (%s) -- not exercised by the auth path", name, what) + if p.strict { + panic("unimplemented import reached: " + name) + } + }) + } + + switch name { + // -- A: bindgen 对象表 ------------------------------------------------ + case "__wbindgen_object_drop_ref": + // ⚠️ 必须**不释放**槽位:实测该模块会 drop 一个 Map 之后继续用同一索引 + // 调 set(日志见 docs)。wasm-bindgen 新版把 drop 退化为空操作 + // (externref 表交给 JS GC),因此这里保持 no-op —— 代价是索引只增不回收。 + return wrap(func(s []uint64) {}) + case "__wbindgen_object_clone_ref": + return wrap(func(s []uint64) { s[0] = uint64(pushObj(p.getObj(uint32(s[0])))) }) + case "__wbindgen_cast_0000000000000001": // wasm slice -> Uint8Array + return wrap(func(s []uint64) { + a, e := uint32(s[0]), uint32(s[1]) + dst := make(u8arr, e) + copy(dst, p.heap()[a:a+e]) + s[0] = uint64(pushObj(dst)) + }) + case "__wbindgen_cast_0000000000000002": // wasm str -> JS string + return wrap(func(s []uint64) { + s[0] = uint64(pushObj(p.readStr(uint32(s[0]), uint32(s[1])))) + }) + + // -- B: 字节缓冲 ------------------------------------------------------ + case "__wbg_new_with_length_9cedd08484b73942": + return wrap(func(s []uint64) { s[0] = uint64(pushObj(make(u8arr, uint32(s[0])))) }) + case "__wbg_length_0c32cb8543c8e4c8": + return wrap(func(s []uint64) { s[0] = uint64(len(p.asBytes(uint32(s[0])))) }) + case "__wbg_subarray_0f98d3fb634508ad": + return wrap(func(s []uint64) { + b := p.asBytes(uint32(s[0])) + lo, hi := uint32(s[1]), uint32(s[2]) + if hi > uint32(len(b)) { + hi = uint32(len(b)) + } + if lo > hi { + lo = hi + } + s[0] = uint64(pushObj(b[lo:hi])) + }) + case "__wbg_set_08463b1df38a7e29": + // 两种形态:Uint8Array.set(src, off) 与 Map.set()(headers 集合)。 + return wrap(func(s []uint64) { + switch dst := p.getObj(uint32(s[0])).(type) { + case jsMap: + k, ok := p.getObj(uint32(s[1])).(string) + if !ok { + k = fmt.Sprint(p.getObj(uint32(s[1]))) + } + dst.keys = append(dst.keys, k) + dst.vals = append(dst.vals, fmt.Sprint(p.getObj(uint32(s[2])))) + objects[s[0]] = dst + case u8arr: + src := p.asBytes(uint32(s[1])) + off := int(uint32(s[2])) + if off > len(dst) { + return + } + copy(dst[off:], src) + default: + if dbg { + fmt.Printf("[dbg] set arg0=%d type=%T arg2=%d\n", s[0], p.getObj(uint32(s[0])), s[2]) + } + p.warn("set on unsupported %T", p.getObj(uint32(s[0]))) + } + }) + case "__wbg_prototypesetcall_3e05eb9545565046": + return wrap(func(s []uint64) { + a, e := uint32(s[0]), uint32(s[1]) + copy(p.heap()[a:a+e], p.asBytes(uint32(s[2]))) + }) + case "__wbg_new_99cabae501c0a8a0": // new Map() + return wrap(func(s []uint64) { + idx := pushObj(jsMap{}) + if dbg { + fmt.Printf("[dbg] newMap -> %d\n", idx) + } + s[0] = uint64(idx) + }) + + // -- C: 随机源与时间 -------------------------------------------------- + // + // ⚠️ 两个 getRandomValues 的签名**方向相反**(upstream 注释里踩过的坑): + // _d49329ff89a07af1 = (wasmPtr, len) -> 直接写 wasm 内存 + // _c44a50d8cfdaebeb = (cryptoObjIdx, u8arrIdx) -> 调 JS 对象的方法 + // 写反会得到 Rust panic `unreachable`。 + case "__wbg_getRandomValues_d49329ff89a07af1": + return wrap(func(s []uint64) { + b := make([]byte, uint32(s[1])) + rand.Read(b) + p.mod.Memory().Write(uint32(s[0]), b) + }) + case "__wbg_getRandomValues_c44a50d8cfdaebeb": + return wrap(func(s []uint64) { + if _, ok := p.getObj(uint32(s[0])).(cryptoObj); !ok { + p.warn("getRandomValues(B): arg0 is %T, want cryptoObj", p.getObj(uint32(s[0]))) + } + if b := p.asBytes(uint32(s[1])); b != nil { + rand.Read(b) + } + }) + case "__wbg_randomFillSync_6c25eac9869eb53c": + return wrap(func(s []uint64) { + if b := p.asBytes(uint32(s[1])); b != nil { + rand.Read(b) + } + }) + case "__wbg_now_88621c9c9a4f3ffc": + return wrap(func(s []uint64) { + s[0] = math.Float64bits(float64(time.Now().UnixMilli())) + }) + + // -- D: JS / Node 环境探针 -------------------------------------------- + case "__wbg_crypto_38df2bab126b63dc", "__wbg_msCrypto_bd5a034af96bcba6": + return wrap(func(s []uint64) { s[0] = uint64(pushObj(cryptoObj{})) }) + case "__wbg_static_accessor_GLOBAL_THIS_a1248013d790bf5f", + "__wbg_static_accessor_GLOBAL_f2e0f995a21329ff": + return wrap(func(s []uint64) { s[0] = uint64(pushObj(undefined{})) }) + case "__wbg_static_accessor_SELF_24f78b6d23f286ea", + "__wbg_static_accessor_WINDOW_59fd959c540fe405": + // 服务端没有 window/self:wasm-bindgen 约定用 0 表示缺席。 + return wrap(func(s []uint64) { s[0] = 0 }) + case "__wbg___wbindgen_is_object_40c5a80572e8f9d3": + return wrap(func(s []uint64) { + switch p.getObj(uint32(s[0])).(type) { + case u8arr, jsMap, cryptoObj: + s[0] = 1 + } + }) + case "__wbg___wbindgen_is_string_b29b5c5a8065ba1a": + return wrap(func(s []uint64) { + if _, ok := p.getObj(uint32(s[0])).(string); ok { + s[0] = 1 + } + }) + case "__wbg___wbindgen_is_undefined_c0cca72b82b86f4d": + return wrap(func(s []uint64) { + if _, ok := p.getObj(uint32(s[0])).(undefined); ok { + s[0] = 1 + } + }) + case "__wbg___wbindgen_throw_81fc77679af83bc6": + return wrap(func(s []uint64) { + p.warn("wasm throw: %s", p.readStr(uint32(s[0]), uint32(s[1]))) + }) + case "__wbg_Error_2e59b1b37a9a34c3": + return wrap(func(s []uint64) { + s[0] = uint64(pushObj(fmt.Errorf("%s", p.readStr(uint32(s[0]), uint32(s[1]))))) + }) + case "__wbg_process_44c7a14e11e9f69e", "__wbg_versions_276b2795b1c6a219", + "__wbg_node_84ea875411254db1", "__wbg_require_b4edbdcf3e2a1ef0", + "__wbg_call_d578befcc3145dee", "__wbg___wbindgen_is_function_49868bde5eb1e745": + return unimpl("node/JS 环境探针") + } + return unimpl("未知") +} + +func (p *probe) neverCalled(imports []api.FunctionDefinition) []string { + var out []string + for _, f := range imports { + _, name, _ := f.Import() + if p.hits[name] == 0 { + out = append(out, name) + } + } + sort.Strings(out) + return out +} + +func sortedKeys[V any](m map[string]V) []string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + sort.Strings(ks) + return ks +}