Files
HomeAgent/internal/lua/auto_update_test.go
JianFeeeee d3eaff46f1 fix(lua): 内置适配器按内容自动更新,替代「文件已存在就跳过」
## 原机制是这次全部误判的根源

    if _, err := os.Stat(dstPath); err == nil { continue }

**升级二进制永远不更新已部署的适配器文件。** 于是"改了仓库 ≠ 生产生效",
而这个机制让同类问题可以长期潜伏:

    2026-08-26 15:46  生产 openai.lua 手工补上 stream_index 透传
    2026-08-26 16:10  ddef195 提交,说明里写了但代码没改这个文件

修复当天先在生产落地、32 分钟后才提交入库(漏了这个文件),此后一个月里
两端都没人发现 —— 生产不报问题(它有),仓库的判据也测不到(直接构造 Go
结构体,绕过适配器)。而"升级不覆盖"意味着即使仓库补上修复,已部署的
老实例也不会拿到。

## 新判据(按内容,不按存在)

    文件不存在                     ⇒ 写
    有历史清单且盘上 == 上次内嵌   ⇒ 覆盖(只是没跟上新版本)
    有历史清单但盘上 != 上次内嵌   ⇒ 不动 + 日志(用户改过)
    无历史清单(首跑/从旧版本升级) ⇒ 不动,只补缺失文件(与旧行为一致)

"上次内嵌的版本"记在 `DataDir/adapters/.bundled`(`<name>\t<sha256>`)。

⚠️ 为什么不能无条件覆盖:adapter_path 是可配置项,用户可以把 adapter_path
指向自己维护的适配器。静默覆盖等于丢弃他们的修改,而且**没有报错**。

## 判据(两个方向都要测)

- TestWriteBundledAdaptersSkipsUserModified  用户改过的**必须保留**,
  且改完仍能正常加载(fixture 必须功能完整,否则会因为缺钩子函数而失败 ——
  那是 fixture 问题,不是保护逻辑问题)
- TestWriteBundledAdaptersUpdatesStale       落后于新内嵌的**必须被更新**,
  且**幂等**(三跑不再改写任何文件)

★ 第二条是必要的:只测保护的话,**一个"永远不覆盖任何文件"的实现也能全绿**
  —— 而那正是要修的病。

## 代价(必须知道)

**升级到本版本的这一次,已部署实例的适配器不会更新**(没有历史清单可比)。
从第二次升级起自动生效。要立刻生效就删掉 DataDir/adapters 让内核重新解包。

对本次修的 8 个适配器而言:生产此刻用不到(三个源 llmsproxy/visionllm/
justworker 全是 openai.lua),所以不影响运行;将来启用 deepseek 等源时,
自然就是修复版。

## 顺带

`bundledAdapterNames` 从 writeBundledAdapters 里提出来成包级变量 ——
writeBundledAdapters 与体检判据共用,避免两处各写一份而漏掉某个
(漏掉的后果是该适配器永远不会被更新)。
2026-09-27 18:46:51 +08:00

168 lines
5.7 KiB
Go
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.

package lua
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestWriteBundledAdaptersSkipsUserModified 钉住自动更新的**安全边界**。
//
// 背景:writeBundledAdapters 原本是 `if 文件已存在 { continue }`,
// 于是「修好适配器 → 升级二进制 → 已部署实例上的文件不更新」。
// 这就是仓库 openai.lua 缺 stream_index 透传、而生产早有(2026-08-26 15:46
// 手工补上,比入库早 32 分钟)却长期没人发现的机制性原因。
//
// 改成按内容判断后,**必须**守住两条边界,否则会吞掉用户的改动:
//
// ① 用户**改过**的文件(内容与上次内嵌的 embed 不同)⇒ 不动它
// ② 与上次内嵌**一致**的文件(只是没跟上新版本)⇒ 用新的覆盖
//
// 为什么不能无条件覆盖:adapter_path 是可配置项,用户可以把 adapter_path
// 指向自己维护的适配器。内核内置那 10 个文件虽在 DataDir 下,但"用户改了
// 内置适配器"是现实存在的用法 —— 无条件覆盖等于静默丢弃他们的修改。
func TestWriteBundledAdaptersSkipsUserModified(t *testing.T) {
dir := t.TempDir()
vm := NewVM(dir)
// 场景 A:用户把 openai.lua 改成了自己的版本
// 假适配器必须**功能完整**(含 transform_response 等钩子),
// 否则后面"用户改过的仍能加载"那条断言会因为缺函数而失败 ——
// 那是 fixture 的问题,不是保护逻辑的问题。
custom := `local adapter = {}
adapter.name = "openai"
function adapter.transform_request(r) return r end
function adapter.transform_response(r) return r end
function adapter.transform_stream_chunk(r) return r end
return adapter
`
if err := os.WriteFile(filepath.Join(dir, "openai.lua"), []byte(custom), 0644); err != nil {
t.Fatal(err)
}
// 场景 B:anthropic.lua 内容是"上次内嵌的版本"(没跟上新版)
prev, err := bundledAdapters.ReadFile("adapters/anthropic.lua")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "anthropic.lua"), prev, 0644); err != nil {
t.Fatal(err)
}
// Start 会 mkdir + writeBundledAdapters + 逐个 LoadAdapter(vm.go:173)
if err := vm.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
// ① 用户改过的 openai.lua 必须原样保留
got, err := os.ReadFile(filepath.Join(dir, "openai.lua"))
if err != nil {
t.Fatal(err)
}
if string(got) != custom {
t.Errorf("用户改过的 openai.lua 被覆盖了\n 期望:%q\n 实际:%q",
custom, string(got))
}
// 而且它应该仍被加载成功(用户改的能用)
if _, err := vm.CallTransformResponse("openai", "{}"); err != nil {
t.Errorf("用户改过的 openai.lua 加载失败:%v", err)
}
// ② 与上次内嵌一致的 anthropic.lua 应保持一致(幂等,不反复改写)
gotA, err := os.ReadFile(filepath.Join(dir, "anthropic.lua"))
if err != nil {
t.Fatal(err)
}
if string(gotA) != string(prev) {
t.Error("anthropic.lua 内容被改动了 —— 与上次内嵌一致的文件应保持不变(幂等)")
}
}
// TestBundledAdapterMatchesEmbedded 确认内嵌内容与源文件一致。
//
// 这是"用户改过"的判据基准:VM 必须拿**当前内嵌**的版本做比对。
func TestBundledAdapterMatchesEmbedded(t *testing.T) {
for _, name := range bundledAdapterNames {
b, err := bundledAdapters.ReadFile("adapters/" + name + ".lua")
if err != nil {
t.Errorf("%s: 内嵌读取失败 %v", name, err)
continue
}
if len(b) == 0 {
t.Errorf("%s: 内嵌内容为空", name)
}
}
}
// TestWriteBundledAdaptersUpdatesStale 验证**正向**路径:没跟上新版本的文件
// 确实被覆盖。
//
// 只测"用户改过的不被覆盖"是不够的 —— 那样一个"永远不覆盖任何文件"的
// 实现也能全绿,而那正是我们要修的病。
func TestWriteBundledAdaptersUpdatesStale(t *testing.T) {
dir := t.TempDir()
// 首跑:解包 + 写清单
vm1 := NewVM(dir)
if err := vm1.Start(); err != nil {
t.Fatalf("首跑 Start: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, ".bundled")); err != nil {
t.Fatalf("首跑应写出 .bundled 清单:%v", err)
}
// 场景:把 groq.lua 换成"用户版本"(与首跑内嵌不同)
groqPath := filepath.Join(dir, "groq.lua")
cur, err := bundledAdapters.ReadFile("adapters/groq.lua")
if err != nil {
t.Fatal(err)
}
if string(cur) == "" {
t.Fatal("groq.lua 内嵌为空")
}
// 模拟"内核版本变了":改清单里的哈希,让它认为 groq 落后于新内嵌
man, err := os.ReadFile(filepath.Join(dir, ".bundled"))
if err != nil {
t.Fatal(err)
}
stale := strings.ReplaceAll(string(man), groqLineHash(man), "deadbeef")
if err := os.WriteFile(filepath.Join(dir, ".bundled"), []byte(stale), 0644); err != nil {
t.Fatal(err)
}
// 二跑:应把 groq.lua 更新回内嵌版本
vm2 := NewVM(dir)
if err := vm2.Start(); err != nil {
t.Fatalf("二跑 Start: %v", err)
}
got, err := os.ReadFile(groqPath)
if err != nil {
t.Fatal(err)
}
if sha256Hex(got) != sha256Hex(cur) {
t.Errorf("落后的 groq.lua 未被更新回内嵌版本(这是本次要修的病)\n"+
" 盘上 %d 字节 / 内嵌 %d 字节", len(got), len(cur))
}
// 幂等:三跑不应再改任何文件
before, _ := os.ReadFile(groqPath)
vm3 := NewVM(dir)
if err := vm3.Start(); err != nil {
t.Fatal(err)
}
after, _ := os.ReadFile(groqPath)
if string(before) != string(after) {
t.Error("已是最新版本却被再次改写 —— 更新不幂等")
}
}
// groqLineHash 从清单里取出 groq 那行的哈希。
func groqLineHash(manifest []byte) string {
for _, line := range strings.Split(string(manifest), "\n") {
if strings.HasPrefix(line, "groq\t") {
return strings.TrimPrefix(line, "groq\t")
}
}
return ""
}