fix(t213): 診斷檔遮蔽引擎錯誤訊息裡的本機絕對路徑

engine.detail/engine.last_error 在同步引擎沒在跑時,會把 collector 開機
橫幅的完整原文帶出來(direct.go RunDirect 的「監看 %s → %s」那行),其中
含監看資料夾的絕對路徑(如 /Users/xxx/Desktop/...)。這是上一輪(第二/
三輪)已發現但標記「附帶發現、未修」的洞——首頁在同一狀態下本來就會顯示
同一句話(不動,留給另案判斷),但診斷檔現在會被匯出成檔案交給外部人看,
風險層級跟留在托盤畫面上不同,這輪把它遮掉。

新增 redactLocalPaths(),只在 buildDiagnosticsPayload() 組裝 engine 欄位時
套用:把訊息裡看起來像本機絕對路徑的片段換成「…/<路徑最後一截>」,
http(s) URL 先放行原文(避免 URL 裡的 / 被誤判成本機路徑)。只動
diagnostics_export.go 這一端,不改 describeStatus()/collectorFailure()/
direct.go 的訊息本身——那些同時是首頁托盤畫面在用的同一組憑據。

擴充 diagnostics_engine_e2e_test.go(真執行檔+真 supervisor 子行程,零
網路零帳號):監看資料夾改用真實絕對路徑,證明子行程停擺後整份匯出 JSON
找不到該路徑,同時 last_error/detail 仍讀得出「引擎沒在跑、以及為什麼」
(含監看資料夾的名字)。另加 8 個 redactLocalPaths 純函式單測。

go build/go vet/go test ./...(collector+arcrun-app+supervisor 三個
package)全綠;diagnostics_stage_manual_test.go 維持預設 SKIP,本輪未碰
網路/stage。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 16:36:20 +08:00
parent 4cf09bf8ca
commit 0c6fb43117
3 changed files with 286 additions and 5 deletions
+39 -1
View File
@@ -18,8 +18,10 @@ package main
// 不需要任何雲端憑證,任何機器上都能跑、都穩定。
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
@@ -42,12 +44,23 @@ func TestDiagnosticsEngine_ReflectsRealSupervisorLifecycle(t *testing.T) {
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
// t213 phase 4:監看資料夾要是一個**真的絕對路徑**,才能重現「collector 開機橫幅把
// 監看路徑印進 stderr → supervisor 記成 LastError → 診斷檔把它原樣帶出去」這個洞
// (見 direct.go RunDirect 的「監看 %s → %s」那行;leo 08-08 附帶發現、本輪要修的問題)。
watchDir := filepath.Join(home, "知識庫")
if err := os.MkdirAll(watchDir, 0o755); err != nil {
t.Fatalf("建監看資料夾失敗:%v", err)
}
// 需要至少一個帳號:LoadDirectConfig 會拒絕零帳號的設定(「缺必填欄位:accounts」,
// 連迴圈都進不去),但這個帳號**不必連得上**——cypher_url 刻意指向一個解析不出來的
// 網址,驗證的是「心跳(LastSync)跟著子行程死活走」這條機制,不是雲端連線本身。
cfg := &directConfig{
Manifest: filepath.Join(home, ".arcrun-rag", "manifest.json"),
Accounts: []accountCfg{{CypherURL: "https://example.invalid.test", Namespace: "test", APIKey: "test"}},
Accounts: []accountCfg{{
CypherURL: "https://example.invalid.test", Namespace: "test", APIKey: "test",
WatchFolders: []string{watchDir},
}},
}
if err := saveCfg(cfg); err != nil {
t.Fatalf("存設定失敗:%v", err)
@@ -110,4 +123,29 @@ func TestDiagnosticsEngine_ReflectsRealSupervisorLifecycle(t *testing.T) {
t.Fatal("情境二:alive=false 時 headline 不該是空的——這是給人看的『還在不在跑』答案")
}
t.Logf("✅ Q2 證據鏈完整:pending 不變的情況下,alivelast_syncseconds_since_last_sync 三者都反映了子行程真的停了")
// ── t213 phase 4leo 08-08 附帶發現、本輪要修)──
// alive=false 這個狀態下,engine.detailengine.last_error 過去會帶出 collector 開機
// 橫幅的完整原文,其中含監看資料夾的絕對路徑(見 direct.go RunDirect 的
// 「監看 %s → %s」那行)。這裡的 watchDir 就是刻意設成真實絕對路徑,用來重現這個洞:
// 驗證整份匯出 JSON 裡找不到它,同時確認「引擎沒在跑、以及為什麼」仍然讀得懂
// (不能把資訊遮成啞巴——資料夾最後一截「知識庫」要還在)。
if strings.Contains(string(stoppedJSON), watchDir) {
t.Fatalf("情境二:整份診斷檔仍然帶出監看資料夾的絕對路徑 %q:%s", watchDir, stoppedJSON)
}
if strings.Contains(string(stoppedJSON), home) {
t.Fatalf("情境二:整份診斷檔仍然帶出本機家目錄路徑 %q:%s", home, stoppedJSON)
}
if containsAbsPath(string(stoppedJSON)) {
t.Fatalf("情境二:整份診斷檔(掃全文,不只 engine 兩個欄位)仍有本機絕對路徑殘留:%s", stoppedJSON)
}
reason := stoppedOut.Local.Engine.LastError + stoppedOut.Local.Engine.Detail
if reason == "" {
t.Fatal("情境二:alive=false 時 last_errordetail 不該兩個都是空的——『為什麼沒在跑』答不出來了")
}
if !strings.Contains(reason, "知識庫") {
t.Fatalf("情境二:遮蔽把資訊遮成啞巴了——連監看資料夾叫什麼名字都看不出來:last_error=%q detail=%q",
stoppedOut.Local.Engine.LastError, stoppedOut.Local.Engine.Detail)
}
t.Logf("✅ t213 phase 4:路徑遮掉了,但『引擎沒在跑、以及為什麼(監看的是「知識庫」資料夾)』還讀得懂")
}
+145 -4
View File
@@ -10,7 +10,7 @@ package main
// 行程本體,本機這半直接讀 status.json,雲端那半改打新增的 X-Arcrun-API-Key 版
// 端點(GET /portal/daemon/diagnostics,免帳密,daemon 背景行程沒有 portal session)。
//
// 🔴 leo 08-08 條追加規則:
// 🔴 leo 08-08 條追加規則:
// ① 首頁與診斷檔必須是同一組數字——本檔**只讀** status.json 裡 t210 已經算好的
// ProgressFailureBreakdown,不重新掃 manifest、不另算一套。
// ② 分類名稱字串只准住在 collector/progress.go 的 ClassifyFailure——本檔原樣照抄
@@ -18,6 +18,10 @@ package main
// ③ 失敗檔名只出 basename,不出完整路徑(完整路徑會洩漏使用者的資料夾結構)——
// 沿用首頁既有的 buildSkipped():它的 Files 欄位本來就是 filepath.Base()+白話標籤,
// 不是原始路徑,這裡直接借用,不重寫第二套。
// ④(t213 phase 408-08 補)engine.detailengine.last_error 一樣不准出現本機絕對
// 路徑——同③的理由,只是這次的路徑來源是 supervisor 捕到的 stderr 末行(collector
// 啟動時印的「監看 <資料夾路徑>」那行),不是檔名。做法見 redactLocalPaths():換成
// 「…/<路徑最後一截>」,不是整句吃掉——「引擎沒在跑、以及為什麼」仍要答得出來。
import (
"encoding/json"
"fmt"
@@ -161,10 +165,15 @@ func (a *App) buildDiagnosticsPayload() exportedDiagnostics {
Syncing: collectorSyncing(),
CrashLooping: looping,
Restarts: restarts,
Headline: headline,
Detail: detail,
// t213 phase 4leo 08-08 追加):headlinedetail 沿用首頁同一套 describeStatus()
// 但 detail 在 !alive 時會把 collectorFailure() 的死因原文接進來(見 describeStatus
// 的「原因:」那幾行)——那句死因常常就是 supervisor 捕到的 stderr 末行,可能帶著
// 監看資料夾的本機絕對路徑(見下面 redactLocalPaths 的長註解)。這裡只遮蔽「要匯出
// 成檔案」的這一份,describeStatus() 本身/首頁托盤畫面完全不動。
Headline: redactLocalPaths(headline),
Detail: redactLocalPaths(detail),
}
engine.LastError = engineLastErrorFor(alive, looping, lastErr)
engine.LastError = redactLocalPaths(engineLastErrorFor(alive, looping, lastErr))
cfg, _ := loadCfg()
accounts := make([]accountDiagnostics, 0, len(cfg.Accounts))
@@ -201,6 +210,138 @@ func engineLastErrorFor(alive, crashLooping bool, lastErr string) string {
return ""
}
// redactLocalPaths — t213 phase 4leo 08-08 追加規則④)。
//
// 背景:`engine.detail``engine.last_error` 的內容有一部分來自 collectorFailure() 的
// 死因原文,那句話是 supervisor 捕到的 stderr **末行**(見 supervisor.go runOnce 的
// stderr scanner)——常見的情況是子行程剛啟動就掛,最後一行 stderr 停在
// direct.go RunDirect 印的開機橫幅:「collector direct daemon 啟動:監看 <資料夾絕對路徑>
// → <目的地>」,也可能是任何 os.PathError 形狀的訊息(如「open /Users/x/y: permission
// denied」)。兩種形狀都以本機絕對路徑開頭——這條路徑會洩漏使用者的目錄結構(含帳號名,
// 例如 /Users/<帳號>/... 或 C:\Users\<帳號>\...),跟既有的「失敗檔名只出 basename」
// (見 buildSkipped())是同一個理由,只是換了個欄位發作。
//
// 怎麼遮:只挖掉「路徑本體」,換成「…/<路徑最後一截>」——資料夾/檔名本身保留,
// 訊息的其餘文字原封不動。這樣「引擎沒在跑、以及為什麼」依然讀得懂(不能把資訊遮成啞巴),
// 只是看不出這是使用者電腦上的哪個帳號、哪層目錄。
//
// 只在這裡(診斷檔匯出的最後一哩)做,不改 direct.gosupervisor.go 的訊息本身,也不改
// describeStatus()collectorFailure():那兩支同時也是首頁托盤畫面在用的同一組憑據,
// 動了會連首頁畫面一起變——超出本次「只動診斷檔這一端」的範圍(首頁要不要一起改,
// 留給總管/leo 另外決定,見交接紀錄)。
func redactLocalPaths(s string) string {
if s == "" {
return s
}
r := []rune(s)
n := len(r)
var b strings.Builder
for i := 0; i < n; {
// http(s) URL 先放行,原文照抄——避免其中的 `/`(如 .../webhooks/named/...
// 被下面的「本機路徑」判斷誤吃(URL 不是本機目錄結構,不在遮蔽範圍內)。
if j, ok := urlSpanAt(r, i); ok {
b.WriteString(string(r[i:j]))
i = j
continue
}
if plen := absPathPrefixLen(r, i); plen > 0 {
start := i
j := i + plen
for j < n && !isPathBoundary(r[j]) {
j++
}
b.WriteString("…/")
b.WriteString(lastPathSegment(string(r[start:j])))
i = j
continue
}
b.WriteRune(r[i])
i++
}
return b.String()
}
// urlSpanAt 偵測 i 處是不是 http(s) URL 的起點(必須在字詞邊界上,避免咬到中間),
// 回傳 URL 一路唸到下一個 URL 邊界字元為止的結尾位置。
//
// 🔴 這裡不能直接用 isPathBoundary 當終止點——URL 裡的 `://` scheme 分隔符與
// `:port` 都合法含有冒號,若拿冒號當終止點,`https://` 掃到第一個 `:` 就會被腰斬成
// 「https」,剩下的 `//host/path` 又會被 absPathPrefixLen 誤判成本機路徑(因為冒號
// 本身是 isPathBoundary 認得的邊界字元,讓緊接在後面的 `/` 通過起點檢查)——
// 這正是這支函式存在的理由,用專屬的終止點集合(isPathBoundary 拿掉冒號)。
func urlSpanAt(r []rune, i int) (int, bool) {
if i > 0 && !isPathBoundary(r[i-1]) {
return 0, false
}
rest := string(r[i:min(i+8, len(r))])
if !strings.HasPrefix(rest, "http://") && !strings.HasPrefix(rest, "https://") {
return 0, false
}
j := i
for j < len(r) && !isURLBoundary(r[j]) {
j++
}
return j, true
}
// isURLBoundaryisPathBoundary 拿掉冒號(URL 合法含冒號,見上面 urlSpanAt 的註解)。
func isURLBoundary(r rune) bool {
if r == ':' {
return false
}
return isPathBoundary(r)
}
// absPathPrefixLen 判斷 i 是不是一個本機絕對路徑的起點,回傳「路徑根部」本身的字元數
// Unix `/` 算 1 個;Windows 磁碟代號 `C:\``C:/` 算 3 個)。只在「前一個字元是邊界
// 字元(或 i==0)」時才算數——避免把一般文字裡湊巧出現的字元誤判成路徑起點。
func absPathPrefixLen(r []rune, i int) int {
if i > 0 && !isPathBoundary(r[i-1]) {
return 0
}
if r[i] == '/' {
return 1
}
if i+2 < len(r) && isASCIILetter(r[i]) && r[i+1] == ':' && (r[i+2] == '\\' || r[i+2] == '/') {
return 3
}
return 0
}
func isASCIILetter(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
}
// isPathBoundary=這個字元不可能是路徑本體的一部分,可以拿來當「路徑到這裡結束」的判準
// (空白、中西文標點、箭頭)。本檔目前已知的兩種訊息形狀都靠它正確截斷:
// 「監看 A、B → url」用「、」「→」收尾;`os.PathError` 的「path: 原因」用「:」收尾。
// 刻意不把純空白以外的一般文字字元(含資料夾名裡常見的空格)當終止點以外的東西處理——
// 資料夾名稱裡的空格會讓比對在空格處提早停下,不會吃到後面不相干的文字,這是刻意的保守
// 選擇:寧可少遮一點(漏出資料夾名稱片段),也不要多遮(把後面的說明文字一起吃掉)。
func isPathBoundary(r rune) bool {
switch r {
case ' ', '\t', '\n', '\r',
'、', '', '', '(', ')',
',', ';', '', '', ':', '→',
'"', '\'', '<', '>':
return true
}
return false
}
// lastPathSegment 取路徑最後一截(跨 `/`、`\` 兩種分隔符,不假設執行平台),
// 拿不到任何一截(例如整條路徑只有根目錄)時回一個看得懂的佔位字,不留空字串。
func lastPathSegment(p string) string {
p = strings.TrimRight(p, `/\`)
if idx := strings.LastIndexAny(p, `/\`); idx >= 0 {
p = p[idx+1:]
}
if p == "" {
return "本機路徑"
}
return p
}
// mergeDiagnostics 純函式:把已經各自拿到的本機/雲端資料組成最終輸出形狀,不做任何 IO。
//
// leo 08-08 規則對照:
+102
View File
@@ -217,3 +217,105 @@ func TestMergeDiagnostics_NoLastSync_OmitsSecondsSinceLastSync(t *testing.T) {
t.Fatalf("omitempty 沒生效:%s", raw)
}
}
// ── t213 phase 4leo 08-08 追加規則④):engine.detailengine.last_error 不准帶絕對路徑 ──
// containsAbsPath 是這批測試共用的「還有沒有絕對路徑」判準:不是只查 `/Users/`
// (那只是巧合常見的 macOS 家目錄),是查任何看起來像「本機路徑起手式」的樣子——
// Unix 開頭的 `/`(前面是邊界字元或字串開頭)、Windows 磁碟代號 `X:\`
// 跟 redactLocalPaths 一樣先放行 http(s) URL`https://host/path` 的 `/` 不算本機路徑),
// 用同一組邊界原語(urlSpanAtabsPathPrefixLen)掃,不是重寫第二套判準。
func containsAbsPath(s string) bool {
r := []rune(s)
for i := 0; i < len(r); {
if j, ok := urlSpanAt(r, i); ok {
i = j // 整段 URL 跳過,不是只跳一個字元——URL 內部的 `/` 不算路徑起點
continue
}
if absPathPrefixLen(r, i) > 0 {
return true
}
i++
}
return false
}
func TestRedactLocalPaths_StartupBannerHidesWatchedFolder(t *testing.T) {
// 這是真實會撞到的那句話(direct.go RunDirect 的開機橫幅),也是這輪要修的洞。
in := `collector direct daemon 啟動:監看 /Users/leo/Desktop/知識庫 → https://arcrun-cypher-executor.youlin-hsieh-dev.workers.dev/webhooks/named/ns/rag_ingest/trigger(每 5s 掃一輪)`
got := redactLocalPaths(in)
if containsAbsPath(got) {
t.Fatalf("redactLocalPaths 沒有把絕對路徑遮掉:%q", got)
}
// 遮蔽不能把資訊遮成啞巴——資料夾最後一截、目的地 URL、輪詢間隔都還要讀得出來。
for _, want := range []string{"知識庫", "webhooks/named/ns/rag_ingest/trigger", "每 5s 掃一輪", "監看"} {
if !strings.Contains(got, want) {
t.Fatalf("redactLocalPaths 把資訊遮成啞巴了,找不到 %q:%q", want, got)
}
}
}
func TestRedactLocalPaths_MultipleFoldersAllHidden(t *testing.T) {
in := `監看 /Users/leo/kb1、/Users/leo/kb2 → https://x.dev/a、https://x.dev/b(每 5s 掃一輪)`
got := redactLocalPaths(in)
if containsAbsPath(got) {
t.Fatalf("多資料夾時仍有絕對路徑殘留:%q", got)
}
if !strings.Contains(got, "…/kb1") || !strings.Contains(got, "…/kb2") {
t.Fatalf("每個資料夾的最後一截應保留(只是加上佔位前綴):%q", got)
}
}
func TestRedactLocalPaths_WindowsDrivePath(t *testing.T) {
in := `collector direct: config JSON 解析失敗:C:\Users\Leo\AppData\Roaming\arcrun-rag\config.json: unexpected end of JSON input`
got := redactLocalPaths(in)
if containsAbsPath(got) {
t.Fatalf("Windows 路徑沒有被遮掉:%q", got)
}
if !strings.Contains(got, "config.json") {
t.Fatalf("檔名本身應保留:%q", got)
}
if !strings.Contains(got, "unexpected end of JSON input") {
t.Fatalf("錯誤原因不該被路徑遮蔽波及:%q", got)
}
}
func TestRedactLocalPaths_OSPathError(t *testing.T) {
// os.PathError 的標準形狀:「<動作> <路徑>: <原因>」。
in := `status 寫入失敗(不擋看守):open /Users/leo/.arcrun-rag/status.json: permission denied`
got := redactLocalPaths(in)
if containsAbsPath(got) {
t.Fatalf("os.PathError 形狀的訊息沒有被遮掉:%q", got)
}
if !strings.Contains(got, "status.json") || !strings.Contains(got, "permission denied") {
t.Fatalf("檔名與原因應保留:%q", got)
}
}
func TestRedactLocalPaths_NoPathPassthroughUnchanged(t *testing.T) {
for _, in := range []string{"", "exit status 1", "config JSON 解析失敗", "已自動重試 3 次都失敗,所以重新開啟也沒有用。"} {
if got := redactLocalPaths(in); got != in {
t.Fatalf("沒有路徑的字串不該被改動:in=%q got=%q", in, got)
}
}
}
func TestRedactLocalPaths_URLNotMistakenForLocalPath(t *testing.T) {
in := `連不上你的知識庫:Get "https://arcrun-cypher-executor.example.workers.dev/webhooks/named/ns/trigger": dial tcp: no such host`
got := redactLocalPaths(in)
if got != in {
t.Fatalf("純 URL(無本機路徑)不該被改動:got %q want %q", got, in)
}
}
func TestMergeDiagnostics_EngineDoesNotRedact(t *testing.T) {
// 對照組:redactLocalPaths 是 buildDiagnosticsPayload 的事,mergeDiagnostics 本身
// 仍要「原樣接住」呼叫端已經處理好的字串(同 TestMergeDiagnostics_EngineCarriesLiveStateVerbatim
// 的精神)——已經遮過的字串進來,出去也要一字不改。
engine := engineDiagnostics{Alive: false, LastError: "…/kb(已遮蔽)", Detail: "原因:…/kb(已遮蔽)"}
out := mergeDiagnostics(syncStatus{}, nil, "dev", UpdateInfo{}, engine, nil)
if out.Local.Engine.LastError != engine.LastError || out.Local.Engine.Detail != engine.Detail {
t.Fatalf("mergeDiagnostics 不該再動已經處理好的 engine 字串:%+v", out.Local.Engine)
}
}