4d3a6a09a6
leo 08-06 裁決:「不要兩支,寫成一支檔案」。
## 為什麼
v0.18.7-8 的「單一 exe」其實是**一支包著另一支**:collector.exe 被 go:embed
進 Arcrun.exe,執行時攤到 ~/.arcrun-rag/bin/ 再跑。
那正是防毒軟體眼中的 dropper 特徵 —— 封測者實撞
`Trojan:Win32/Sabsik.FL.A!ml`,檔案當場被隔離、自動刪除。
⚠️ 誠實界定:`!ml` 結尾=**機器學習判定**,Sabsik 是最常見的通用誤判家族,
主因是「未簽章+下載次數少」,**不是**特別指向 dropper 行為。
所以本次改動**不保證**解除誤判——真正的解是上架 MS Store(微軟簽章)。
但「執行時把第二支 PE 寫到磁碟再執行」本來就該拿掉,這是對的方向且順手變小。
## 怎麼做
- `collector/` 39 個檔 `package main` → `package collector`,`main()` → 匯出的 `Run(args) int`
- 新增 `collector/cmd/collector/`(薄殼 CLI,讓單獨跑 collector 這條路仍可用)
- App 直接 import 該套件;`main()` 第一件事就判 `--collector`,是的話走 `collector.Run` 不碰 GUI
- `supervisor` 加 `ArgPrefix`,App 把 `BinPath` 指向 `os.Executable()` 自己
- 刪掉 `bundled_collector_{windows,other}.go`(embed + 攤檔那套)
- 三支打包腳本不再編/複製第二支;版本注入同時打到兩個 package
- build-win.sh 的機械閘改成**直接問它**:`--collector --version` 回得出版本才放行
(舊閘是比大小,只能證明「有 embed」,證明不了「分派是對的」)
## 驗(真機實跑)
· `.app/Contents/MacOS/` 只有 **一個** 執行檔(原本兩個)
· 跑起來兩個行程是**同一個 exe**:
…/MacOS/arcrun-app
…/MacOS/arcrun-app --collector direct --config …
· `~/.arcrun-rag/bin` **不存在**(沒有任何東西被攤出來)
· 端到端:丟檔進看守資料夾 → collector.log `"status":"ingested","http_status":200`
· `lsappinfo` 仍是 `type="UIElement"`、`Version="0.18.9"`
· collector 39 檔測試全過;app 測試過;go vet 全綠;mac + windows 交叉編譯皆過
· 單檔 26MB → 22MB(不再夾帶第二份完整程式)
148 lines
5.1 KiB
Go
148 lines
5.1 KiB
Go
// direct_t182_test.go — t182:老 config 必須被**抹除**成 Workers AI(leo 2026-08-04)。
|
||
//
|
||
// 「如果是我的 config 保持舊的,那新版裝上就要檢查,因為已經是 default worker AI,
|
||
// **就要抹除改成用 Workers AI**,如果保持 Gemini 它不會改掉,**那就是失敗的**」
|
||
//
|
||
// 這組測試守的是 leo 08-04 實撞的三個真 bug:
|
||
// 1. 更新到 v0.15.5 後托盤**兩個帳號都還顯示 Gemini**(帳號層舊值沒清)
|
||
// 2. 丟 PDF 進去**產不出卡**(因為根本沒走到 Workers AI,還在跑 Gemini)
|
||
// 3. `workers-ai` 不在合法值清單裡 ⇒ 一旦寫進 config,daemon 直接起不來
|
||
package collector
|
||
|
||
import (
|
||
"encoding/json"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
// t182①:老 config(extractor=gemma、帳號層也是 gemma、無 explicit)
|
||
// → 每一層都要被抹成 workers-ai,而且**要寫回檔案**。
|
||
func TestT182ErasesLegacyExtractorAllLayers(t *testing.T) {
|
||
dir := t.TempDir()
|
||
p := writeDirectConfig(t, dir, map[string]any{
|
||
"manifest": filepath.Join(dir, "m.json"),
|
||
"extractor": "gemma", // 舊值:頂層
|
||
"gemini_api_key": "old-key",
|
||
// 注意:**沒有** extractor_explicit ⇒ 使用者從沒主動選過
|
||
"accounts": []map[string]any{
|
||
{
|
||
"cypher_url": "https://a.example", "namespace": "nsA",
|
||
"watch_folders": []string{"/tmp/a"},
|
||
"extractor": "gemma", // 舊值:帳號層(leo 的 config 正是這樣)
|
||
},
|
||
{
|
||
"cypher_url": "https://b.example", "namespace": "nsB",
|
||
"watch_folders": []string{"/tmp/b"},
|
||
"extractor": "claude", // 更舊的殘留值
|
||
},
|
||
},
|
||
})
|
||
|
||
cfg, err := LoadDirectConfig(p)
|
||
if err != nil {
|
||
t.Fatalf("LoadDirectConfig: %v", err)
|
||
}
|
||
if cfg.Extractor != "workers-ai" {
|
||
t.Errorf("① 頂層沒被抹除:got %q want workers-ai", cfg.Extractor)
|
||
}
|
||
for i, a := range cfg.Accounts {
|
||
if a.Extractor != "workers-ai" {
|
||
t.Errorf("① 帳號[%d] 沒被抹除:got %q want workers-ai(leo 實撞:兩個帳號都還顯示 Gemini)", i, a.Extractor)
|
||
}
|
||
}
|
||
|
||
// 金鑰要留著——Gemini 只是變選配,不是廢除(leo:「客戶說他要用 Gemini」)
|
||
if cfg.GeminiAPIKey != "old-key" {
|
||
t.Errorf("① Gemini 金鑰不該被清掉:got %q", cfg.GeminiAPIKey)
|
||
}
|
||
|
||
// 真的寫回檔案了嗎?(只改記憶體 ⇒ 托盤是另一個行程,還是會念 Gemini)
|
||
raw, err := os.ReadFile(p)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var onDisk struct {
|
||
Extractor string `json:"extractor"`
|
||
Accounts []struct {
|
||
Extractor string `json:"extractor"`
|
||
} `json:"accounts"`
|
||
}
|
||
if err := json.Unmarshal(raw, &onDisk); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if onDisk.Extractor != "workers-ai" {
|
||
t.Errorf("① **沒寫回檔案**:磁碟上頂層仍為 %q ⇒ 托盤下次讀還是念 Gemini", onDisk.Extractor)
|
||
}
|
||
for i, a := range onDisk.Accounts {
|
||
if a.Extractor != "workers-ai" {
|
||
t.Errorf("① **沒寫回檔案**:磁碟上帳號[%d] 仍為 %q", i, a.Extractor)
|
||
}
|
||
}
|
||
}
|
||
|
||
// t182②:主動選過 Gemini 的人不被動到(Gemini 是選配、不是廢除)。
|
||
func TestT182KeepsExplicitGeminiChoice(t *testing.T) {
|
||
dir := t.TempDir()
|
||
p := writeDirectConfig(t, dir, map[string]any{
|
||
"manifest": filepath.Join(dir, "m.json"),
|
||
"extractor": "gemma",
|
||
"extractor_explicit": true, // ← 使用者在「AI 設定…」主動選過
|
||
"gemini_api_key": "my-key",
|
||
"accounts": []map[string]any{
|
||
{"cypher_url": "https://a.example", "namespace": "nsA", "watch_folders": []string{"/tmp/a"}},
|
||
},
|
||
})
|
||
cfg, err := LoadDirectConfig(p)
|
||
if err != nil {
|
||
t.Fatalf("LoadDirectConfig: %v", err)
|
||
}
|
||
if cfg.Extractor != "gemma" {
|
||
t.Errorf("② 主動選過 Gemini 不該被抹掉:got %q", cfg.Extractor)
|
||
}
|
||
}
|
||
|
||
// t182③:`workers-ai` 必須是合法值——否則寫進 config 後 daemon 直接起不來。
|
||
// (v0.15.5 就踩到這顆:預設改了、驗證器沒跟上。)
|
||
func TestT182WorkersAIIsValidExtractor(t *testing.T) {
|
||
dir := t.TempDir()
|
||
p := writeDirectConfig(t, dir, map[string]any{
|
||
"manifest": filepath.Join(dir, "m.json"),
|
||
"extractor": "workers-ai",
|
||
"extractor_explicit": true,
|
||
"accounts": []map[string]any{
|
||
{"cypher_url": "https://a.example", "namespace": "nsA", "watch_folders": []string{"/tmp/a"}},
|
||
},
|
||
})
|
||
if _, err := LoadDirectConfig(p); err != nil {
|
||
t.Fatalf("③ workers-ai 應為合法值,卻載入失敗:%v", err)
|
||
}
|
||
}
|
||
|
||
// t182④:冪等——已是 workers-ai 再載一次不應改動任何東西。
|
||
func TestT182MigrationIdempotent(t *testing.T) {
|
||
dir := t.TempDir()
|
||
p := writeDirectConfig(t, dir, map[string]any{
|
||
"manifest": filepath.Join(dir, "m.json"),
|
||
"extractor": "workers-ai",
|
||
"accounts": []map[string]any{
|
||
{"cypher_url": "https://a.example", "namespace": "nsA",
|
||
"watch_folders": []string{"/tmp/a"}, "extractor": "workers-ai"},
|
||
},
|
||
})
|
||
before, err := os.ReadFile(p)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := LoadDirectConfig(p); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
after, err := os.ReadFile(p)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if string(before) != string(after) {
|
||
t.Error("④ 已是 workers-ai 不該再改寫檔案(非冪等=每次啟動都寫一次磁碟)")
|
||
}
|
||
}
|