diff --git a/cmd/arcrun-app/diagnostics_engine_e2e_test.go b/cmd/arcrun-app/diagnostics_engine_e2e_test.go index fb2351a..8e64b38 100644 --- a/cmd/arcrun-app/diagnostics_engine_e2e_test.go +++ b/cmd/arcrun-app/diagnostics_engine_e2e_test.go @@ -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 不變的情況下,alive/last_sync/seconds_since_last_sync 三者都反映了子行程真的停了") + + // ── t213 phase 4(leo 08-08 附帶發現、本輪要修)── + // alive=false 這個狀態下,engine.detail/engine.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_error/detail 不該兩個都是空的——『為什麼沒在跑』答不出來了") + } + if !strings.Contains(reason, "知識庫") { + t.Fatalf("情境二:遮蔽把資訊遮成啞巴了——連監看資料夾叫什麼名字都看不出來:last_error=%q detail=%q", + stoppedOut.Local.Engine.LastError, stoppedOut.Local.Engine.Detail) + } + t.Logf("✅ t213 phase 4:路徑遮掉了,但『引擎沒在跑、以及為什麼(監看的是「知識庫」資料夾)』還讀得懂") } diff --git a/cmd/arcrun-app/diagnostics_export.go b/cmd/arcrun-app/diagnostics_export.go index 93d69d8..f2dfa50 100644 --- a/cmd/arcrun-app/diagnostics_export.go +++ b/cmd/arcrun-app/diagnostics_export.go @@ -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 已經算好的 // Progress/FailureBreakdown,不重新掃 manifest、不另算一套。 // ② 分類名稱字串只准住在 collector/progress.go 的 ClassifyFailure——本檔原樣照抄 @@ -18,6 +18,10 @@ package main // ③ 失敗檔名只出 basename,不出完整路徑(完整路徑會洩漏使用者的資料夾結構)—— // 沿用首頁既有的 buildSkipped():它的 Files 欄位本來就是 filepath.Base()+白話標籤, // 不是原始路徑,這裡直接借用,不重寫第二套。 +// ④(t213 phase 4,08-08 補)engine.detail/engine.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 4(leo 08-08 追加):headline/detail 沿用首頁同一套 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 4(leo 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.go/supervisor.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 +} + +// isURLBoundary=isPathBoundary 拿掉冒號(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 規則對照: diff --git a/cmd/arcrun-app/diagnostics_export_test.go b/cmd/arcrun-app/diagnostics_export_test.go index 4bf018a..99aa3c6 100644 --- a/cmd/arcrun-app/diagnostics_export_test.go +++ b/cmd/arcrun-app/diagnostics_export_test.go @@ -217,3 +217,105 @@ func TestMergeDiagnostics_NoLastSync_OmitsSecondsSinceLastSync(t *testing.T) { t.Fatalf("omitempty 沒生效:%s", raw) } } + +// ── t213 phase 4(leo 08-08 追加規則④):engine.detail/engine.last_error 不准帶絕對路徑 ── + +// containsAbsPath 是這批測試共用的「還有沒有絕對路徑」判準:不是只查 `/Users/` +// (那只是巧合常見的 macOS 家目錄),是查任何看起來像「本機路徑起手式」的樣子—— +// Unix 開頭的 `/`(前面是邊界字元或字串開頭)、Windows 磁碟代號 `X:\`; +// 跟 redactLocalPaths 一樣先放行 http(s) URL(`https://host/path` 的 `/` 不算本機路徑), +// 用同一組邊界原語(urlSpanAt/absPathPrefixLen)掃,不是重寫第二套判準。 +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) + } +}