diff --git a/local-agent/visiona-agent/app.go b/local-agent/visiona-agent/app.go index 6969470..18706b0 100644 --- a/local-agent/visiona-agent/app.go +++ b/local-agent/visiona-agent/app.go @@ -466,14 +466,60 @@ func (a *App) tryStartTunnel() { a.appLog("tunnel: manager created (relay=%s, local=%s, mock=%v, paired=%v)", relayURL, localAddr, mockMode, sessionToken != "") - // 有 session token 才立刻啟動;沒有則等使用者從 UI Pair() - if sessionToken != "" { + // 決定啟動時要不要立刻連 tunnel(純函式,方便單元測試;見 shouldAutoStartTunnel)。 + if shouldAutoStartTunnel(sessionToken, a.tokenStore, a.appLog) { if err := mgr.Start(a.ctx); err != nil { a.appLog("tunnel: start failed: %v", err) } } } +// shouldAutoStartTunnel 判斷 agent 啟動時是否要立刻連 tunnel(不需使用者從 UI 手動 Pair)。 +// +// 優先序(維持既有:env 優先): +// 1. envToken 有值(來自 VISIONA_SESSION_TOKEN)→ true。dev/demo 直接指定 token 的路徑。 +// 2. envToken 空 → 嘗試從 TokenStore 撈上次配對持久化的 token(token.bin): +// - Load 到非空 token → true(免重配對,這是修復核心)。 +// - Load 空(沒配對過 / 已 Unpair)→ false,等 UI Pair()。 +// - Load err(解密失敗 / I/O 錯誤)→ false,不擋啟動,等 UI Pair()。 +// +// 注意:回 true 走 Load 分支時,本函式不需要把 token 塞回 Manager config—— +// Manager.startLocked() 內部本來就會在 cfg.SessionToken == "" 時自己從 +// cfg.TokenStore.Load() 補 token(manager.go 的「嘗試補 SessionToken」段)。 +// 本函式只負責「要不要呼叫 Start()」這個決策。 +// +// 修復前的 bug:app.go 只在 envToken != "" 時才呼叫 mgr.Start(),envToken 空時 +// 直接跳過,導致 Manager 內部的 TokenStore.Load() 那條路永遠走不到,使用者每次 +// 重啟都要重新配對(token.bin 有存卻沒被讀)。 +// +// logf 用於記錄決策原因(傳 a.appLog);nil 時不記 log(測試方便)。 +func shouldAutoStartTunnel(envToken string, tokenStore tunnel.TokenStore, logf func(string, ...any)) bool { + log := func(format string, args ...any) { + if logf != nil { + logf(format, args...) + } + } + if envToken != "" { + return true + } + if tokenStore == nil { + log("tunnel: no token store; waiting for UI pairing") + return false + } + tok, err := tokenStore.Load() + if err != nil { + // Load 失敗(解密失敗 / I/O 錯誤)不擋啟動,只 log,維持未配對狀態等 UI Pair()。 + log("tunnel: token store load failed (%v); waiting for UI pairing", err) + return false + } + if tok != "" { + log("tunnel: found persisted session token; auto-starting tunnel (no re-pairing needed)") + return true + } + log("tunnel: no persisted session token; waiting for UI pairing") + return false +} + // runStartupStage5 處理 R5-E 階段 5:開瀏覽器。 // AutoOpenBrowser=false → SkipStage 進入階段 6(也會被 skip-timeout 規則處理) // AutoOpenBrowser=true → 呼叫 openBrowser 並 CompleteStage(5) diff --git a/local-agent/visiona-agent/tunnel_autostart_test.go b/local-agent/visiona-agent/tunnel_autostart_test.go new file mode 100644 index 0000000..206ca67 --- /dev/null +++ b/local-agent/visiona-agent/tunnel_autostart_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "errors" + "testing" + + "visiona-agent/internal/tunnel" +) + +// errTokenStore 是測試用 TokenStore,Load() 固定回傳指定的 token / error, +// 用來覆蓋 shouldAutoStartTunnel 的三種 Load 結果分支。 +type errTokenStore struct { + token string + loadErr error +} + +func (s *errTokenStore) Save(string) error { return nil } +func (s *errTokenStore) Load() (string, error) { return s.token, s.loadErr } +func (s *errTokenStore) Delete() error { return nil } + +// TestShouldAutoStartTunnel 覆蓋 bug 修復的決策邏輯: +// - env 有 token(優先序不變) +// - env 空 + tokenStore 有 token(修復核心:免重配對自動連) +// - env 空 + tokenStore 空(沒配對過 / Unpair 後:等 UI Pair,不自動連) +// - env 空 + tokenStore Load 失敗(解密/IO 錯誤:不擋啟動、不自動連) +// - env 空 + tokenStore 為 nil(fallback:不自動連) +func TestShouldAutoStartTunnel(t *testing.T) { + tests := []struct { + name string + envToken string + store tunnel.TokenStore + want bool + }{ + { + name: "env token set → start (env 優先,dev/demo 路徑不斷)", + envToken: "vAs_from_env", + store: &errTokenStore{token: "vAs_from_store"}, // 即使 store 也有,env 仍優先 + want: true, + }, + { + name: "env empty + store has token → start (修復核心:免重配對)", + envToken: "", + store: &errTokenStore{token: "vAs_from_store"}, + want: true, + }, + { + name: "env empty + store empty → no start (沒配對過 / Unpair 後等 UI Pair)", + envToken: "", + store: &errTokenStore{token: ""}, + want: false, + }, + { + name: "env empty + store load error → no start (不擋啟動、等 UI Pair)", + envToken: "", + store: &errTokenStore{loadErr: errors.New("decrypt failed")}, + want: false, + }, + { + name: "env empty + nil store → no start (fallback)", + envToken: "", + store: nil, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldAutoStartTunnel(tt.envToken, tt.store, nil) + if got != tt.want { + t.Errorf("shouldAutoStartTunnel(%q, %v) = %v, want %v", + tt.envToken, tt.store, got, tt.want) + } + }) + } +} + +// TestShouldAutoStartTunnel_EncryptedStoreRoundTrip 用真正的 +// EncryptedFileTokenStore(token.bin)驗證「Save 後重啟 → 自動連」的端到端行為, +// 對齊使用者實際情境(配對存 token.bin,重啟後應免重配對)。 +func TestShouldAutoStartTunnel_EncryptedStoreRoundTrip(t *testing.T) { + dir := t.TempDir() + + // 第一次配對:建立 store 並存 token(模擬配對流程的 Save)。 + store1, err := tunnel.NewEncryptedFileTokenStore(dir, nil) + if err != nil { + t.Fatalf("NewEncryptedFileTokenStore: %v", err) + } + if err := store1.Save("vAs_persisted_token"); err != nil { + t.Fatalf("Save: %v", err) + } + + // 模擬重啟:用同一個 dataDir 建新 store(token.bin 已存在),env 沒設 token。 + store2, err := tunnel.NewEncryptedFileTokenStore(dir, nil) + if err != nil { + t.Fatalf("NewEncryptedFileTokenStore (restart): %v", err) + } + if got := shouldAutoStartTunnel("", store2, nil); !got { + t.Error("重啟後(env 空 + token.bin 有 token)應自動連 tunnel,got false") + } + + // Unpair 後(Delete token):重啟應回到未配對、等 UI Pair。 + if err := store2.Delete(); err != nil { + t.Fatalf("Delete: %v", err) + } + store3, err := tunnel.NewEncryptedFileTokenStore(dir, nil) + if err != nil { + t.Fatalf("NewEncryptedFileTokenStore (after unpair): %v", err) + } + if got := shouldAutoStartTunnel("", store3, nil); got { + t.Error("Unpair 後(token.bin 已清)不應自動連 tunnel,got true") + } +}