mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-30 06:22:53 +00:00
第一次 CI 跑出 2 类失败,都是**本地以 root/amd64 跑永远看不见**的问题。
这正是建 CI 的价值:换一个环境就暴露了。
## 一、.syso 无条件被链进所有平台 → arm64 交叉编译必炸
CI 报:
$WORK/b001/_pkg_.a(waiter.syso): 310766: unknown ARM64 relocation type 3
(linux/arm64 与 darwin/arm64 两个 job 都红;amd64 两个都绿)
根因(已在本地用 Go 1.25.9 + arm64 精确复现):Go 会把**同目录的 *.syso
无条件链进任何 GOOS/GOARCH**,而这两个 .syso 是 Windows 资源对象
(x86-64 COFF,只含 .rsrc 图标段)。链进 arm64 目标即报「未知 ARM64 重定位」。
仓库其实**早就知道**这件事 —— deploy/packaging/build.sh:86-90 写着
「Go 会把同目录的 .syso 无条件链进任何目标」,并留了 hide_syso_for_target()
绕过,注释还点名「这正是 arm64 产物长期缺失的原因(曾被误判为缺 g++
交叉编译器)」。但那是打包脚本里的私有绕道:任何**直接 go build** 的路径
(包括 CI、包括本机原生 arm64 构建)都仍会撞上。修在源头而不是再加一层绕道。
修法分两种,因为两个文件的处境**完全不同**:
1. cmd/waiter/waiter_windows_amd64.syso(原 waiter.syso,git mv)
waiter **仍支持 Windows**(package-windows.sh:68 明确构建 waiter.exe),
所以不能删。按 Go 的文件名约定加 _windows_amd64 后缀 ⇒ 只在
windows/amd64 被链入。实测:linux/amd64、linux/arm64、darwin/arm64、
windows/amd64 四平台全部通过,且 Windows 产物的 .rsrc 段大小
(00049eb8 字节)与改动前**逐字节一致** —— 图标没丢。
2. cmd/homed/{homed.syso,homed.rc} 删除
homed 的 Windows 原生支持**已放弃**,五处独立来源一致:
- README.md:251「homed 放弃 Windows 原生支持改走 WSL2」
- cmd/homed/platform_windows.go 的 requireSupportedPlatform 直接拒绝启动
(理由是设计性的:fd 继承 + 同段内偏移解引用,Windows 句柄模型无法表达)
- package-windows.sh:4「❗安装器不往 Windows 装 homed」
- build.sh:35「Windows 不再安装 homed.exe」
- installer.nsi:230「homed 不再装到 Windows」
即 homed.exe 即便构建出来也拒绝运行 ⇒ 图标资源毫无意义,却是 arm64
构建失败的来源之一。顺带查明:homed.syso 与 waiter.syso 本是**同一个
blob**(两个 .rc 指向同一 icon),属纯重复。
★ 由此留下一处**未修的残留**(已确认,不在本次范围):installer.nsi:297,313
仍在创建指向 homed.exe 的快捷方式与 Run 注册项,而同文件 230 行已声明
homed 不装 Windows。那是 Windows 安装器的独立缺陷,需单独处理。
## 二、internal/system 测试硬编码 /etc → 非 root 必失败
CI 报:
system_test.go:51: expected archive to happen
system_test.go:97: expected restore to happen
测试写死 target := "/etc/xxx.test.tmp" 并**忽略了 os.WriteFile 的错误**。
GitHub Actions runner 以非 root 运行 ⇒ 写 /etc permission denied ⇒ 文件
不存在 ⇒ ArchiveBeforeWrite 按「新建文件无需留档」返回 false ⇒ 断言失败。
本地以 root 跑则一路通过 —— 缺陷因此长期不可见。
修法:用仓库**已有**的 SetProtectedPaths([]string{临时目录}) 显式声明受保护
前缀(不再碰真实 /etc),defer SetProtectedPaths(nil) 复原默认。既去掉了对
root 的隐式依赖,也没有削弱被测语义(保护的仍是「受保护前缀下的文件」)。
## 验证
- go test ./... -count=1 全绿
- go build ./... 通过
- waiter 四平台交叉编译 全通过(含此前必红的 arm64)
- homed linux/amd64 原生构建 通过(确认删除 .syso 无害)
- Windows 产物 .rsrc 段 改动前后一致(00049eb8 字节)
183 lines
5.6 KiB
Go
183 lines
5.6 KiB
Go
package system
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func TestIsProtectedPath(t *testing.T) {
|
||
cases := map[string]bool{
|
||
"/etc/resolv.conf": true,
|
||
"/etc/hosts": true,
|
||
"/etc/apt/sources.list": true,
|
||
"/etc/environment": true,
|
||
"/tmp/foo.txt": false,
|
||
"/home/user/x": false,
|
||
"/usr/local/bin/homed": false,
|
||
}
|
||
for p, want := range cases {
|
||
if got := IsProtectedPath(p); got != want {
|
||
t.Errorf("IsProtectedPath(%q) = %v, want %v", p, got, want)
|
||
}
|
||
}
|
||
// 发行版/部署路径注入:显式前缀集可扩展受保护范围
|
||
SetProtectedPaths([]string{"/opt/llm-mock", "/home/newqqagent"})
|
||
if !IsProtectedPath("/opt/llm-mock/mock_server.py") {
|
||
t.Error("explicit prefix /opt/llm-mock should be protected")
|
||
}
|
||
if !IsProtectedPath("/home/newqqagent/config.yaml") {
|
||
t.Error("explicit prefix /home/newqqagent should be protected")
|
||
}
|
||
SetProtectedPaths(nil) // 恢复默认
|
||
if IsProtectedPath("/opt/llm-mock/mock_server.py") {
|
||
t.Error("default should not protect /opt/llm-mock")
|
||
}
|
||
}
|
||
|
||
func TestArchiveBeforeWrite(t *testing.T) {
|
||
dir := t.TempDir()
|
||
|
||
// 受保护路径用**临时目录 + SetProtectedPaths 显式声明**,不写真实的 /etc。
|
||
//
|
||
// 为什么不能硬编码 /etc:CI(GitHub Actions runner)以非 root 运行,
|
||
// os.WriteFile("/etc/...") 会 permission denied,而此处原先忽略了该错误
|
||
// ⇒ 文件根本不存在 ⇒ ArchiveBeforeWrite 按「新建文件无需留档」返回 false
|
||
// ⇒ 断言 "expected archive to happen" 失败。本地以 root 跑则通过,
|
||
// 缺陷因此长期不可见(只有换到非 root 环境才暴露)。
|
||
protected := t.TempDir()
|
||
SetProtectedPaths([]string{protected})
|
||
defer SetProtectedPaths(nil) // 恢复默认(/etc/),避免影响同包其它测试
|
||
|
||
target := filepath.Join(protected, "archive_before_write.tmp")
|
||
if err := os.WriteFile(target, []byte("original"), 0644); err != nil {
|
||
t.Fatalf("write target: %v", err)
|
||
}
|
||
|
||
archived, err := ArchiveBeforeWrite(dir, target)
|
||
if err != nil {
|
||
t.Fatalf("ArchiveBeforeWrite: %v", err)
|
||
}
|
||
if !archived {
|
||
t.Fatal("expected archive to happen")
|
||
}
|
||
dst := filepath.Join(dir, "file_baseline", strings.TrimPrefix(target, "/"))
|
||
data, err := os.ReadFile(dst)
|
||
if err != nil {
|
||
t.Fatalf("read archived: %v", err)
|
||
}
|
||
if string(data) != "original" {
|
||
t.Fatalf("archived content = %q, want original", data)
|
||
}
|
||
|
||
// 幂等:同内容不重复
|
||
archived2, err := ArchiveBeforeWrite(dir, target)
|
||
if err != nil {
|
||
t.Fatalf("ArchiveBeforeWrite #2: %v", err)
|
||
}
|
||
if archived2 {
|
||
t.Fatal("expected idempotent archive (no repeat)")
|
||
}
|
||
|
||
// 非保护路径不存档
|
||
tmp := filepath.Join(t.TempDir(), "x.txt")
|
||
os.WriteFile(tmp, []byte("x"), 0644)
|
||
archived3, err := ArchiveBeforeWrite(dir, tmp)
|
||
if err != nil {
|
||
t.Fatalf("ArchiveBeforeWrite non-protected: %v", err)
|
||
}
|
||
if archived3 {
|
||
t.Fatal("non-protected path should not archive")
|
||
}
|
||
}
|
||
|
||
func TestRestoreFileFromBaseline(t *testing.T) {
|
||
dir := t.TempDir()
|
||
|
||
// 同 TestArchiveBeforeWrite:用临时目录声明受保护路径,不碰真实 /etc
|
||
// (非 root 环境写 /etc 必然失败,会让断言在 CI 上假红)。
|
||
protected := t.TempDir()
|
||
SetProtectedPaths([]string{protected})
|
||
defer SetProtectedPaths(nil)
|
||
|
||
target := filepath.Join(protected, "restore_from_baseline.tmp")
|
||
if err := os.WriteFile(target, []byte("v1"), 0644); err != nil {
|
||
t.Fatalf("write target: %v", err)
|
||
}
|
||
|
||
ArchiveBeforeWrite(dir, target)
|
||
os.WriteFile(target, []byte("v2"), 0644)
|
||
|
||
restored, err := RestoreFileFromBaseline(dir, target)
|
||
if err != nil {
|
||
t.Fatalf("RestoreFileFromBaseline: %v", err)
|
||
}
|
||
if !restored {
|
||
t.Fatal("expected restore to happen")
|
||
}
|
||
data, _ := os.ReadFile(target)
|
||
if string(data) != "v1" {
|
||
t.Fatalf("restored content = %q, want v1", data)
|
||
}
|
||
}
|
||
|
||
func TestCaptureNetworkBaselineRoundtrip(t *testing.T) {
|
||
base, err := CaptureNetwork()
|
||
if err != nil {
|
||
t.Fatalf("CaptureNetwork: %v", err)
|
||
}
|
||
if base.CapturedAt == "" {
|
||
t.Fatal("captured_at empty")
|
||
}
|
||
if base.ResolvConf == "" {
|
||
t.Log("warning: no /etc/resolv.conf readable on this host")
|
||
}
|
||
|
||
dir := t.TempDir()
|
||
if err := SaveNetworkBaseline(dir, base); err != nil {
|
||
t.Fatalf("SaveNetworkBaseline: %v", err)
|
||
}
|
||
loaded, err := LoadNetworkBaseline(dir)
|
||
if err != nil {
|
||
t.Fatalf("LoadNetworkBaseline: %v", err)
|
||
}
|
||
if loaded.ResolvConf != base.ResolvConf {
|
||
t.Fatal("resolv.conf roundtrip mismatch")
|
||
}
|
||
if loaded.CapturedAt != base.CapturedAt {
|
||
t.Fatal("captured_at roundtrip mismatch")
|
||
}
|
||
}
|
||
|
||
func TestMismatchedFiles(t *testing.T) {
|
||
dir := t.TempDir()
|
||
base := &NetworkBaseline{ResolvConf: "# baselinetest", Hosts: "# hosts"}
|
||
// RestoreFiles 只写"当前内容与基线不同且基线非空"的文件;若 resolv.conf 恰好与
|
||
// 测试用的假基线一致仍存在,则跳过。此处以空字段基线验证幂等(不破坏真实 /etc)。
|
||
emptyBase := &NetworkBaseline{ResolvConf: "", Hosts: ""}
|
||
if changed, err := emptyBase.RestoreFiles(); err != nil {
|
||
t.Fatalf("RestoreFiles empty: %v", err)
|
||
} else if len(changed) > 0 {
|
||
t.Fatalf("empty baseline should change nothing, got %v", changed)
|
||
}
|
||
if base.Summary() == "" {
|
||
t.Fatal("summary empty")
|
||
}
|
||
if _, err := LoadNetworkBaseline(dir); err == nil {
|
||
t.Fatal("expected error loading missing baseline")
|
||
}
|
||
}
|
||
|
||
func TestExtraProxyKeys(t *testing.T) {
|
||
base := &NetworkBaseline{ProxyEnv: map[string]string{
|
||
"http_proxy": "http://p:8080",
|
||
"HTTP_PROXY": "http://p:8080",
|
||
"ENVIRONMENT": "FOO=bar",
|
||
}}
|
||
keys := base.ExtraProxyKeys()
|
||
if len(keys) != 1 || keys[0] != "ENVIRONMENT" {
|
||
t.Fatalf("ExtraProxyKeys = %v, want [ENVIRONMENT]", keys)
|
||
}
|
||
}
|